diff --git a/.claude/skills/kaizen-research/SKILL.md b/.claude/skills/kaizen-research/SKILL.md index 2a829906b..8b9ae40f9 100644 --- a/.claude/skills/kaizen-research/SKILL.md +++ b/.claude/skills/kaizen-research/SKILL.md @@ -29,12 +29,50 @@ Friday early morning (~6am MT). `kaizen-review-prep` runs ~2 hours later (~8am M - https://aws.amazon.com/blogs/machine-learning/ (filter: bedrock, agentcore) - Filter to: Bedrock, AgentCore, Bedrock Agents, Knowledge Bases, Guardrails, model availability/region/quota changes. -2. **Strands Agents SDK** - - https://github.com/strands-agents/sdk-python/releases - - https://github.com/strands-agents/sdk-python/blob/main/CHANGELOG.md - - https://github.com/strands-agents/sdk-python/issues?q=is%3Aissue+sort%3Aupdated-desc +2. **Strands Agents SDK** — note the repo moved: `strands-agents/sdk-python` now + redirects to **`strands-agents/harness-sdk`** (a monorepo; the Python SDK lives + under `strands-py/`). Use the new name — `gh` search against the old one errors. + - https://github.com/strands-agents/harness-sdk/releases + - https://github.com/strands-agents/harness-sdk/blob/main/strands-py/CHANGELOG.md + - https://github.com/strands-agents/harness-sdk/issues?q=is%3Aissue+sort%3Aupdated-desc - For each new release, identify: breaking changes, new hooks/features, fixes that map to current usage in `backend/src/agents/main_agent/`. +2a. **Prompt caching across providers — standing watch (added 2026-09-05)** + + We carry custom code that exists only because Strands' caching support is + Bedrock/Anthropic-shaped. Upstream is converging on a provider-agnostic + `CacheConfig`, so this is a **subtraction** item: each upstream landing should + delete code here, not add it. Check every run, and check it for *all* cacheable + families — Anthropic, OpenAI/GPT, and any newly cacheable model — not just the + one that prompted this. + + Our carried code, and what would retire it: + + | Ours | Retired by | + |---|---| + | `usage_normalization.py` — maps `cache_write_tokens`, which Strands drops | [harness-sdk#4193](https://github.com/strands-agents/harness-sdk/pull/4193) (ours) merging + releasing | + | `usage_normalization.py` — disjointness shim for OpenAI's inclusive `input_tokens` | [harness-sdk#3546](https://github.com/strands-agents/harness-sdk/issues/3546) landing a `Usage` convention contract | + | `build_prompt_cache_key()` in `bedrock_responses.py` | `strands/models/_openai_cache.py::apply_cache_config` — **already on upstream main**, maps `CacheConfig.cache_key` → `prompt_cache_key`. Adopt on the next pin bump. | + | `apply_explicit_prompt_cache()` (breakpoints) | No upstream equivalent yet — `apply_cache_config` emits no `prompt_cache_breakpoint`. Ours is OFF by default (measured 57% worse); don't re-enable without re-running the probe. | + | `cache_ttl_seconds_for()` in `observability/prompt_cache.py` | A model-derived TTL upstream. Note `apply_cache_config` maps ttl to `prompt_cache_retention` (`in_memory`/`24h`), *not* GPT-5.6's `prompt_cache_options.ttl: "30m"` — so these are not yet the same concept. | + + Each run, answer: + - Does the pinned Strands version now ship `_openai_cache.py` / a `CacheConfig` + that covers a provider we hand-roll? If so, propose the swap and say which of + our modules shrinks. + - Did `CacheConfig` gain a field (`cache_key`, `tools_ttl`, `system_prompt_ttl`, …) + that maps onto something we do manually? + - Any new Bedrock model family with prompt caching? Confirm which API surface + serves it — GPT-5.6 caches **only** over the Responses API, and the same model + over Converse caches not at all. + - Movement on #3546 / #4193, or a new `Usage` convention. Both change what our + cost math may assume. + + ⚠️ Never adopt an upstream caching default on inspection alone. This stack has + already shipped one caching change whose premise was wrong and cost ~57% more + in a live measurement. `backend/scripts/probe_gpt56_cache_rates.py` is the gate: + beat the current arm, measured, before switching. + 3. **Reference repo — `aws-samples/sample-strands-agent-with-agentcore`** - https://github.com/aws-samples/sample-strands-agent-with-agentcore/commits/main - Diff the last 7 days (or "since last research run" — whichever is longer). Identify new patterns, removed approaches, or fixes that map to constructs in this repo: agent setup, tool registration, AgentCore Identity flows, Memory configuration, Gateway/MCP wiring. diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index a7f41ce29..737263590 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -10,6 +10,22 @@ # all Run all tracks with default branches # (tests + deploy + e2e on develop) # +# MUST be `main` or `develop`. Any other branch is rejected with an +# error. This is a security boundary, not an oversight: the workflow is +# triggered by `schedule`/`workflow_dispatch`, so it runs in the context of the +# default branch and its jobs can WRITE the default-branch GitHub Actions cache +# scope. Checking out an arbitrary ref would let unreviewed code execute while +# holding that cache token and poison entries that privileged workflows later +# restore (CWE-349; CodeQL actions/cache-poisoning/*). +# +# The refs are assigned as literal strings in the parser below rather than +# sliced out of the track token — keep it that way, so attacker-influenced +# text never reaches a `ref:`. +# +# To exercise a feature branch, open a pull request: tests.yml runs per-PR in a +# non-privileged context. To make another long-lived branch nightly-testable, +# add explicit cases for it in the parser below. +# # Examples: # test-backend-develop # test-frontend-main,test-backend-main @@ -121,25 +137,58 @@ jobs: run_e2e=true e2e_ref="develop" ;; - test-backend-*) + test-backend-main) + run_test_backend=true + test_backend_ref="main" + ;; + test-backend-develop) run_test_backend=true - test_backend_ref="${token#test-backend-}" + test_backend_ref="develop" ;; - test-frontend-*) + test-frontend-main) run_test_frontend=true - test_frontend_ref="${token#test-frontend-}" + test_frontend_ref="main" ;; - deploy-*) + test-frontend-develop) + run_test_frontend=true + test_frontend_ref="develop" + ;; + deploy-main) run_deploy=true - deploy_ref="${token#deploy-}" + deploy_ref="main" ;; - scan-images-*) + deploy-develop) + run_deploy=true + deploy_ref="develop" + ;; + scan-images-main) run_scan_images=true - scan_images_ref="${token#scan-images-}" + scan_images_ref="main" + ;; + scan-images-develop) + run_scan_images=true + scan_images_ref="develop" ;; - e2e-*) + e2e-main) run_e2e=true - e2e_ref="${token#e2e-}" + e2e_ref="main" + ;; + e2e-develop) + run_e2e=true + e2e_ref="develop" + ;; + test-backend-*|test-frontend-*|deploy-*|scan-images-*|e2e-*) + # Refuse any branch outside the allowlist above. This workflow + # runs on `schedule`/`workflow_dispatch`, i.e. in the context of + # the default branch, where the job can WRITE the default-branch + # Actions cache scope. Checking out an arbitrary ref here would + # let unreviewed code run with that cache token and poison + # entries that privileged workflows later restore + # (CWE-349; CodeQL actions/cache-poisoning/*). + echo "::error::Track '$token' names a branch outside the allowlist (main, develop)." + echo "::error::Nightly runs privileged; testing an arbitrary branch here risks Actions cache poisoning." + echo "::error::To test another branch, open a PR (tests.yml runs per-PR) or add the branch to the allowlist in this workflow." + exit 1 ;; *) echo "::warning::Unknown track token: $token" diff --git a/.github/workflows/platform.yml b/.github/workflows/platform.yml index 37d1f6298..745b7ca38 100644 --- a/.github/workflows/platform.yml +++ b/.github/workflows/platform.yml @@ -99,6 +99,15 @@ jobs: # http://localhost:4200 for a local SPA pointed at this deployment. # Empty on prod. Mirrors CDK_MCP_SANDBOX_EXTRA_FRAME_ANCESTORS below. CDK_ARTIFACTS_EXTRA_FRAME_ANCESTORS: ${{ vars.CDK_ARTIFACTS_EXTRA_FRAME_ANCESTORS }} + # "Shared with you" inbox on the artifact library. Default OFF, + # opt-in — the reverse of the flags above, because the surface ships + # ahead of the product decision about it. Unset resolves to an empty + # string, which config.ts treats as off; set the + # `CDK_ARTIFACT_SHARE_INBOX_ENABLED` variable to "true" in an + # environment to reveal it there. The fan-out rows the inbox reads are + # written regardless of this flag, so turning it on shows a complete + # inbox with no backfill. + CDK_ARTIFACT_SHARE_INBOX_ENABLED: ${{ vars.CDK_ARTIFACT_SHARE_INBOX_ENABLED }} CDK_FRONTEND_CERTIFICATE_ARN: ${{ vars.CDK_FRONTEND_CERTIFICATE_ARN }} # MCP Apps sandbox-proxy origin (mcp-sandbox.{domain}). Without the # cert ARN the construct silently falls back to the CloudFront default diff --git a/.gitignore b/.gitignore index 9553d6371..38e4d1986 100644 --- a/.gitignore +++ b/.gitignore @@ -156,4 +156,4 @@ internal-docs/ *-report-*.pdf audit-*.pdf audit-*.md -review-*.pdf +review-*.pdf \ No newline at end of file diff --git a/.kiro/specs/base-color-theming/.config.kiro b/.kiro/specs/base-color-theming/.config.kiro new file mode 100644 index 000000000..2f196e687 --- /dev/null +++ b/.kiro/specs/base-color-theming/.config.kiro @@ -0,0 +1 @@ +{"specId": "7c3f1a48-9d52-4e6b-b0a7-2f8c41d9e5b3", "workflowType": "requirements-first", "specType": "feature"} diff --git a/.kiro/specs/base-color-theming/design.md b/.kiro/specs/base-color-theming/design.md new file mode 100644 index 000000000..1cb59afa6 --- /dev/null +++ b/.kiro/specs/base-color-theming/design.md @@ -0,0 +1,468 @@ +# Design Document + +## Overview + +This feature adds two `Base_Color` anchors to `Brand_Config` and derives the application's entire neutral surface family from them at build time, one ramp per theme. It reuses the existing `branding-customization` machinery — same config file, same normalization pattern, same generator script, same `prebuild` / `prestart` wiring — and extends it rather than building a parallel system. + +Three parts, in dependency order: + +1. **Unblock the cascade.** Remove `@import "tailwindcss"` from all sixteen component stylesheets. Without this, nothing else in this feature has any visible effect outside ``. +2. **Derive and emit the ramps.** Extend `generate-brand-theme.ts` to compute a `Light_Ramp` and a `Dark_Ramp` from the two anchors and emit them as plain-CSS overrides of `--color-gray-*` and `--color-white`. +3. **Close the leaks.** Migrate the `slate-*` utilities and hardcoded neutral hex/rgba values that bypass the variables entirely. + +### The root cause, stated precisely + +This is the single most important thing for an implementer to understand, because it is counterintuitive and it invalidates the obvious approach. + +Every Tailwind utility resolves through a CSS variable. Verified in the built output: + +```css +.bg-white { background-color: var(--color-white) } +.bg-gray-50 { background-color: var(--color-gray-50) } +.text-white { color: var(--color-white) } +.border-gray-200{ border-color: var(--color-gray-200) } +``` + +So overriding the variables is a legitimate mechanism and needs no template changes. The problem is *where* those variables get declared. + +Sixteen component stylesheets contain `@import "tailwindcss"`. Each one emits Tailwind's full theme block, and Angular's emulated encapsulation rewrites the selector. From `dist/ai.client/browser/chunk-35AGSY2X.js`: + +```css +@layer theme { + [_ngcontent-%COMP%]:root, [_nghost-%COMP%] { + --color-gray-50: oklch(98.5% .002 247.839); + /* … the entire default gray scale … */ + --color-white: #fff; + } +} +``` + +`[_ngcontent-…]:root` never matches — `` carries no content attribute. But `[_nghost-…]` matches the component's own host element. `app.css` is attached to `app-root`, so `` re-declares the default gray scale and `--color-white` for the entire application subtree. Custom property resolution walks to the nearest ancestor that declares the property, and that is now `app-root`, not `:root`. A global `:root` override loses unconditionally. + +`` is the only element outside `app-root`, which is why the earlier attempt changed `body`'s background and nothing else, and why the sidenav was untouched. + +Twenty-three built chunks were found to contain a `--color-gray-900:` *definition*, confirming the duplication is pervasive rather than isolated. + +Brand accent colors are immune because `--color-primary-*` is not a Tailwind default. It is defined once in the global `:root` (from the generated `@theme` block) and the component-host blocks never mention it, so it inherits cleanly. Confirmed: zero chunks define `--color-primary-500`; they only reference it. + +### Why `@reference` is the right fix + +Tailwind v4 provides `@reference "tailwindcss";` for exactly this situation — it makes theme values, custom variants, and theme functions available to a stylesheet while emitting no CSS. Present in the installed `tailwindcss@4.2.4`. + +Removing the per-component imports is safe because the global stylesheet already emits every utility the application uses. Verified against `dist/ai.client/browser/styles-*.css`: it contains `.bg-slate-800\/70` and `.bg-slate-100`, which appear **only** inside inline `template:` strings in `.ts` files — so Tailwind's automatic source detection from the global entry point is already scanning everything. Conversely, component-only custom classes (`sidenav-panel-enter`, `artifact-pane-open`, `approval-prompt`) appear only in the component chunks, so those stylesheets still carry real content and must be kept. + +Specificity is also safe. The component-scoped utility copies currently sit at `.bg-white[_ngcontent-x]`, and after removal the global `.bg-white` applies. Both live in `@layer utilities`, and layered rules always lose to unlayered rules regardless of specificity — so the hand-written unlayered rules in each component stylesheet (`nav a.active`, `.sidenav.collapsed`, and so on) continue to win either way. The change is expected to be pixel-neutral, and it is the whole point of the Phase 0 checkpoint to confirm that. + +Secondary benefit: each of those chunks currently carries a duplicate copy of the theme block, which is why `angular.json` needs a 200 kB warning / 500 kB error `anyComponentStyle` budget. Expect a sharp drop. + +### Key design decisions + +- **Override the existing `gray` and `white` variables; do not introduce new token names.** The application has 2041 `bg-gray-*`, 5024 `text-gray-*`, 1820 `border-gray-*`, 1030 `dark:bg-gray-*`, and 811 `bg-white` call sites. Migrating them to `surface-*` tokens is a ~10,000-edit change with no functional benefit over overriding the variables they already read. +- **Two ramps over one set of variable names.** `gray` is shared: light mode uses `50–300` for surfaces and `500–900` for text, dark mode uses the reverse. One ramp anchored on one base cannot serve both. So the generator emits two ramps, both preserving the lightest-to-darkest ordering, differing in tint and in where the surface band sits. +- **Ramps are plain CSS, not `@theme`.** `@theme` can only write `:root`; the `Dark_Ramp` needs `html.dark`. Plain unlayered rules also beat `@layer theme`, which is precisely the override behavior wanted. As a bonus, plain declarations are not subject to Tailwind's unused-variable tree-shaking. +- **Lightness comes from the Reference_Ladder; only the surface band moves.** Text steps keep their current absolute lightness, so every text-contrast relationship in the application is preserved by construction rather than by hope. Only the near-surface steps compress to make room for the anchor. +- **Anchor light on the raised surface, dark on the page.** In light mode the visible anchor is the card (`--color-white`, 811 call sites); in dark mode it is the page (`--color-gray-900`, which `html.dark body` already reads). Anchoring each theme where the eye lands makes the config predictable: "I set `#FAF7F2` and my cards are `#FAF7F2`." +- **Contrast is verified, not assumed.** The generator already contains WCAG contrast math for the brand accessible aliases. Reuse it to validate the surface/border/text pairs the application actually relies on, and adjust or fall back with a warning rather than emitting an inaccessible theme. + +### Scope boundaries + +Build-time only, matching `branding-customization`. No admin UI, no persistence, no runtime overrides. Status (`state-*`) and category identity (`vendor-*`, `filetype-*`) tokens deliberately do not follow the base color — a red error banner stays red. + +## Architecture + +```mermaid +flowchart TD + A["brand.config.ts
colors + baseColors"] --> B["generate-brand-theme.ts"] + B --> C["@theme block
brand scales + accessible aliases"] + B --> D[":root block
Light_Ramp"] + B --> E["html.dark block
Dark_Ramp"] + C & D & E --> F["src/styles/generated/brand-theme.css
(committed)"] + F --> G["src/styles.css
@import tailwindcss + generated"] + G --> H["global stylesheet
every utility, one :root"] + I["16 component stylesheets
@reference tailwindcss"] --> J["component chunks
custom rules only, no theme block"] + H --> K["rendered application"] + J --> K + L["ThemeService + index.html script"] -->|"toggles .dark on html"| K +``` + +### Two clocks + +| Concern | When resolved | Mechanism | +|---|---|---| +| Base_Color → Neutral_Scale | Build time | `Neutral_Scale_Generator` writes literal `oklch()` values | +| Which ramp is active | Runtime | `.dark` class on ``, set by `ThemeService` and the pre-bootstrap script in `index.html` | +| Chart / canvas neutrals | Runtime | resolved from computed style, because Chart.js needs a color string | + +### Emitted file shape + +`src/styles/generated/brand-theme.css` grows two blocks after the existing `@theme`: + +```css +@theme { + /* brand scales + accessible aliases — unchanged shape */ +} + +/* Light_Ramp */ +:root { + --color-white: oklch(…); + --color-gray-50: oklch(…); + /* … through 950 … */ +} + +/* Dark_Ramp */ +html.dark { + --color-white: #fff; + --color-gray-50: oklch(…); + /* … through 950 … */ +} +``` + +`html.dark` matches the existing convention in `styles.css` (`html.dark body { … }`) and sits at specificity (0,1,1), above `:root`'s (0,1,0). Both blocks are unlayered, so they beat every `@layer theme` declaration. Once Phase 0 lands there are no competing `[_nghost-…]` declarations to fight. + +## Components and Interfaces + +### Base_Config (`src/branding/brand.config.ts`) + +```typescript +export const BRAND_CONFIG: BrandConfig = { + logo: { … }, + appName: "…", + greetingTemplates: [ … ], + fallbackGreetings: [ … ], + colors: { primary: '…', secondary: '…', tertiary: '…' }, + baseColors: { + light: '#ffffff', + dark: '#101828', + }, + pageTitle: "…", +}; +``` + +### Types (`src/branding/brand.types.ts`) + +```typescript +export interface BrandBaseColors { + /** Anchors the light theme's Raised_Surface (cards, panels, inputs). */ + light: HexColorInput; + /** Anchors the dark theme's Page_Surface (application background). */ + dark: HexColorInput; +} + +export interface BrandConfig { + // … existing fields … + baseColors: BrandBaseColors; +} +``` + +### Defaults (`src/branding/brand.defaults.ts`) + +```typescript +export const DEFAULT_BASE_COLORS: BrandBaseColors = Object.freeze({ + light: '#ffffff', + dark: '#101828', // nearest hex to oklch(21% 0.034 264.665) = gray-900 +}); +``` + +The `dark` default must be verified by round-tripping through the generator's existing `hexToOklch`, and the resulting OKLCH must land within the Requirement 8.2 tolerance of `gray-900`. If `#101828` does not, pick the hex that does and record the measured deviation in a comment. + +### Normalization (`src/branding/brand-config.normalize.ts`) + +Add `normalizeBaseColorRole` and `normalizeBaseColors`, mirroring `normalizeColorRole` / `normalizeColors` exactly — same `HEX_COLOR_PATTERN`, same `BrandConfigError` shape, same independent per-role defaulting. Add `baseColors` to `NormalizedBrandConfig` and wire it into `normalizeBrandConfig`. + +`BrandingService` does not need to expose `baseColors` — like `colors`, they are consumed only at build time. But `resolveBranding` should still destructure and discard them so the normalization errors continue to reach `configErrors` and the developer console. + +### Neutral_Scale_Generator (`scripts/branding/generate-brand-theme.ts`) + +Extends the existing module. New exports, following the existing pure-function-plus-guarded-entry-point structure: + +```typescript +export type NeutralStep = 50 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 950; + +/** Tailwind v4 default gray, read from node_modules/tailwindcss/theme.css. */ +export const REFERENCE_LADDER: Record; + +/** Maximum chroma any derived neutral step may carry. */ +export const NEUTRAL_CHROMA_CEILING = 0.04; + +/** Below this chroma a Base_Color is treated as achromatic. */ +export const CHROMA_EPSILON = 0.001; + +export function generateNeutralRamp( + theme: 'light' | 'dark', + baseHex: string, + warnings: BrandConfigError[], +): { css: string; resolved: Record }; + +export function generateBaseTheme(config: BrandConfig): { css: string; errors: BrandConfigError[] }; +``` + +`generateBrandTheme` keeps its current signature and output so the existing golden test stays valid. `run()` concatenates `wrapInThemeBlock(brandCss)` with the two ramp blocks. + +## Data Models + +### Reference_Ladder + +Tailwind v4.2.4 default `gray`, verified against `node_modules/tailwindcss/theme.css`: + +| step | L | C | H | +|---|---|---|---| +| 50 | 0.985 | 0.002 | 247.839 | +| 100 | 0.967 | 0.003 | 264.542 | +| 200 | 0.928 | 0.006 | 264.531 | +| 300 | 0.872 | 0.010 | 258.338 | +| 400 | 0.707 | 0.022 | 261.325 | +| 500 | 0.551 | 0.027 | 264.364 | +| 600 | 0.446 | 0.030 | 256.802 | +| 700 | 0.373 | 0.034 | 259.733 | +| 800 | 0.278 | 0.033 | 256.848 | +| 900 | 0.210 | 0.034 | 264.665 | +| 950 | 0.130 | 0.028 | 261.692 | + +Plus `white` at L = 1.000, C = 0. + +Two derived constants, both read from this table rather than hardcoded: + +- `RAISED_PAGE_DELTA_LIGHT = 1.000 − 0.985 = 0.015` — how much lighter a card is than the page today. +- `PAGE_RAISED_DELTA_DARK = 0.278 − 0.210 = 0.068` — how much lighter a dark card is than the dark page today. +- `PAGE_DEEP_DELTA_DARK = 0.210 − 0.130 = 0.080` — how much darker `gray-950` is than the dark page today. + +### Derivation algorithm + +Given a `Base_Color` hex, compute `(L_b, C_b, H_b)` via the existing `hexToOklch`. + +**Tint (both ramps, identical rule).** + +``` +if C_b <= CHROMA_EPSILON: + # achromatic base — reproduce the Reference_Ladder exactly (Requirement 3.11) + chroma[step] = REFERENCE_LADDER[step].c + hue[step] = REFERENCE_LADDER[step].h +else: + chroma[step] = min(C_b, NEUTRAL_CHROMA_CEILING) # constant across the ramp + hue[step] = H_b +``` + +Constant chroma is chosen over a per-step profile because it is predictable and easy to reason about: the whole neutral family carries the base's tint at the same strength. The ceiling of `0.04` is roughly Tailwind's own maximum gray chroma (`0.034` at step 700) plus headroom — enough for a clearly tinted neutral family, low enough that `#ff0000` (C ≈ 0.25) produces a warm grey rather than pink. Exceeding it records a warning per Requirement 3.10. + +`--color-white` in the light ramp carries the base's chroma unclamped, because it *is* the base color, emitted verbatim. + +**Light_Ramp lightness.** + +``` +L[white] = L_b # the anchor, exact (R3.3) +L[50] = L_b - RAISED_PAGE_DELTA_LIGHT # preserves today's separation (R3.4) +L[100..300] = remapLinear( + REFERENCE_LADDER[step].l, + from: [0.985, 0.707], # today's gray-50 … gray-400 + to: [L[50], 0.707], # compressed top, fixed bottom +) +L[400..950] = REFERENCE_LADDER[step].l # Text_Steps untouched (R3.12) +``` + +Only `50`–`300` move. Those are the surface and border steps. `400` is the pivot and is unchanged, so every step from `400` down keeps its exact current lightness and therefore its exact current contrast against white-ish backgrounds. When `L_b = 1.0`, the remap is the identity and the ramp reproduces the Reference_Ladder. + +**Dark_Ramp lightness.** + +``` +L[900] = L_b # the anchor, exact (R3.5) +L[800] = clamp01(L_b + PAGE_RAISED_DELTA_DARK) # preserves separation (R3.6) +L[950] = clamp01(L_b - PAGE_DEEP_DELTA_DARK) +L[700..500] = remapLinear( + REFERENCE_LADDER[step].l, + from: [0.373, 0.446], # today's gray-700 … gray-600 + to: [max(L[800] + 0.02, 0.373), 0.446], +) +L[400..50] = REFERENCE_LADDER[step].l # dark-mode Text_Steps untouched +L[white] = 1.000 # pure white in dark mode +``` + +The `700..500` remap exists to stop `gray-700` from landing below `gray-800` when someone picks a light "dark" base. `gray-400` and above are dark mode's text and icon steps and stay put. + +`--color-white` stays literal `#fff` in the dark ramp. Dark mode's raised surface is `gray-800`, not white; `text-white` and the 43 `dark:bg-white` inverted-chip usages need real white there. + +**Post-derivation contrast pass (Requirement 5).** + +Verify these pairs, using the existing `contrastRatio`: + +| Theme | Foreground | Background | Target | +|---|---|---|---| +| light | gray-500, 600, 700, 800, 900 | white, gray-50, gray-100 | 4.5:1 | +| light | gray-200, gray-300 | white, gray-50 | 3:1 | +| dark | gray-100, 200, 300, 400 | gray-900, gray-800 | 4.5:1 | +| dark | gray-600, gray-700 | gray-900, gray-800 | 3:1 | + +On failure, walk the failing step's lightness toward the Reference_Ladder value in `LIGHTNESS_SEARCH_STEP` increments until it passes, recording a warning. If it still fails at the Reference_Ladder value, emit the Reference_Ladder value and record an error. Re-check monotonicity after any adjustment, and if an adjustment would break ordering, fall back that step to the Reference_Ladder instead. + +**Emission.** Literal `oklch(L% C H)` strings, matching the `state.css` convention of literals over `var()` references. Round L to 1 decimal place as a percentage, C to 3 decimals, H to 3 decimals, so output is stable and diffable. + +### Brand accessible-alias coupling (Requirement 6) + +`generate-brand-theme.ts` currently hardcodes: + +```typescript +const DARK_SURFACE_OKLCH = { l: 0.21, c: 0.034, h: 264.665 } as const; +``` + +This is a copy of `gray-900` and is the background reference for every `--color-{role}-accessible-dark` alias. It must become the *resolved* `Dark_Ramp` `gray-900` — that is, the dark `Base_Color` itself. Otherwise every brand accent's dark-mode contrast guarantee is computed against a surface that is no longer on screen. Order of operations in `run()`: resolve the ramps first, then generate the brand theme using the resolved dark page surface. + +Similarly, `findAccessibleLightnessDelta` for the light-mode alias currently uses literal white as the background. That should become the resolved light `Raised_Surface`. + +The comment in the generator claiming `DARK_SURFACE_OKLCH` is "kept in sync with the `html.dark body` background in src/styles.css" needs updating — the dependency now runs the other way. + +### Neutral leak inventory (Requirement 7) + +Found by audit; the implementer should re-run the searches rather than trust this list to be exhaustive. + +**`slate-*` utilities — 9 files:** + +| File | Notes | +|---|---| +| `session/components/message-list/components/oauth-consent-prompt/oauth-consent-prompt.component.ts` | `dark:bg-slate-800/70`, `dark:bg-slate-900` | +| `session/components/message-list/components/tool-approval-prompt/tool-approval-prompt.component.ts` | `dark:bg-slate-800/70`, `dark:bg-slate-900`, `dark:bg-slate-900/60` | +| `session/components/message-list/components/message-metadata-badges.component.ts` | `bg-slate-100`, `text-slate-700`, `dark:bg-slate-800/60`, `dark:text-slate-300`, `text-slate-500`, `dark:text-slate-400` | +| `session/components/message-list/components/mcp-app-card/mcp-app-card.component.ts` | `dark:bg-slate-800/70`, `dark:bg-slate-900` | +| `session/components/message-list/components/mcp-app-consent-prompt/mcp-app-consent-prompt.component.ts` | `dark:bg-slate-800/70`, `dark:bg-slate-900` | +| `session/components/chat-input/chat-input.component.html` | `dark:bg-slate-800` ×2 | +| `components/storage-quota-banner/storage-quota-banner.component.ts` | `dark:bg-slate-800` | +| `components/quota-warning-banner/quota-warning-banner.component.ts` | `dark:bg-slate-800` ×2 | +| `components/model-dropdown/model-dropdown.component.ts` | `dark:text-slate-400` ×2 | + +Map each to the same-numbered `gray-*` step. Tailwind's `slate` and `gray` differ only in hue and a little chroma at equal steps, so this is a near-invisible change today and becomes correct once the ramps are tinted. + +**Hardcoded neutral hex / rgba in component styles:** + +| File | Notes | +|---|---| +| `shared/constants/chart-colors.constants.ts` | `CHART_CHROME` light/dark: tooltip background, title/body text, border, axis text, grid line. Must resolve from computed style at runtime — Chart.js needs a color string. | +| `admin/costs/components/model-breakdown.component.ts` | `'#ffffff'` doughnut segment border | +| `session/components/message-list/components/artifact/artifact-card.component.ts` | `#6b7280`, `#374151`, several `rgba(255,255,255,…)` | +| `session/components/assistant-indicator/assistant-indicator.component.ts` | `rgb(30 41 59) /* slate-800 */` ×2, several `rgba()` | +| `session/components/message-list/components/tool-use/renderers/mcp-app-frame.component.ts` | `#4b5563`, `#d1d5db`, `#f3f4f6` shimmer gradient | +| `session/components/message-list/components/file-attachment/file-attachment-badge.component.ts` | `--corner-bg: #f3f4f6` / `#374151` | +| `not-found.page.ts` | `rgba(255,255,255,…)`, `rgba(0,0,0,…)` — decorative; document and retain | +| `session/components/message-list/components/artifact/artifact-panel.component.ts` | `rgba(255,255,255,0.45)` shimmer — decorative; document and retain | + +**Other:** + +- `components/sidenav/sidenav.css`: `.dark nav a.active { color: white }` uses the CSS keyword. Should read `var(--color-white)`. +- `styles.css` `.message-block`: `tr:nth-child(odd) td { background-color: white }` — same issue. +- `shadow-[0_1px_2px_rgba(15,23,42,0.04)]` arbitrary shadows encode slate-900. Low priority; black-alpha shadows read correctly on a tinted surface, so these can be normalized to `rgba(0,0,0,0.04)` or left with a comment. + +Black-alpha `rgba(0,0,0,…)` shadows and `rgba(255,255,255,…)` dark-mode surface washes are **correct as-is** — they compose over whatever surface is beneath them and therefore already follow the base color. Only opaque neutral values need migrating. + +## Correctness Properties + +*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* + +The `Neutral_Scale_Generator` is a pure function from two hex strings to CSS, which makes it well suited to property-based testing. The cascade fix, theme switching, and leak closure are covered by guard specs, golden tests, and manual verification instead. + +### Property 1: Ramp structure + +*For any* pair of valid 6-digit `Base_Color` hexes, the generator emits exactly two ramp blocks — one scoped to `:root` and one to `html.dark` — each containing exactly twelve declarations: `--color-gray-{step}` for the eleven steps in ascending order, plus `--color-white`. + +**Validates: Requirements 3.1, 3.2** + +### Property 2: Monotonic lightness within each ramp + +*For any* pair of valid `Base_Color` hexes, within each emitted ramp the OKLCH lightness decreases strictly and monotonically from step 50 through step 950, and `--color-white` is at least as light as step 50. + +**Validates: Requirements 3.7, 5.6** + +### Property 3: Anchors are exact + +*For any* valid light `Base_Color`, the emitted `:root` `--color-white` resolves to that exact color; and *for any* valid dark `Base_Color`, the emitted `html.dark` `--color-gray-900` resolves to that exact color. + +**Validates: Requirements 3.3, 3.5** + +### Property 4: Surface separation is preserved + +*For any* pair of valid `Base_Color` hexes, the light ramp's `white`-to-`gray-50` lightness separation and the dark ramp's `gray-900`-to-`gray-800` separation each equal the corresponding `Reference_Ladder` separation, unless a documented clamp at the 0 or 1 lightness boundary applies. + +**Validates: Requirements 3.4, 3.6, 5.5** + +### Property 5: Tint derivation is bounded + +*For any* valid `Base_Color`, every emitted step's chroma is at most `NEUTRAL_CHROMA_CEILING`, every emitted step's hue equals the base's hue when the base is chromatic, and every emitted step reproduces the `Reference_Ladder`'s own hue and chroma when the base is achromatic. + +**Validates: Requirements 3.8, 3.9, 3.10, 3.11** + +### Property 6: Contrast guarantees hold + +*For any* pair of valid `Base_Color` hexes, every verified foreground-and-background pair in the emitted ramps meets its WCAG target — 4.5:1 for text steps, 3:1 for border steps — against both the `Page_Surface` and the `Raised_Surface` of its own theme. + +**Validates: Requirements 5.1, 5.2, 5.3, 5.4** + +### Property 7: Generator determinism and independence + +*For any* valid `Base_Config`, generating twice produces character-for-character identical CSS, and changing one `Base_Color` changes only that ramp's declarations while leaving the other ramp and all brand-color declarations byte-identical. + +**Validates: Requirements 3.13, 3.14** + +### Property 8: Invalid base colors degrade safely + +*For any* value that is not a valid 6-digit hexadecimal input, the generator uses the corresponding `Default_Base_Colors` value for that role only, records an error identifying the offending value and the role, still honors the other role's valid value, and emits a complete, contrast-passing pair of ramps. + +**Validates: Requirements 1.4, 1.5, 9.1, 9.2, 9.4** + +### Property 9: Brand accessible aliases track the base + +*For any* pair of valid `Base_Color` hexes and any valid `Brand_Color`, each `--color-{role}-accessible-dark` alias meets the AA contrast target against the resolved dark `Page_Surface`, and each `--color-{role}-accessible` alias meets it against the resolved light `Raised_Surface`, with the configured hue and chroma held unchanged. + +**Validates: Requirements 6.1, 6.2, 6.3, 6.4** + +## Error Handling + +All handling is non-blocking. A branding mistake degrades to a usable value and surfaces a console warning at build time; it never fails the build or blanks the interface. This matches the established behavior for invalid `Brand_Color` values. + +| Condition | Behavior | +|---|---| +| Invalid or absent `baseColors.light` / `.dark` | Use `DEFAULT_BASE_COLORS` for that role only; record error (R1.4, R1.5, R9.4) | +| `baseColors` absent entirely | Use both defaults; record one error (R1.5) | +| Base chroma above `NEUTRAL_CHROMA_CEILING` | Clamp; record warning naming the role (R3.10) | +| Derived lightness outside `[0, 1]` | Clamp; record warning (R5.6) | +| Contrast pair below target | Walk toward `Reference_Ladder` until it passes; record warning (R5.3) | +| Contrast still failing at `Reference_Ladder` | Emit `Reference_Ladder` value; record error (R5.4) | +| Adjustment would break monotonicity | Emit `Reference_Ladder` value for that step; record warning (R3.7) | + +## Testing Strategy + +### Guard specs (the ratchets) + +Two new specs are the highest-value tests in this feature, because they prevent silent regression of changes that are invisible in review: + +1. **`src/styles/no-component-tailwind-import.spec.ts`** — scans `src/**/*.css` and asserts `@import "tailwindcss"` appears in `src/styles.css` and nowhere else. Without this, one future `@import` reintroduces the exact bug that motivated this feature, and it will look like a base color bug rather than a stylesheet bug. +2. **`src/app/color-tokens.spec.ts` (existing, amended)** — move `slate`, `zinc`, `neutral`, `stone` from `ALLOWED_NEUTRALS` into `BANNED_PALETTES`. Keep `gray`, `white`, `black` allowed. The file's explanatory comment currently asserts neutrals "are not part of the themed surface" — that comment is now wrong and must be corrected. + +### Golden regression + +The existing `brand-theme-golden.spec.ts` compares generator output character-for-character against the committed `brand-theme.css`, extracting the region inside `@theme { … }`. Its `extractThemeDeclarations` uses `lastIndexOf('}')`, which will now find the closing brace of the `html.dark` block. It must be reworked to bound the `@theme` block correctly, and `generateBrandTheme` should keep returning only the brand declarations so the character-for-character guarantee survives. + +A **new** ramp golden spec compares against the `Reference_Ladder` **numerically, not textually**, because hex is a lossier input format than the OKLCH the Tailwind palette is authored in — a hex `Base_Color` cannot round-trip to `oklch(21% 0.034 264.665)` exactly. Assert per-channel tolerance (ΔL ≤ 0.005, ΔC ≤ 0.002, Δh ≤ 1°) and, more meaningfully, that every verified contrast ratio is within 0.05 of its current value. This is a deliberate, documented deviation from `branding-customization`'s byte-identity guarantee, and the reason should be stated in the spec file's header comment. + +### Property tests + +`fast-check` + Vitest, minimum 100 iterations, tagged `// Feature: base-color-theming, Property {number}: {property text}`, colocated with the generator, matching the existing convention. + +### Example and unit tests + +- `normalizeBaseColors` / `normalizeBaseColorRole` — valid, invalid, absent, wrong-type, one-valid-one-invalid. +- `DEFAULT_BASE_COLORS` asserted explicitly (R8.5). +- `BrandingService` still records `baseColors` errors in `configErrors`. +- `chart-colors.constants.ts` runtime resolution — returns the active theme's values and re-reads on theme change. + +### Manual verification + +No automated test covers "does it look right," and this feature's whole risk surface is visual. Two mandatory manual passes: + +**After Phase 0, before any color change** — the diff must be invisible. Walk chat, sidenav (expanded and collapsed), topnav, session list, model dropdown, chat input focus and drag states, message list with tool approvals and artifacts, login, first-boot, and three admin pages, in both themes. Any visible difference is a specificity interaction that must be understood before proceeding. + +**After Phase 2 and 3** — set a deliberately tinted pair (for example light `#FAF7F2`, dark `#1A1614`) and confirm the tint reaches every surface, that cards remain distinguishable from the page, that borders and shadows still read, and that toggling the theme switches every element at once with nothing left behind. Then restore `DEFAULT_BASE_COLORS` and confirm the application is pixel-identical to `main`. + +### Build verification + +Run `npm run build` after Phase 0 and confirm: + +- Zero `--color-gray-900:` *definitions* (as opposed to `var()` references) in `dist/**/*.js`. +- `styles-*.css` still contains utilities that appear only in inline `template:` strings. +- Component-only classes (`sidenav-panel-enter`, `artifact-pane-open`, `approval-prompt`) still present in the chunks. +- The `anyComponentStyle` budget in `angular.json` is comfortably met — expect a large drop, and consider tightening the budget afterward so the duplication cannot creep back unnoticed. diff --git a/.kiro/specs/base-color-theming/requirements.md b/.kiro/specs/base-color-theming/requirements.md new file mode 100644 index 000000000..c6abb04a5 --- /dev/null +++ b/.kiro/specs/base-color-theming/requirements.md @@ -0,0 +1,195 @@ +# Requirements Document + +## Introduction + +The `branding-customization` feature made the three brand accent colors (primary, secondary, tertiary) rebrandable from a single `Brand_Config` file. It deliberately left the application's neutral surfaces — page backgrounds, cards, panels, side menus, borders, and body text — out of scope, on the stated rationale that "grays and other neutrals are not part of the themed surface." + +That rationale no longer holds. A `Forker` rebranding this application for their own organization needs to set the surface color of the light and dark themes, not just the accents. This feature makes the neutral surface family rebrandable from the same `Brand_Config`, driven by two literal `Base_Color` anchors — one per theme. + +An earlier attempt at this failed in a way that defines the shape of this feature. Sixteen Angular component stylesheets each contain `@import "tailwindcss"`, including `app.css`, which is attached to the `app-root` host element. Angular's emulated view encapsulation rewrites Tailwind's emitted theme block to `[_ngcontent-%COMP%]:root, [_nghost-%COMP%] { ... }`. The `[_nghost-…]` half matches the component's own host element, so `app-root` re-declares Tailwind's *default* gray scale and `--color-white` for the entire application subtree. Every descendant inherits those defaults instead of any global override. Only ``, which sits outside `app-root`, escaped — which is why the earlier attempt changed the page background and nothing else, and why the side menus were unaffected. + +Brand accent colors work today for the inverse reason: `--color-primary-*` is not a Tailwind default, so it is declared only once, in the global `:root`, and nothing clobbers it. Neutrals are Tailwind defaults, so they are clobbered at every component host that imports Tailwind. Removing that per-component duplication is therefore a prerequisite of this feature, not an optimization. + +### Scope + +In scope: +1. Two `Base_Color` anchors in `Brand_Config` — one for the light theme, one for the dark theme. +2. A build-time `Neutral_Scale_Generator` that derives a full neutral ramp per theme from those anchors. +3. Removal of the per-component Tailwind theme duplication that prevents any global neutral override from reaching the application. +4. Closure of the known neutral leaks — `slate-*` utilities and hardcoded neutral hex/rgba values — so they follow the `Base_Color`. +5. Contrast guard rails that keep surfaces, borders, and text legible for any accepted `Base_Color` pair. +6. `Rebranding_Documentation` covering the new configuration. + +Out of scope (explicit non-goals — see Requirement 11): +- Any admin dashboard or in-app UI for editing base colors. +- Backend persistence of base colors. +- Runtime base color overrides. +- Migrating the ~10,000 existing `gray-*` / `bg-white` call sites to new token names. This feature overrides the existing variables instead. +- Making `state-*` (status) or `vendor-*` / `filetype-*` (category identity) tokens follow the `Base_Color`. Their meaning is fixed by design. + +## Glossary + +- **Base_Color**: A single hex value provided by a `Forker` that anchors one theme's neutral surface family. There are exactly two: `light` and `dark`. +- **Base_Config**: The `baseColors` field added to the existing `Brand_Config` (`frontend/ai.client/src/branding/brand.config.ts`), holding the two `Base_Color` values. +- **Neutral_Scale**: The eleven derived neutral steps (50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950) plus the raised-surface value, expressed as overrides of Tailwind's `--color-gray-{step}` and `--color-white` variables. +- **Neutral_Scale_Generator**: The build-time mechanism, extending the existing `Color_Scale_Generator` at `frontend/ai.client/scripts/branding/generate-brand-theme.ts`, that derives both `Neutral_Scale`s from the two `Base_Color` values. +- **Light_Ramp** / **Dark_Ramp**: The `Neutral_Scale` emitted for the light theme (under `:root`) and the dark theme (under `html.dark`) respectively. +- **Raised_Surface**: The neutral surface used for cards, panels, dropdowns, and inputs that sit above the page. In light mode this is `--color-white`; in dark mode it is `--color-gray-800`. +- **Page_Surface**: The neutral surface used for the application background. In light mode this is `--color-gray-50`; in dark mode it is `--color-gray-900`. +- **Border_Step**: A `Neutral_Scale` step used for borders, rings, and dividers — `gray-200` and `gray-300` in light mode, `gray-600` and `gray-700` in dark mode. +- **Text_Step**: A `Neutral_Scale` step used for body text, secondary text, and icons — `gray-500` through `gray-900` in light mode, `gray-100` through `gray-400` in dark mode. +- **Reference_Ladder**: The lightness and chroma values of Tailwind v4's default `gray` palette, which define the neutral relationships the application currently depends on. +- **Component_Theme_Duplication**: The condition in which an Angular component stylesheet contains `@import "tailwindcss"`, causing Tailwind's full theme variable block to be emitted onto that component's host element via `[_nghost-%COMP%]`, shadowing the global values for the component's entire subtree. +- **Neutral_Leak**: A place in the application that renders a neutral color without reading it from a `Neutral_Scale` variable — a `slate-*` / `zinc-*` / `neutral-*` / `stone-*` utility, a hardcoded hex or `rgba()` value, or the `white` CSS keyword. +- **Default_Base_Colors**: The shipped `Base_Color` values, chosen to reproduce the application's current appearance. +- **Forker**: A developer who clones or forks the repository to deploy a rebranded instance. + +## Requirements + +### Requirement 1: Base Color Configuration + +**User Story:** As a Forker, I want to set my light and dark theme surface colors in the same file where I set my brand colors, so that rebranding remains a single-file edit. + +#### Acceptance Criteria + +1. THE Base_Config SHALL define exactly two Base_Color values, one named `light` and one named `dark`, within the existing Brand_Config file. +2. THE Base_Config SHALL accept each Base_Color as a 6-digit hexadecimal value with an optional leading `#`, case-insensitive, matching the format already accepted for Brand_Color values. +3. THE Base_Config SHALL be the only file a Forker edits to change the application's base colors. +4. IF a Base_Color value is not a valid 6-digit hexadecimal value, THEN THE Neutral_Scale_Generator SHALL use the corresponding Default_Base_Colors value and record an error identifying the offending value and the role. +5. IF the `baseColors` field is absent from the Brand_Config entirely, THEN THE Neutral_Scale_Generator SHALL use both Default_Base_Colors values and record an error. +6. THE Base_Config SHALL resolve at build time, consistent with how Brand_Color values already resolve. + +### Requirement 2: Global Cascade Reach + +**User Story:** As a Forker, I want my base color to apply to every part of the application, so that the side menus, chat surface, and dialogs match the pages that already respond. + +#### Acceptance Criteria + +1. THE application SHALL contain Component_Theme_Duplication in exactly zero Angular component stylesheets. +2. WHERE a component stylesheet requires Tailwind theme values, custom variants, or theme functions, THE component stylesheet SHALL obtain them without emitting any CSS output. +3. WHEN the Neutral_Scale is overridden globally, THE override SHALL take effect in the sidenav, the top navigation, the session list, the chat container, the chat input, the message list, the login page, the first-boot page, the admin pages, and every dialog and overlay. +4. THE application SHALL emit each Tailwind utility class exactly once, from the global stylesheet, with no per-component duplicate. +5. WHEN Component_Theme_Duplication is removed, THE rendered appearance of every affected component SHALL be unchanged. +6. THE test suite SHALL fail if any file under `frontend/ai.client/src/` other than `src/styles.css` contains `@import "tailwindcss"`. + +### Requirement 3: Neutral Scale Derivation + +**User Story:** As a Forker, I want the whole neutral family to follow my base color, so that surfaces, borders, and text read as one coherent palette rather than a tinted background with grey furniture on it. + +#### Acceptance Criteria + +1. THE Neutral_Scale_Generator SHALL derive a Light_Ramp from the light Base_Color and a Dark_Ramp from the dark Base_Color. +2. THE Neutral_Scale_Generator SHALL emit both ramps as overrides of the same `--color-gray-{step}` and `--color-white` variable names, the Light_Ramp scoped to `:root` and the Dark_Ramp scoped to `html.dark`. +3. THE Light_Ramp SHALL set the Raised_Surface to the light Base_Color exactly. +4. THE Light_Ramp SHALL set the Page_Surface to a lightness that preserves the Reference_Ladder's current separation between Raised_Surface and Page_Surface. +5. THE Dark_Ramp SHALL set the Page_Surface to the dark Base_Color exactly. +6. THE Dark_Ramp SHALL set the Raised_Surface to a lightness that preserves the Reference_Ladder's current separation between Page_Surface and Raised_Surface. +7. WITHIN each ramp, THE Neutral_Scale_Generator SHALL emit steps whose lightness decreases strictly and monotonically from step 50 through step 950. +8. THE Neutral_Scale_Generator SHALL derive each ramp's hue from that ramp's Base_Color. +9. THE Neutral_Scale_Generator SHALL derive each ramp's chroma from that ramp's Base_Color, clamped to a documented ceiling. +10. IF a Base_Color's chroma exceeds the documented ceiling, THEN THE Neutral_Scale_Generator SHALL clamp the ramp's chroma to that ceiling and record a warning identifying the role. +11. WHERE a Base_Color is achromatic, THE Neutral_Scale_Generator SHALL emit that ramp using the Reference_Ladder's own hue and chroma values. +12. THE Neutral_Scale_Generator SHALL leave every Text_Step at the Reference_Ladder's lightness for its theme, so existing text contrast relationships are preserved by construction. +13. THE Neutral_Scale_Generator SHALL emit deterministic output: generating twice from the same Base_Config produces character-for-character identical CSS. +14. WHEN one Base_Color changes, THE Neutral_Scale_Generator SHALL change only that ramp's declarations. + +### Requirement 4: Theme Switching + +**User Story:** As a user, I want switching between light and dark themes to be instant and complete, so that I never see a half-switched interface. + +#### Acceptance Criteria + +1. THE application SHALL define both the Light_Ramp and the Dark_Ramp in the stylesheet simultaneously, selected by the presence or absence of the `dark` class on the document root element. +2. WHEN the active theme changes, THE application SHALL apply the corresponding ramp to every element within 1 second and without a full page reload. +3. WHEN the active theme changes, THE application SHALL leave no element rendering a value from the previously active ramp. +4. THE application SHALL apply the correct ramp on first paint, before the Angular application bootstraps, using the existing pre-bootstrap theme script. +5. THE Neutral_Scale_Generator SHALL emit each ramp at a specificity that no component stylesheet or Tailwind layer can shadow. + +### Requirement 5: Surface Separation and Legibility + +**User Story:** As a Forker, I want the application to stay readable whatever base color I pick, so that a rebrand cannot silently ship an inaccessible interface. + +#### Acceptance Criteria + +1. THE Neutral_Scale_Generator SHALL verify that each Border_Step meets a contrast ratio of at least 3:1 against both the Page_Surface and the Raised_Surface of its own theme. +2. THE Neutral_Scale_Generator SHALL verify that each Text_Step used for body text meets a contrast ratio of at least 4.5:1 against both the Page_Surface and the Raised_Surface of its own theme. +3. IF a derived ramp fails a contrast verification, THEN THE Neutral_Scale_Generator SHALL adjust the failing step's lightness toward the Reference_Ladder value until the threshold is met, and record a warning identifying the failing pair and the adjustment applied. +4. IF a derived ramp cannot meet a contrast threshold by lightness adjustment alone, THEN THE Neutral_Scale_Generator SHALL fall back to the Reference_Ladder for that step and record an error identifying the role. +5. THE Neutral_Scale_Generator SHALL preserve the Raised_Surface / Page_Surface lightness separation in both themes, so cards remain distinguishable from the page. +6. THE Neutral_Scale_Generator SHALL emit no step whose lightness falls outside the range 0 to 1 inclusive. + +### Requirement 6: Brand Color Interaction + +**User Story:** As a Forker, I want my brand accents to stay legible against my chosen dark surface, so that changing the base color does not silently break the accent contrast guarantees. + +#### Acceptance Criteria + +1. THE Color_Scale_Generator SHALL derive its dark-theme background reference from the dark Base_Color rather than from a hardcoded constant. +2. WHEN the dark Base_Color changes, THE Color_Scale_Generator SHALL recompute every `--color-{role}-accessible-dark` alias against the new dark surface. +3. THE Color_Scale_Generator SHALL continue to derive each `--color-{role}-accessible` alias against the light theme's Raised_Surface. +4. THE Color_Scale_Generator SHALL preserve its existing behavior of adjusting only lightness when deriving accessible aliases, holding the configured hue and chroma. + +### Requirement 7: Neutral Leak Closure + +**User Story:** As a Forker, I want every neutral surface in the application to follow my base color, so that no component stands out as un-rebranded. + +#### Acceptance Criteria + +1. THE application SHALL contain zero `slate-*`, `zinc-*`, `neutral-*`, and `stone-*` Tailwind utilities under `frontend/ai.client/src/app/`. +2. THE application SHALL replace each removed non-`gray` neutral utility with the corresponding `gray-*` utility at the same Reference_Ladder step. +3. THE application SHALL replace each hardcoded neutral hex value in component styles with the corresponding Neutral_Scale variable reference. +4. THE application SHALL replace each use of the `white` CSS keyword that denotes a neutral surface or neutral text with the corresponding Neutral_Scale variable reference. +5. WHERE a hardcoded neutral appears in a resolved-color context that cannot read a CSS variable, THE application SHALL resolve that value from the computed Neutral_Scale at runtime. +6. THE test suite SHALL fail if a `slate-*`, `zinc-*`, `neutral-*`, or `stone-*` utility is reintroduced under `frontend/ai.client/src/app/`. +7. WHERE a hardcoded neutral is intentionally retained, THE application SHALL document the reason at the call site. + +### Requirement 8: Preserve the Default Appearance + +**User Story:** As a maintainer, I want the shipped default to look exactly as it does today, so that this feature is provably a no-op until someone changes a base color. + +#### Acceptance Criteria + +1. THE Default_Base_Colors SHALL be chosen so the derived ramps reproduce the Reference_Ladder. +2. WHEN the Base_Config holds the Default_Base_Colors, THE Light_Ramp and Dark_Ramp SHALL each match the Reference_Ladder within a documented per-channel tolerance in OKLCH lightness, chroma, and hue. +3. WHEN the Base_Config holds the Default_Base_Colors, THE contrast ratio of every verified surface-and-foreground pair SHALL match its current value within a documented tolerance. +4. THE existing golden regression test for the brand `@theme` block SHALL continue to pass character-for-character under Default_Base_Colors. +5. THE test suite SHALL assert the Default_Base_Colors values explicitly, so a change to them is a deliberate, reviewed edit. + +### Requirement 9: Validation and Fallbacks + +**User Story:** As a Forker, I want a mistake in my base color to produce a clear warning rather than a broken build or an unreadable application. + +#### Acceptance Criteria + +1. THE Neutral_Scale_Generator SHALL never fail the build because of an invalid, out-of-range, or over-saturated Base_Color. +2. WHEN the Neutral_Scale_Generator records an error or warning, THE Neutral_Scale_Generator SHALL emit it to the build console identifying the field, the offending value, and the reason. +3. THE branding access boundary SHALL normalize the `baseColors` field using the same per-field, independently-defaulting approach already applied to `colors`. +4. WHERE one Base_Color is invalid, THE Neutral_Scale_Generator SHALL still honor the other. + +### Requirement 10: Documentation + +**User Story:** As a Forker, I want the rebranding guide to tell me how to set my base colors and what to expect, so that I do not have to read the generator source. + +#### Acceptance Criteria + +1. THE Rebranding_Documentation SHALL describe how to set each Base_Color, naming the field and the accepted hex format. +2. THE Rebranding_Documentation SHALL describe which parts of the interface each Base_Color controls. +3. THE Rebranding_Documentation SHALL state that the Neutral_Scale regenerates automatically at build time via the existing `prebuild` and `prestart` scripts. +4. THE Rebranding_Documentation SHALL state the chroma ceiling and describe what happens when a Base_Color exceeds it. +5. THE Rebranding_Documentation SHALL describe the contrast guard rails and what a Forker sees when one triggers. +6. THE Rebranding_Documentation SHALL provide observable verification steps for both themes. +7. THE Rebranding_Documentation SHALL state that `text-white` in the light theme carries the light Base_Color's tint, and describe the documented follow-on option for separating them. +8. THE steering guidance that currently states neutrals are not part of the themed surface SHALL be corrected. +9. THE steering guidance SHALL state that `gray`, `white`, and `black` are the base tokens and remain permitted, while `slate`, `zinc`, `neutral`, and `stone` are prohibited. + +### Requirement 11: Non-Goals + +**User Story:** As a maintainer, I want the boundaries of this feature stated explicitly, so that scope is not assumed to include capabilities that were deliberately deferred. + +#### Acceptance Criteria + +1. THE Rebranding_Documentation SHALL state that an in-app admin UI for editing base colors is out of scope and deferred. +2. THE Rebranding_Documentation SHALL state that backend persistence of base colors is out of scope. +3. THE Rebranding_Documentation SHALL state that runtime base color overrides are out of scope. +4. THE Rebranding_Documentation SHALL state that migrating existing `gray-*` and `bg-white` call sites to new token names is out of scope, and that this feature overrides the existing variables instead. +5. THE Rebranding_Documentation SHALL state that status and category identity tokens do not follow the Base_Color, and why. diff --git a/.kiro/specs/base-color-theming/tasks.md b/.kiro/specs/base-color-theming/tasks.md new file mode 100644 index 000000000..803b81a46 --- /dev/null +++ b/.kiro/specs/base-color-theming/tasks.md @@ -0,0 +1,225 @@ +# Implementation Plan: Base Color Theming + +## Overview + +Convert the base-color-theming design into incremental, verifiable coding steps for the Angular v21 frontend at `frontend/ai.client/`. The work has a strict ordering: the component-stylesheet cascade fix (Phase 0) must land and be verified as pixel-neutral before any color derivation is written, because without it no global neutral override reaches the application and any color bug will be indistinguishable from the cascade bug. + +Property tests use `fast-check` + Vitest (already project dependencies), minimum 100 iterations each, tagged `// Feature: base-color-theming, Property {number}: {property text}`. + +All paths are relative to `frontend/ai.client/` unless stated otherwise. + +## Tasks + +- [ ] 1. Unblock the cascade + - [ ] 1.1 Add the component-stylesheet guard spec first + - Create `src/styles/no-component-tailwind-import.spec.ts` scanning `src/**/*.css` and asserting `@import "tailwindcss"` appears in `src/styles.css` and nowhere else + - Write it before the fix so it starts red and turns green, proving it actually detects the condition + - _Requirements: 2.1, 2.6_ + + - [ ] 1.2 Replace the per-component Tailwind imports with `@reference` + - In each of the sixteen component stylesheets, replace `@import "tailwindcss";` with `@reference "tailwindcss";`: `app/app.css`, `app/components/sidenav/sidenav.css`, `app/components/sidenav/components/session-list/session-list.css`, `app/components/topnav/topnav.css`, `app/components/topnav/components/theme-toggle/theme-toggle.component.css`, `app/session/session.page.css`, `app/session/components/voice-overlay/voice-overlay.component.css`, `app/session/components/message-list/message-list.component.css`, `app/session/components/chat-input/chat-input.component.css`, `app/session/components/chat-container/chat-container.component.css`, `app/auth/login/login.page.css`, `app/auth/first-boot/first-boot.page.css`, `app/admin/gemini-models/gemini-models.page.css`, `app/admin/bedrock-models/bedrock-models.page.css`, `app/agents/migration/agents-migration.page.css` + - Re-run a directory search first — the list above is from an audit and must be confirmed, not trusted + - Leave the `@custom-variant dark (…)` declarations in place; they are definitions, not emissions + - Change nothing else in these files + - _Requirements: 2.1, 2.2, 2.4_ + + - [ ] 1.3 Verify the cascade fix in the build output + - Run `npm run build`; confirm zero `--color-gray-900:` *definitions* in `dist/**/*.js` (as distinct from `var(--color-gray-900)` references) + - Confirm `dist/**/styles-*.css` still contains `.bg-slate-800\/70` and `.bg-slate-100`, which appear only inside inline `template:` strings — this proves the global stylesheet is still scanning every source + - Confirm `sidenav-panel-enter`, `artifact-pane-open`, and `approval-prompt` are still present in the component chunks + - Record the `anyComponentStyle` budget headroom before and after + - _Requirements: 2.3, 2.4_ + + - [ ] 1.4 Checkpoint — confirm Phase 0 is pixel-neutral + - **MANUAL VERIFICATION (not an automated test)** — run the app and walk chat, sidenav expanded and collapsed, topnav, session list, model dropdown, chat input focus and drag states, message list with tool approvals and artifacts, login, first-boot, and three admin pages, in **both** themes + - The diff must be invisible. Any visible difference is a specificity interaction that must be understood and explained before proceeding — do not proceed on the assumption it is cosmetic + - Run `npm run test:ci` and confirm green + - Commit Phase 0 on its own so it is independently revertable + - _Requirements: 2.5_ + +- [ ] 2. Add the Base_Color configuration surface + - [ ] 2.1 Define the base color types + - Add `BrandBaseColors` to `src/branding/brand.types.ts` with `light` and `dark` `HexColorInput` fields, documenting that `light` anchors the light theme's Raised_Surface and `dark` anchors the dark theme's Page_Surface + - Add `baseColors: BrandBaseColors` to `BrandConfig` + - _Requirements: 1.1, 1.2_ + + - [ ] 2.2 Add the Default_Base_Colors constant + - Add frozen `DEFAULT_BASE_COLORS` to `src/branding/brand.defaults.ts` + - Determine the `dark` value empirically: find the hex whose `hexToOklch` result lands closest to `oklch(21% 0.034 264.665)` (Tailwind `gray-900`), start from `#101828`, and record the measured per-channel deviation in a comment + - _Requirements: 8.1, 8.2_ + + - [ ] 2.3 Populate Brand_Config with the base colors + - Add a `baseColors` block to `src/branding/brand.config.ts` holding the `DEFAULT_BASE_COLORS` values as literals, matching how `colors` is populated + - _Requirements: 1.1, 1.3_ + + - [ ] 2.4 Add base color normalization + - Add `normalizeBaseColorRole` and `normalizeBaseColors` to `src/branding/brand-config.normalize.ts`, mirroring `normalizeColorRole` / `normalizeColors` exactly — same `HEX_COLOR_PATTERN`, same `BrandConfigError` shape, independent per-role defaulting + - Add `baseColors` to `NormalizedBrandConfig` and wire it into `normalizeBrandConfig` + - In `BrandingService.resolveBranding`, destructure and discard `baseColors` so its normalization errors still reach `configErrors` and the developer console + - _Requirements: 1.4, 1.5, 9.3_ + + - [ ]* 2.5 Write unit tests for base color normalization + - Valid with and without leading `#`, mixed case; invalid string; wrong type; absent field; absent `baseColors` object; one role valid and one invalid + - Assert `DEFAULT_BASE_COLORS` values explicitly so a change to them is a deliberate reviewed edit + - Assert `BrandingService.configErrors` surfaces a `baseColors.*` error for an invalid value + - _Requirements: 1.4, 1.5, 8.5, 9.3, 9.4_ + +- [ ] 3. Implement the Neutral_Scale_Generator + - [ ] 3.1 Add the Reference_Ladder and derivation constants + - In `scripts/branding/generate-brand-theme.ts`, add `REFERENCE_LADDER` with the eleven steps' L/C/H values plus the `white` entry, copied verbatim from `node_modules/tailwindcss/theme.css` and cited in a comment + - Derive `RAISED_PAGE_DELTA_LIGHT`, `PAGE_RAISED_DELTA_DARK`, and `PAGE_DEEP_DELTA_DARK` from the table rather than hardcoding them + - Add `NEUTRAL_CHROMA_CEILING = 0.04` and `CHROMA_EPSILON = 0.001` with the rationale from the design's derivation section + - _Requirements: 3.8, 3.9, 8.1_ + + - [ ] 3.2 Implement ramp derivation + - Implement `generateNeutralRamp(theme, baseHex, warnings)` per the design's Derivation algorithm: constant clamped chroma and base hue for a chromatic base, Reference_Ladder hue and chroma for an achromatic base; light ramp anchoring `white` and compressing steps 50–300; dark ramp anchoring `gray-900`, deriving 800 and 950 by the preserved deltas, and remapping 700–500 to preserve ordering; Text_Steps left at Reference_Ladder lightness in both ramps; `--color-white` pure `#fff` in the dark ramp + - Clamp all lightness to `[0, 1]`, recording a warning on clamp + - Emit literal `oklch(L% C H)` with L to 1 decimal as a percentage, C and H to 3 decimals, for stable diffs + - _Requirements: 3.1, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8, 3.9, 3.10, 3.11, 3.12, 5.5, 5.6_ + + - [ ] 3.3 Implement the contrast verification pass + - After deriving each ramp, verify the pair table in the design's post-derivation section using the existing `contrastRatio` + - On failure, walk the failing step's lightness toward its Reference_Ladder value in `LIGHTNESS_SEARCH_STEP` increments until it passes, recording a warning naming the pair and the adjustment + - If it still fails at the Reference_Ladder value, emit the Reference_Ladder value and record an error + - Re-verify monotonicity after any adjustment; if an adjustment would break ordering, fall back that step to the Reference_Ladder and record a warning + - _Requirements: 5.1, 5.2, 5.3, 5.4, 3.7_ + + - [ ] 3.4 Implement generateBaseTheme and wire the entry point + - Implement `generateBaseTheme(config)` returning `{ css, errors }`, emitting the `:root` Light_Ramp block and the `html.dark` Dark_Ramp block with explanatory header comments + - Update `run()` to resolve the ramps first, then the brand theme, then write the concatenated output to `src/styles/generated/brand-theme.css` + - Keep `generateBrandTheme`'s signature and output shape unchanged so the existing golden test stays meaningful + - Print all errors and warnings to the build console with field, value, and reason; never fail the build + - Commit the regenerated `brand-theme.css` + - _Requirements: 3.2, 3.13, 3.14, 4.1, 4.5, 9.1, 9.2_ + + - [ ] 3.5 Couple the brand accessible aliases to the resolved base surfaces + - Replace the hardcoded `DARK_SURFACE_OKLCH` with the resolved Dark_Ramp `gray-900`, and the literal-white light background with the resolved Light_Ramp Raised_Surface + - Update the stale comment claiming `DARK_SURFACE_OKLCH` is kept in sync with `html.dark body` in `styles.css` — the dependency now runs the other way + - _Requirements: 6.1, 6.2, 6.3, 6.4_ + + - [ ] 3.6 Write property tests for ramp structure, monotonicity, and anchors + - Single required property test file covering the three structural guarantees for arbitrary valid base hex pairs. Required, not optional — the golden test only pins the default pair, so this is what guarantees the contract for the arbitrary hex a Forker actually supplies + - **Property 1: Ramp structure** — exactly two blocks, one `:root` and one `html.dark`, each with the eleven `--color-gray-{step}` declarations in ascending order plus `--color-white` + - **Property 2: Monotonic lightness within each ramp** — lightness decreases strictly from step 50 to 950, and `--color-white` is at least as light as step 50 + - **Property 3: Anchors are exact** — `:root --color-white` resolves to the light base exactly; `html.dark --color-gray-900` resolves to the dark base exactly + - **Validates: Requirements 3.1, 3.2, 3.3, 3.5, 3.7, 5.6** + + - [ ] 3.7 Write property test for contrast guarantees + - **Property 6: Contrast guarantees hold** — every verified foreground/background pair meets its target (4.5:1 text, 3:1 borders) against both the Page_Surface and Raised_Surface of its own theme + - Required, not optional. This is the only automated defence against a rebrand shipping an unreadable interface + - **Validates: Requirements 5.1, 5.2, 5.3, 5.4** + + - [ ]* 3.8 Write property test for surface separation and bounded tint + - **Property 4: Surface separation is preserved** — light `white`→`gray-50` and dark `gray-900`→`gray-800` separations equal the Reference_Ladder separations, absent a documented boundary clamp + - **Property 5: Tint derivation is bounded** — every step's chroma is at most the ceiling; hue equals the base hue when chromatic; Reference_Ladder hue and chroma are reproduced when achromatic + - **Validates: Requirements 3.4, 3.6, 3.8, 3.9, 3.10, 3.11, 5.5** + + - [ ]* 3.9 Write property tests for determinism and safe degradation + - **Property 7: Generator determinism and independence** — two runs are character-identical; changing one base color changes only that ramp and leaves brand declarations byte-identical + - **Property 8: Invalid base colors degrade safely** — any non-hex value defaults that role only, records an identifying error, honors the other role, and still emits complete contrast-passing ramps + - **Validates: Requirements 1.4, 1.5, 3.13, 3.14, 9.1, 9.2, 9.4** + + - [ ]* 3.10 Write property test for brand alias coupling + - **Property 9: Brand accessible aliases track the base** — `accessible-dark` meets AA against the resolved dark Page_Surface, `accessible` against the resolved light Raised_Surface, with configured hue and chroma held + - **Validates: Requirements 6.1, 6.2, 6.3, 6.4** + +- [ ] 4. Repair and extend the golden regression tests + - [ ] 4.1 Fix the existing brand theme golden test + - `brand-theme-golden.spec.ts`'s `extractThemeDeclarations` uses `lastIndexOf('}')`, which now finds the `html.dark` block's closing brace. Rework it to bound the `@theme { … }` block correctly + - Confirm the character-for-character brand assertion still passes + - _Requirements: 8.4_ + + - [ ] 4.2 Add the neutral ramp golden test + - New spec comparing both ramps against the Reference_Ladder **numerically**, not textually: per-channel tolerance ΔL ≤ 0.005, ΔC ≤ 0.002, Δh ≤ 1° + - Additionally assert every verified contrast ratio is within 0.05 of its current value + - Document in the file header why this is a tolerance comparison rather than the byte-identity guarantee `branding-customization` uses: a hex Base_Color cannot round-trip exactly to the OKLCH the Tailwind palette is authored in + - _Requirements: 8.2, 8.3_ + +- [ ] 5. Checkpoint — ensure all tests pass + - Ensure all tests pass. Ask the user if questions arise. + - Regenerate `brand-theme.css` and confirm the committed file is unchanged from what the generator produces + +- [ ] 6. Close the Neutral_Leaks + - [ ] 6.1 Migrate the non-gray neutral utilities + - Re-run the search for `slate-*`, `zinc-*`, `neutral-*`, `stone-*` under `src/app/` rather than trusting the design's inventory + - Replace each with the same-numbered `gray-*` utility, preserving any opacity modifier: `dark:bg-slate-800/70` → `dark:bg-gray-800/70`, `text-slate-500` → `text-gray-500`, and so on + - Expected files: `oauth-consent-prompt`, `tool-approval-prompt`, `message-metadata-badges`, `mcp-app-card`, `mcp-app-consent-prompt`, `chat-input.component.html`, `storage-quota-banner`, `quota-warning-banner`, `model-dropdown` + - _Requirements: 7.1, 7.2_ + + - [ ] 6.2 Migrate hardcoded neutrals in component styles + - Replace opaque neutral hex values with `var(--color-gray-*)` / `var(--color-white)` references in: `artifact-card.component.ts`, `assistant-indicator.component.ts` (including both `rgb(30 41 59) /* slate-800 */`), `mcp-app-frame.component.ts` shimmer gradient, `file-attachment-badge.component.ts` corner fold + - Fix `sidenav.css` `.dark nav a.active { color: white }` → `var(--color-white)` + - Fix `styles.css` `.message-block` `tr:nth-child(odd) td { background-color: white }` → `var(--color-white)` + - Leave `rgba(0,0,0,…)` shadows and `rgba(255,255,255,…)` dark-mode washes as-is — they compose over the surface beneath them and already follow the base color. Add a brief comment where their intent is not obvious + - Document and retain the decorative values in `not-found.page.ts` and `artifact-panel.component.ts` + - _Requirements: 7.3, 7.4, 7.7_ + + - [ ] 6.3 Resolve chart neutrals from the computed theme + - `shared/constants/chart-colors.constants.ts` hardcodes the light and dark `CHART_CHROME` values (tooltip background, title/body text, border, axis text, grid line). Chart.js needs a resolved color string, so replace the literals with a helper that reads the corresponding `--color-*` values from computed style for the active theme + - Ensure the helper re-reads on theme change so charts do not keep stale colors after a toggle + - Replace the `'#ffffff'` doughnut segment border in `admin/costs/components/model-breakdown.component.ts` with the resolved Raised_Surface + - _Requirements: 7.5_ + + - [ ] 6.4 Ratchet the color token guard + - In `src/app/color-tokens.spec.ts`, move `slate`, `zinc`, `neutral`, `stone` from `ALLOWED_NEUTRALS` into `BANNED_PALETTES`; keep `gray`, `white`, `black` allowed + - Correct the file's explanatory comment, which currently states neutrals "are not part of the themed surface" — `gray` and `white` now are + - _Requirements: 7.6, 10.9_ + + - [ ]* 6.5 Write unit tests for runtime chart color resolution + - Assert the helper returns the active theme's values and re-reads after the `dark` class is toggled on the document root + - _Requirements: 7.5_ + +- [ ] 7. Documentation + - [ ] 7.1 Extend the rebranding guide + - Add a "Base colors" section to `src/branding/README.md`: how to set each value, the accepted hex format, which parts of the interface each anchor controls, that the ramps regenerate via the existing `prebuild` / `prestart` scripts, the chroma ceiling and what exceeding it does, the contrast guard rails and what a Forker sees when one triggers, and observable verification steps for both themes + - State explicitly that `text-white` in the light theme carries the light Base_Color's tint, and describe the follow-on option of splitting `bg-white` onto a separate `surface-raised` token if that is unacceptable for a given brand + - Extend the existing "Non-Goals" section with: no admin UI for base colors, no backend persistence, no runtime overrides, no migration of existing `gray-*` / `bg-white` call sites, and that status and category identity tokens deliberately do not follow the base color + - _Requirements: 10.1, 10.2, 10.3, 10.4, 10.5, 10.6, 10.7, 11.1, 11.2, 11.3, 11.4, 11.5_ + + - [ ] 7.2 Correct the steering guidance + - `.kiro/steering/tailwind-colors.md` line 7 states neutrals "are not part of the themed surface." Correct it: `gray`, `white`, and `black` are the base tokens and remain permitted; `slate`, `zinc`, `neutral`, and `stone` are prohibited because they do not follow the Base_Color + - Apply the same correction to the mirrored guidance in `src/branding/README.md` section 6 and `.kiro/steering/tailwind-theming.md` + - Add a note to `.kiro/steering/tailwind-theming.md` that component stylesheets must use `@reference "tailwindcss"`, never `@import`, with a one-line explanation of the `[_nghost-…]` shadowing so the next developer does not undo it + - _Requirements: 10.8, 10.9_ + + - [ ]* 7.3 Write doc-presence test for the new Non-Goals entries + - Assert each newly deferred capability is stated under the Non-Goals heading + - _Requirements: 11.1, 11.2, 11.3, 11.4, 11.5_ + +- [ ] 8. Final checkpoint — verify both themes and both base configurations + - Ensure all tests pass. Ask the user if questions arise. + - **MANUAL VERIFICATION (not an automated test)** — set a deliberately tinted pair (for example light `#FAF7F2`, dark `#1A1614`) and confirm in **both** themes: the tint reaches every surface including the sidenav, topnav, session list, chat input, dialogs, and overlays; cards remain distinguishable from the page; borders and shadows still read; charts and tooltips follow; and toggling the theme switches every element at once with nothing rendering a value from the previous ramp + - Then restore `DEFAULT_BASE_COLORS`, rebuild, and confirm the application is pixel-identical to `main`. Requirement 8 is an eyeball check that no automated test fully covers + - Consider tightening the `anyComponentStyle` budget in `angular.json` now that the duplicated theme blocks are gone, so the duplication cannot creep back unnoticed + - _Requirements: 4.2, 4.3, 4.4, 8.2, 8.3_ + +## Notes + +- Tasks marked with `*` are optional test tasks and can be skipped for a faster MVP. Tasks 3.6 and 3.7 are deliberately **not** optional: the golden test only pins the default base pair, so the property tests are the only guarantee the contract holds for an arbitrary Forker-supplied hex, and 3.7 is the only automated defence against shipping an unreadable rebrand. +- Phase 1 must be committed and visually verified separately from everything else. It is the riskiest change in this plan and it must be independently revertable. +- Do not trust the file inventories in the design document. They come from an audit and must be re-derived by search before editing. +- Property tests use `fast-check` + Vitest, minimum 100 iterations, tagged `// Feature: base-color-theming, Property {number}: {property text}`. + +## Task Dependency Graph + +```json +{ + "waves": [ + { "id": 0, "tasks": ["1.1"] }, + { "id": 1, "tasks": ["1.2"] }, + { "id": 2, "tasks": ["1.3"] }, + { "id": 3, "tasks": ["1.4"] }, + { "id": 4, "tasks": ["2.1"] }, + { "id": 5, "tasks": ["2.2", "3.1"] }, + { "id": 6, "tasks": ["2.3", "2.4"] }, + { "id": 7, "tasks": ["2.5", "3.2"] }, + { "id": 8, "tasks": ["3.3"] }, + { "id": 9, "tasks": ["3.4"] }, + { "id": 10, "tasks": ["3.5", "3.6", "3.7", "3.8", "3.9", "4.1", "4.2"] }, + { "id": 11, "tasks": ["3.10", "5"] }, + { "id": 12, "tasks": ["6.1", "6.2", "6.3", "7.1", "7.2"] }, + { "id": 13, "tasks": ["6.4", "6.5", "7.3"] }, + { "id": 14, "tasks": ["8"] } + ] +} +``` diff --git a/.kiro/specs/branding-customization/.config.kiro b/.kiro/specs/branding-customization/.config.kiro new file mode 100644 index 000000000..c3bd428b2 --- /dev/null +++ b/.kiro/specs/branding-customization/.config.kiro @@ -0,0 +1 @@ +{"specId": "0115bde9-12ac-45be-90ce-d60f2cbf3505", "workflowType": "requirements-first", "specType": "feature"} diff --git a/.kiro/specs/branding-customization/design.md b/.kiro/specs/branding-customization/design.md new file mode 100644 index 000000000..571efba7b --- /dev/null +++ b/.kiro/specs/branding-customization/design.md @@ -0,0 +1,346 @@ +# Design Document + +## Overview + +This feature centralizes every rebrandable value in the AgentCore Public Stack Angular frontend into a single, documented source of truth — the `Brand_Config` — and routes all consumption of those values through one access boundary. A `Forker` rebrands the application by (a) replacing a documented pair of logo files and (b) editing hex colors, the app name, and greeting text in one file. From the three brand hex values, the full 11-step Tailwind color scales regenerate automatically. + +The design has three consumption paths that share the same `Brand_Config`: + +1. **Build-time color generation.** A `Color_Scale_Generator` reads the three `Brand_Color` values and emits the Tailwind `@theme` color-scale declarations (the `--color-{role}-{step}` custom properties) into a generated CSS partial that `styles.css` imports. Tailwind processes `@theme` at build time, so colors cannot come from a runtime service today. +2. **Runtime branding access.** A `BrandingService` (Angular `providedIn: 'root'`) exposes logo references, the app name, and greeting arrays as the single access boundary. Components read from this service, never from `Brand_Config` directly or from hardcoded literals. +3. **Runtime greeting rendering.** A `GreetingProvider` selects and renders a greeting, handling `{name}` substitution and the fallback chain. + +### Key design decisions + +- **Preserve the existing color mechanism exactly.** The current `@theme` block derives each non-500 step with CSS relative color: `oklch(from #hex calc(l ± delta) c h)`. This holds chroma (`c`) and hue (`h`) literally and adjusts only lightness (`l`), which is precisely what Requirement 5.3 asks for. The `Color_Scale_Generator` emits this same expression per step. With the `Default_Branding` hexes, the generated output is character-for-character identical to today's committed `@theme` block (Requirement 7.2), because the only inputs are the three hex literals and a fixed delta table. +- **`Brand_Config` is a TypeScript module.** A `.ts` module can be imported by both the Node build script (the generator) and the Angular runtime (the service), giving one physical source of truth for all four value groups. This also keeps the shape forward-compatible with a future runtime writer ("Option 2"): the same named fields, and colors expressed as single hex inputs, are exactly what a runtime writer would populate. +- **Keep CSS-only theme-aware logo switching.** Today both logo `` elements are always in the DOM and toggled by Tailwind's `dark:hidden` / `hidden dark:block` under the `html.dark` class that `ThemeService` maintains. This is instant, needs no page reload, and requires no JavaScript branch. The design keeps this structure and only sources `src`/`alt` from the `BrandingService`, preserving byte-identical rendering (Requirement 7.1) and sub-second switching (Requirement 2.6). +- **Access boundary applies defaults defensively.** Every field read through the `BrandingService` is normalized: missing/empty/invalid values fall back to the `Default_Branding` value for that field and surface an error indication, so every consuming component always receives a usable value (Requirements 7.5, 8.4, 8.5). + +### Scope boundaries + +This is a build-time / deploy-time foundation only. No admin UI, no backend persistence, no runtime logo uploads, and no runtime color/branding overrides are in scope (Requirement 9). The configuration shape is designed so a future "Option 2" runtime writer can reuse it without rework. + +## Architecture + +```mermaid +flowchart TD + BC["Brand_Config
(brand.config.ts)
logos · appName · greetings · colors"] + + subgraph Build["Build time (npm prebuild)"] + CSG["Color_Scale_Generator
(Node/TS script)"] + GEN["generated/brand-theme.css
(@theme color scales)"] + CSG --> GEN + end + + subgraph Runtime["Runtime (Angular)"] + BS["BrandingService
(access boundary + defaults)"] + GP["GreetingProvider"] + SN["Sidenav_Component"] + CG["Chat_Greeting_Block"] + TS["ThemeService
(existing: html.dark)"] + end + + STYLES["styles.css
@import generated/brand-theme.css"] + + BC --> CSG + BC --> BS + BS --> GP + BS --> SN + BS --> CG + GP --> CG + GEN --> STYLES + STYLES -.->|"@theme → --color-* vars"| SN + STYLES -.->|"--color-* vars"| CG + TS -.->|"toggles .dark → CSS swaps logo & colors"| SN + TS -.->|"toggles .dark"| CG +``` + +### Two clocks: build time vs runtime + +| Concern | When resolved | Mechanism | +| --- | --- | --- | +| Brand colors → 11-step scales | Build time | `Color_Scale_Generator` emits `@theme` CSS partial; Tailwind compiles it | +| Light/dark color selection | Runtime (CSS) | `@theme` + `dark:` variants; `ThemeService` toggles `html.dark` | +| Logo `src` / `alt` | Runtime | `BrandingService` provides values; template binds them | +| Light/dark logo selection | Runtime (CSS) | Both `` present; `dark:hidden` / `hidden dark:block` | +| Greeting text | Runtime | `GreetingProvider` selects + substitutes `{name}` | + +### Build integration + +The `Color_Scale_Generator` runs as a `prebuild` / `prestart` npm script (before `ng build` / `ng serve`), reading `brand.config.ts` and writing `src/styles/generated/brand-theme.css`. `styles.css` imports that partial. The generated file is committed so a clean checkout renders correctly and diffs are reviewable; regeneration is deterministic, so committing it does not create churn unless a `Brand_Color` actually changed. + +## Components and Interfaces + +### Brand_Config (`src/branding/brand.config.ts`) + +The single source of truth. A plain exported constant conforming to the `BrandConfig` interface. This is the only file a `Forker` edits for non-logo values. + +### BrandingService (`src/branding/branding.service.ts`) + +The single runtime access boundary (Requirement 8.2). `providedIn: 'root'`. Reads `Brand_Config` once, validates/normalizes each field against defaults, and exposes read-only accessors. It never throws on bad config; it substitutes defaults and records error indications. + +```typescript +@Injectable({ providedIn: 'root' }) +export class BrandingService { + /** Normalized, always-usable logo asset references. */ + readonly logo: { light: string; dark: string }; + /** Normalized app name (falls back to a non-empty default label). */ + readonly appName: string; + /** Normalized greeting template list (>= 1 entry, or empty if none valid). */ + readonly greetingTemplates: readonly string[]; + /** Normalized fallback greeting list (>= 1 entry, or empty if none valid). */ + readonly fallbackGreetings: readonly string[]; + /** Non-fatal problems found while reading Brand_Config (for surfacing/logging). */ + readonly configErrors: readonly BrandConfigError[]; +} +``` + +### GreetingProvider (`src/branding/greeting.provider.ts`) + +Encapsulates greeting selection and `{name}` substitution. Consumed by `session.page.ts` (replacing the hardcoded arrays and the `computed` greeting) and reused by any other empty-state greeting. + +```typescript +@Injectable({ providedIn: 'root' }) +export class GreetingProvider { + /** + * Resolve the greeting string to display. + * @param firstName current user's first name, possibly null/blank + */ + resolveGreeting(firstName: string | null | undefined): string; +} +``` + +Selection rule (deterministic given a chosen index; a random index is chosen once per session to match current behavior): + +1. If `firstName` has at least one non-whitespace character AND `greetingTemplates` is non-empty → pick a template, replace **every** `{name}` occurrence with `firstName` (using `replaceAll`, fixing today's first-only `.replace`). (R4.3, R4.5) +2. Else if `fallbackGreetings` is non-empty → return a fallback entry. (R1.7, R4.4, R4.7) +3. Else → return the built-in `DEFAULT_GREETING` constant, which contains no `{name}`. (R4.8) + +### Color_Scale_Generator (`scripts/branding/generate-brand-theme.ts`) + +A Node/TypeScript build script. Pure transformation from three hex strings to a CSS string. + +```typescript +/** The 11 Tailwind steps in order. */ +const STEPS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950] as const; + +/** Fixed lightness deltas applied via oklch(from #hex calc(l + delta) c h). + * step 500 is the literal hex (delta 0, emitted as the hex itself). */ +const LIGHTNESS_DELTA: Record = { + 50: 0.4, 100: 0.35, 200: 0.3, 300: 0.2, 400: 0.1, + 500: 0, + 600: -0.1, 700: -0.15, 800: -0.2, 900: -0.25, 950: -0.3, +}; + +/** Produce the 11 CSS declarations for one role. */ +function generateScale(role: 'primary' | 'secondary' | 'tertiary', hex: string): string; + +/** Produce the full @theme color block for all three roles. */ +function generateBrandTheme(config: BrandConfig): { css: string; errors: BrandConfigError[] }; +``` + +For each non-500 step it emits `--color-{role}-{step}: oklch(from {hex} calc(l {+|-} {|delta|}) c h);`. For step 500 it emits `--color-{role}-500: {hex};`. Invalid hexes are rejected and the role falls back to the `Default_Branding` hex, with an error recorded (R5.7, R8.5). + +### Sidenav_Component & Chat_Greeting_Block (template changes) + +Both keep the dual-`` CSS-swap structure and bind `src`/`alt` from `BrandingService`, plus an `(error)` handler for missing assets: + +```html + + +``` + +`onLogoError` marks the image as failed and reveals a same-dimension placeholder with a visible "logo failed to load" indication, without collapsing layout or blocking surrounding content (R2.8). + +## Data Models + +### BrandConfig + +```typescript +/** A 6-digit hex color input, with optional leading '#'. Validated at read time. */ +export type HexColorInput = string; + +export interface BrandLogoAssets { + /** Documented path to the light-theme logo (served from /public). */ + light: string; + /** Documented path to the dark-theme logo (served from /public). */ + dark: string; +} + +export interface BrandColors { + primary: HexColorInput; // Default_Branding: #0033a0 + secondary: HexColorInput; // Default_Branding: #d64309 + tertiary: HexColorInput; // Default_Branding: #0072ce +} + +export interface BrandConfig { + /** Light/dark logo file references (Requirement 2.1). */ + logo: BrandLogoAssets; + /** App name / logo alt text, 1–100 chars, >=1 non-whitespace (Requirement 3.1). */ + appName: string; + /** Ordered greeting templates, 1–50 entries, each 1–500 chars (Requirement 4.1). */ + greetingTemplates: string[]; + /** Ordered fallback greetings, 1–50 entries, each 1–500 chars (Requirement 4.2). */ + fallbackGreetings: string[]; + /** Brand colors as single hex inputs (Requirement 5.1, 8.3). */ + colors: BrandColors; +} +``` + +Each field is one distinct named slot (Requirement 8.1), and every color role is a single hex input a future runtime writer could supply (Requirement 8.3). + +### BrandConfigError + +```typescript +export interface BrandConfigError { + /** Which field was invalid, e.g. 'colors.primary', 'appName'. */ + field: string; + /** The offending value (for surfacing/identification), if representable. */ + value?: string; + /** Human-readable reason. */ + reason: string; +} +``` + +### Default_Branding constants (`src/branding/brand.defaults.ts`) + +Frozen constants capturing today's shipped values, used as fallbacks by the access boundary and the generator: + +- `DEFAULT_LOGO = { light: 'img/logo-light.png', dark: 'img/logo-dark.png' }` +- `DEFAULT_APP_NAME = 'Boise State University Logo'` +- `DEFAULT_ALT_LABEL = 'Logo'` (the non-empty default alt when `appName` is blank — R3.5) +- `DEFAULT_GREETING_TEMPLATES` / `DEFAULT_FALLBACK_GREETINGS` = the exact arrays currently in `session.page.ts` +- `DEFAULT_GREETING = 'How can I help you today?'` (built-in ultimate fallback, no `{name}` — R4.8) +- `DEFAULT_COLORS = { primary: '#0033a0', secondary: '#d64309', tertiary: '#0072ce' }` + +### Validation rules (applied at the access boundary and by the generator) + +| Field | Rule | On failure | +| --- | --- | --- | +| `colors.{role}` | matches `/^#?[0-9a-fA-F]{6}$/` | use default hex for role, record error (R5.7, R8.5) | +| `appName` | 1–100 chars, ≥1 non-whitespace | use `DEFAULT_ALT_LABEL`, record error (R3.5) | +| `logo.{light,dark}` | non-empty string path | use default path, record error | +| `greetingTemplates` | array, 1–50 entries, each 1–500 chars | drop invalid entries; if none valid, treat as empty → fallback chain (R4.7) | +| `fallbackGreetings` | array, 1–50 entries, each 1–500 chars | drop invalid entries; if none valid → built-in default (R4.8) | +| whole config | importable/parseable | render with `Default_Branding`, record error (R7.5) | + +## Correctness Properties + +*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* + +The two pure cores of this feature — the `Color_Scale_Generator` (hex → CSS scale) and the `GreetingProvider` / access-boundary normalization (config + name → usable value) — are well suited to property-based testing. UI wiring, CSS-driven theme switching, byte-identical regression checks, and documentation are covered by example, snapshot, and edge-case tests in the Testing Strategy instead. + +### Property 1: Color scale structure + +*For any* valid 6-digit `Brand_Color` hex and role, the generated scale contains exactly 11 declarations named `--color-{role}-{step}` for steps `50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950` in that order, and the step-500 declaration is the literal input hex, unchanged. + +**Validates: Requirements 5.2, 5.4** + +### Property 2: Lightness derivation holds chroma and hue + +*For any* valid 6-digit `Brand_Color` hex, every non-500 step is emitted as `oklch(from {hex} calc(l {±delta}) c h)` — holding chroma (`c`) and hue (`h`) equal to the input — and the applied lightness offsets are strictly decreasing from step 50 to step 950, so steps 50–400 are lighter than 500 (positive offset) and steps 600–950 are darker than 500 (negative offset). + +**Validates: Requirements 5.3** + +### Property 3: Generator determinism + +*For any* valid `Brand_Config`, generating the brand theme twice produces character-for-character identical CSS, and changing a single role's hex changes only that role's declarations, leaving the other two roles' declarations unchanged. + +**Validates: Requirements 5.5** + +### Property 4: Invalid hex rejection + +*For any* color-role value that is not a valid 6-digit hexadecimal input (with or without a leading `#`), the system rejects the value, uses the `Default_Branding` hex for that role, and records an error indication that identifies both the offending value and the role. + +**Validates: Requirements 5.7, 8.5** + +### Property 5: Config normalization supplies usable defaults + +*For any* `Brand_Config` in which any field is absent, empty, out of bounds, or otherwise invalid (including an absent or unparseable config entirely), every value read through the `BrandingService` access boundary is a usable value — the valid provided value when acceptable, otherwise the defined `Default_Branding` value for that field — and an error indication is recorded for each defaulted field. + +**Validates: Requirements 3.1, 4.1, 4.2, 7.5, 8.4** + +### Property 6: Named greeting substitution + +*For any* first name containing at least one non-whitespace character and any non-empty template list, the resolved greeting is one of the configured templates with every `{name}` occurrence replaced by the first name, and the result contains no remaining `{name}` placeholder. + +**Validates: Requirements 4.3, 4.5** + +### Property 7: Fallback greeting selection + +*For any* resolution where the first name is absent, null, empty, or whitespace-only, or where the template list is empty or unreadable, the resolved greeting is a member of the configured fallback list (when that list is non-empty). + +**Validates: Requirements 1.7, 4.4, 4.7, 7.4** + +### Property 8: Ultimate default greeting + +*For any* resolution where both the template list and the fallback list are empty or undefined, the resolved greeting equals the built-in default greeting and contains no `{name}` placeholder. + +**Validates: Requirements 4.8** + +### Property 9: Logo alt text equals normalized app name + +*For any* `App_Name` value, every branding logo image rendered by the `Sidenav_Component` and the `Chat_Greeting_Block` has identical alt text equal to the normalized app name — the `App_Name` when it is valid, and a fixed non-empty default label when the `App_Name` is absent or whitespace-only. + +**Validates: Requirements 3.2, 3.3, 3.4, 3.5** + +## Error Handling + +All error handling is non-blocking: branding problems degrade to `Default_Branding` and surface an indication, never halting application render (Requirements 2.8, 5.7, 7.5, 8.4, 8.5). + +| Failure | Detection | Response | Requirement | +| --- | --- | --- | --- | +| Invalid `Brand_Color` hex | Regex `/^#?[0-9a-fA-F]{6}$/` at generation and read time | Use default hex for that role; record `BrandConfigError{ field: 'colors.{role}', value, reason }`; build logs a warning | 5.7, 8.5 | +| Blank/oversized `appName` | Length + non-whitespace check | Use `DEFAULT_ALT_LABEL` for alt text; record error | 3.5, 8.4 | +| Missing/empty logo path | Empty-string check | Use default path; record error | 8.4 | +| Logo file absent / fails to load | `` `(error)` event | Reveal same-dimension placeholder with visible "logo failed to load" indication; keep surrounding content rendered | 2.8 | +| Empty/invalid `greetingTemplates` | Array validation, entry bounds | Drop invalid entries; if none valid, fall through to fallbacks | 1.7, 4.7 | +| Empty/invalid `fallbackGreetings` | Array validation, entry bounds | Drop invalid entries; if none valid, use built-in `DEFAULT_GREETING` | 4.8 | +| Absent/empty/unparseable `Brand_Config` | Import guard / shape check in `BrandingService` | Render with full `Default_Branding`; record error | 7.5 | + +The `BrandingService` exposes `configErrors` so a developer-visible surface (console warning at minimum, and an optional dev-mode banner) can report rejected values without affecting end users. + +## Testing Strategy + +### Dual approach + +- **Property-based tests** verify the universal properties above across many generated inputs — the `Color_Scale_Generator` and the `GreetingProvider`/normalization logic. +- **Unit / example tests** verify specific wiring, edge cases, and error handling. +- **Snapshot / golden tests** verify byte- and character-level regression against the current appearance. + +### Property-based testing + +- **Library:** `fast-check` with Vitest (the frontend's existing test runner). Do not hand-roll generators or a PBT harness. +- **Iterations:** minimum 100 per property. +- **Tagging:** each property test is tagged with a comment of the form + `// Feature: branding-customization, Property {number}: {property text}`. +- **Generators:** + - Valid hex: 6 hex digits, optional `#`, mixed case (covers R8.3 case-insensitivity and optional prefix). + - Invalid hex: wrong length, non-hex chars, empty, `null`/`undefined`. + - App name: arbitrary strings including empty, whitespace-only, 1-char, 100-char, 101-char, and unicode. + - Names: arbitrary strings including `null`, `undefined`, empty, whitespace-only, and names containing `{name}`. + - Templates/fallbacks: arrays from empty up to >50 entries, entries from empty to >500 chars, templates with 0..N `{name}` occurrences. +- **Mapping:** Property 1–4 → `Color_Scale_Generator`; Property 5 → `BrandingService` normalization; Property 6–8 → `GreetingProvider`; Property 9 → rendered `Sidenav`/`Chat_Greeting_Block` via component tests driven by generated app names. + +### Example & edge-case unit tests + +- Components read logo `src`/`alt` from `BrandingService`, not literals (R1.3, R2.2, R2.3, R4.6, R8.2). +- Theme toggle swaps the visible logo via the `dark:` CSS classes with no navigation/reload (R2.4, R2.5, R2.6, R7.3). +- Logo `(error)` handler renders the same-dimension placeholder and keeps surrounding content (R2.8). +- Shape assertions that `Brand_Config` exposes every named field (R1.1, R1.2, R2.1, R8.1). + +### Snapshot / golden regression tests + +- **Colors (R7.2):** run the generator with the `Default_Branding` hexes and assert the output equals the current committed `@theme` color block, character-for-character. This is the guard that centralizing colors does not change any pixel. +- **Logos (R7.1):** assert default logo paths equal the current paths (`img/logo-light.png`, `img/logo-dark.png`). +- **Greetings (R7.4):** assert `DEFAULT_GREETING_TEMPLATES` / `DEFAULT_FALLBACK_GREETINGS` equal the arrays currently in `session.page.ts`. + +### Documentation verification + +- A checklist review confirms the `Rebranding_Documentation` covers logo swap steps + paths, per-value edit steps, the single-location statement, `{name}` behavior, hex-regeneration behavior, and verification steps (R6.1–R6.6). +- A doc-presence test asserts a heading containing "Non-Goals" exists and each deferred capability (admin UI / "Option 2", backend persistence, runtime logo uploads, runtime color/branding overrides) is stated (R9.1–R9.5). diff --git a/.kiro/specs/branding-customization/requirements.md b/.kiro/specs/branding-customization/requirements.md new file mode 100644 index 000000000..aa995d6c7 --- /dev/null +++ b/.kiro/specs/branding-customization/requirements.md @@ -0,0 +1,162 @@ +# Requirements Document + +## Introduction + +This feature establishes a plug-n-play, build-time / deploy-time branding foundation for the AgentCore Public Stack Angular frontend. The stack is open source and intended to be forked and rebranded. Today, branding values (logo asset paths, logo alt text / app name, chat greeting text, and brand colors) are scattered and hardcoded across component templates, TypeScript source, and the global stylesheet. This makes rebranding error-prone and undocumented. + +This feature centralizes the rebrandable surface into a single, well-documented source of truth so that a forker can rebrand the application by (a) replacing a documented set of logo asset files and (b) editing brand values (hex colors, greeting text, app name) in one config location, from which the full derived Tailwind color scales regenerate. The change must preserve the existing light/dark theme behavior and must not alter the current appearance when the default (current) branding values are used. + +The configuration shape is deliberately designed to be forward-compatible with a future runtime admin customization page ("Option 2"), so that a future runtime writer can populate the same values without rework. + +### Scope + +In scope — the following are the ONLY rebrandable elements: +1. Logo images (sidenav top-left and chat greeting block), including light and dark variants. +2. Logo alt text / app name (currently hardcoded to "Boise State University Logo"). +3. Chat greeting text (greeting templates and fallback greetings shown on a new/empty chat). +4. Brand colors used across light and dark themes (primary, secondary, tertiary), including the full derived color scales. + +Out of scope (explicit non-goals — see Requirement 9): +- Any admin dashboard or in-app UI for editing branding. +- Backend persistence of branding values. +- S3 or runtime logo uploads. +- Runtime color or branding overrides. + +## Glossary + +- **Branding_System**: The frontend branding foundation delivered by this feature, comprising the branding configuration source of truth, the color scale derivation, and the components/styles that consume branding values. +- **Brand_Config**: The single source-of-truth configuration artifact that holds all rebrandable branding values (logo asset references, app name / logo alt text, greeting text, and brand hex colors). +- **Brand_Color**: A single hex color value provided by a forker for one of the named brand roles (primary, secondary, tertiary). +- **Color_Scale**: The set of eleven derived color steps (50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950) generated from a single Brand_Color, where step 500 is the literal Brand_Color hex and the remaining steps are derived by adjusting lightness while holding chroma and hue. +- **Color_Scale_Generator**: The build-time mechanism that produces the Tailwind `@theme` color scale declarations from the Brand_Color values. +- **Logo_Asset**: A logo image file referenced by the Brand_Config. Each logo has a light-theme variant and a dark-theme variant. +- **App_Name**: The human-readable product/organization name used as the logo alt text and any name-based branding string. +- **Greeting_Template**: A greeting string shown on a new/empty chat that contains the `{name}` placeholder for the current user's first name. +- **Fallback_Greeting**: A greeting string shown on a new/empty chat when the current user's first name is not available. +- **Greeting_Provider**: The frontend logic that selects and renders a greeting from the Greeting_Templates or Fallback_Greetings defined in the Brand_Config. +- **Default_Branding**: The current, shipped branding values (Boise State University logos, alt text, greeting arrays, and the primary `#0033a0`, secondary `#d64309`, tertiary `#0072ce` colors). +- **Forker**: A developer who clones/forks the repository to deploy a rebranded instance of the application. +- **Sidenav_Component**: The frontend navigation sidebar component that renders the logo top-left (`frontend/ai.client/src/app/components/sidenav/sidenav.html`). +- **Chat_Greeting_Block**: The empty-chat greeting area that renders the logo and greeting message (`frontend/ai.client/src/app/session/components/chat-container/chat-container.component.html`). +- **Rebranding_Documentation**: The README/docs content that explains the rebranding process to a Forker. + +## Requirements + +### Requirement 1: Single Source of Truth for Branding Values + +**User Story:** As a Forker, I want all rebrandable branding values collected in one documented location, so that I can rebrand the application without hunting through templates and source files. + +#### Acceptance Criteria + +1. THE Branding_System SHALL define all rebrandable branding values in a single Brand_Config location. +2. THE Brand_Config SHALL include the Logo_Asset references, the App_Name, the Greeting_Templates, the Fallback_Greetings, and the Brand_Color values for the primary, secondary, and tertiary roles. +3. WHERE a rebrandable value is consumed by the Sidenav_Component, the Chat_Greeting_Block, or the Greeting_Provider, THE Branding_System SHALL read that value from the Brand_Config rather than from a hardcoded literal in a component template or component TypeScript source. +4. THE Brand_Config SHALL define each of the primary, secondary, and tertiary brand color roles as a single valid 6-digit hexadecimal Brand_Color value. +5. THE Brand_Config SHALL define the App_Name as a non-empty text value between 1 and 50 characters in length. +6. THE Brand_Config SHALL provide at least one Greeting_Templates entry and at least one Fallback_Greetings entry. +7. IF the Greeting_Provider cannot read a valid Greeting_Templates value from the Brand_Config, THEN THE Greeting_Provider SHALL render a Fallback_Greetings value in the Chat_Greeting_Block. + +### Requirement 2: Configurable Logo Assets + +**User Story:** As a Forker, I want to replace the logo images by swapping documented files, so that my organization's logo appears in the sidenav and the chat greeting block. + +#### Acceptance Criteria + +1. THE Brand_Config SHALL reference exactly one light-theme Logo_Asset and exactly one dark-theme Logo_Asset, each identified by a documented file path. +2. WHEN the application renders the Sidenav_Component logo, THE Branding_System SHALL use the Logo_Asset references from the Brand_Config. +3. WHEN the application renders the Chat_Greeting_Block logo, THE Branding_System SHALL use the Logo_Asset references from the Brand_Config. +4. WHILE the active theme is light, THE Branding_System SHALL display the light-theme Logo_Asset in both the Sidenav_Component and the Chat_Greeting_Block. +5. WHILE the active theme is dark, THE Branding_System SHALL display the dark-theme Logo_Asset in both the Sidenav_Component and the Chat_Greeting_Block. +6. WHEN the active theme changes between light and dark, THE Branding_System SHALL update the displayed Logo_Asset to match the newly active theme within 1 second and without a full page reload. +7. WHERE a Forker replaces the documented Logo_Asset files at the documented paths without editing component templates, THE Branding_System SHALL display the replacement logos in the Sidenav_Component and the Chat_Greeting_Block. +8. IF a referenced Logo_Asset file is absent at its documented path or cannot be loaded, THEN THE Branding_System SHALL render the Sidenav_Component and the Chat_Greeting_Block with their existing layout dimensions preserved and SHALL surface a visible indication that the logo failed to load, without blocking rendering of surrounding content. + +### Requirement 3: Configurable App Name and Logo Alt Text + +**User Story:** As a Forker, I want the app name and logo alt text to come from configuration, so that accessibility labels reflect my brand instead of the original organization. + +#### Acceptance Criteria + +1. THE Brand_Config SHALL define exactly one App_Name value as a text string containing 1 to 100 characters with at least one non-whitespace character. +2. WHEN the Sidenav_Component renders a logo image, THE Branding_System SHALL set the image alt text to a value equal to the App_Name. +3. WHEN the Chat_Greeting_Block renders a logo image, THE Branding_System SHALL set the image alt text to a value equal to the App_Name. +4. THE Branding_System SHALL set identical alt text equal to the App_Name on every branding logo image rendered by the Sidenav_Component and the Chat_Greeting_Block. +5. IF the App_Name is absent or contains only whitespace when a branding logo image is rendered, THEN THE Branding_System SHALL set the image alt text to a non-empty default label and render the image without error. + +### Requirement 4: Configurable Chat Greeting Text + +**User Story:** As a Forker, I want the chat greeting text to come from configuration, so that new/empty chats greet users with my brand's messaging. + +#### Acceptance Criteria + +1. THE Brand_Config SHALL define the Greeting_Templates as an ordered list of 1 to 50 greeting strings, each containing 1 to 500 characters. +2. THE Brand_Config SHALL define the Fallback_Greetings as an ordered list of 1 to 50 greeting strings, each containing 1 to 500 characters. +3. WHEN the Greeting_Provider renders a greeting and the current user's first name is present and contains at least one non-whitespace character, THE Greeting_Provider SHALL select one Greeting_Template and render it with every `{name}` placeholder replaced by the current user's first name. +4. WHEN the Greeting_Provider renders a greeting and the current user's first name is absent, null, empty, or whitespace-only, THE Greeting_Provider SHALL render one Fallback_Greeting selected from the Brand_Config. +5. WHERE a Greeting_Template contains the `{name}` placeholder, THE Greeting_Provider SHALL replace every occurrence of `{name}` with the current user's first name. +6. THE Greeting_Provider SHALL read the Greeting_Templates and Fallback_Greetings from the Brand_Config rather than from hardcoded arrays in component TypeScript source. +7. IF the Greeting_Templates list is empty or undefined, THEN THE Greeting_Provider SHALL render a Fallback_Greeting. +8. IF both the Greeting_Templates and Fallback_Greetings lists are empty or undefined, THEN THE Greeting_Provider SHALL render a built-in default greeting that contains no `{name}` placeholder. + +### Requirement 5: Configurable Brand Colors with Derived Scales + +**User Story:** As a Forker, I want to set brand colors from single hex values, so that the full light and dark theme color scales update to match my brand. + +#### Acceptance Criteria + +1. THE Brand_Config SHALL define one Brand_Color value for each of the primary, secondary, and tertiary roles as a 6-digit hexadecimal value (#RRGGBB, digits 0-9 and A-F, case-insensitive). +2. WHEN the Color_Scale_Generator produces a Color_Scale for a role, THE Color_Scale_Generator SHALL set the step-500 value to the literal Brand_Color hex for that role, unchanged. +3. WHEN the Color_Scale_Generator produces a Color_Scale for a role, THE Color_Scale_Generator SHALL derive the non-500 steps from the Brand_Color by adjusting lightness while holding chroma and hue equal to the Brand_Color, such that steps 50 through 400 are progressively lighter and steps 600 through 950 are progressively darker. +4. THE Color_Scale_Generator SHALL produce exactly 11 Color_Scale steps (50, 100, 200, 300, 400, 500, 600, 700, 800, 900, and 950) for each of the primary, secondary, and tertiary roles. +5. WHEN a Forker edits a Brand_Color hex value in the Brand_Config and the value is saved, THE Branding_System SHALL regenerate the full Color_Scale for the corresponding role used by the Tailwind theme. +6. THE Branding_System SHALL apply the generated Color_Scale values to both the light theme and the dark theme. +7. IF a Brand_Color value is not a valid 6-digit hexadecimal value, THEN THE Branding_System SHALL reject the value, retain the prior Color_Scale for that role, and surface an error indication identifying the offending Brand_Color and role. + +### Requirement 6: Rebranding Documentation + +**User Story:** As a Forker, I want clear documentation of the rebranding process, so that I can rebrand the application quickly without reading the source code. + +#### Acceptance Criteria + +1. THE Rebranding_Documentation SHALL provide the ordered steps to replace the Logo_Asset files and the file paths for both the light-theme variant and the dark-theme variant, such that each variant is individually identified. +2. THE Rebranding_Documentation SHALL provide the ordered steps to edit each of the App_Name, the Greeting_Templates, the Fallback_Greetings, and the Brand_Color values in the Brand_Config, listing each value by name. +3. THE Rebranding_Documentation SHALL identify the single Brand_Config location as the one place to edit all branding values, and SHALL state that no other file requires editing to complete rebranding. +4. THE Rebranding_Documentation SHALL state that the `{name}` placeholder in a Greeting_Template is replaced at runtime with the current user's first name, and SHALL state the displayed result when no first name is available. +5. THE Rebranding_Documentation SHALL state that editing a Brand_Color hex value regenerates the derived Color_Scale for that role, and SHALL state the accepted hex value format for a Brand_Color entry. +6. THE Rebranding_Documentation SHALL provide the observable steps a Forker performs to verify that each edited branding value appears in the running application after rebranding. + +### Requirement 7: Preserve Existing Appearance with Default Branding + +**User Story:** As a maintainer of the upstream stack, I want the default branding to render exactly as it does today, so that centralizing branding does not regress the current appearance. + +#### Acceptance Criteria + +1. WHERE the Brand_Config holds the Default_Branding values, THE Branding_System SHALL render logos in the Sidenav_Component and the Chat_Greeting_Block that are byte-for-byte identical to the logo assets rendered by the current application for the same theme. +2. WHERE the Brand_Config holds the Default_Branding values, THE Branding_System SHALL produce primary, secondary, and tertiary Color_Scale values that are character-for-character identical to the corresponding values defined in the current `@theme` block. +3. WHEN the active theme changes and the Brand_Config holds the Default_Branding values, THE Branding_System SHALL perform the logo and color theme switching with no additional or missing switch events compared to the current behavior. +4. WHERE the Brand_Config holds the Default_Branding values, THE Greeting_Provider SHALL render greetings that are character-for-character identical to an entry contained in the current Default_Branding greeting text set. +5. IF the Brand_Config is absent, empty, or unparseable, THEN THE Branding_System SHALL render the application using the Default_Branding values and surface an error indication that the Brand_Config could not be read, without blocking application rendering. + +### Requirement 8: Forward-Compatible Configuration Shape + +**User Story:** As a maintainer planning a future runtime admin customization page (Option 2), I want the branding configuration shape to be reusable by a future runtime writer, so that Option 2 can populate the same values without reworking the branding foundation. + +#### Acceptance Criteria + +1. THE Brand_Config SHALL represent branding values in a structured shape that contains one distinct named field for each of the Logo_Asset references, the App_Name, the Greeting_Templates, the Fallback_Greetings, and the Brand_Color values. +2. WHEN a consuming component reads a branding value, THE Branding_System SHALL provide that value through a single defined access boundary such that the value source can change without modifying the consuming component. +3. THE Brand_Config shape SHALL represent each Brand_Color role as a single hexadecimal color input value in 6-digit form (with an optional leading "#"), so that a future runtime writer can supply the same input the Color_Scale_Generator consumes. +4. IF a Brand_Config field is absent or empty when read through the access boundary, THEN THE Branding_System SHALL supply the defined default value for that field so that every consuming component receives a usable branding value. +5. IF a Brand_Color role value is not a valid 6-digit hexadecimal color input value, THEN THE Branding_System SHALL reject the invalid value, apply the default Brand_Color for that role, and record an indication that the value was rejected. + +### Requirement 9: Documented Non-Goals for Deferred Runtime Customization + +**User Story:** As a maintainer, I want the deferred runtime customization capabilities documented as non-goals, so that the scope of this feature is unambiguous and Option 2 is clearly separated. + +#### Acceptance Criteria + +1. THE Rebranding_Documentation SHALL contain a section, identified by a heading that includes the term "Non-Goals", that lists all capabilities deferred from this feature. +2. WHERE the Non-Goals section is present, THE Rebranding_Documentation SHALL state that an in-app admin UI for editing branding is out of scope for this feature and deferred to a future capability explicitly labeled "Option 2". +3. WHERE the Non-Goals section is present, THE Rebranding_Documentation SHALL state that backend persistence of branding values is out of scope for this feature. +4. WHERE the Non-Goals section is present, THE Rebranding_Documentation SHALL state that runtime logo uploads are out of scope for this feature. +5. WHERE the Non-Goals section is present, THE Rebranding_Documentation SHALL state that runtime color overrides and runtime branding overrides are out of scope for this feature. diff --git a/.kiro/specs/branding-customization/tasks.md b/.kiro/specs/branding-customization/tasks.md new file mode 100644 index 000000000..450a3d95f --- /dev/null +++ b/.kiro/specs/branding-customization/tasks.md @@ -0,0 +1,162 @@ +# Implementation Plan: Branding Customization + +## Overview + +Convert the branding-customization design into a series of incremental, test-driven coding steps for the Angular v21 frontend at `frontend/ai.client/`. The work builds a single `Brand_Config` source of truth, a build-time `Color_Scale_Generator` that emits the Tailwind `@theme` color scales, a runtime `BrandingService` access boundary with defensive defaults, a `GreetingProvider` that fixes the current first-only `{name}` replacement, and the Sidenav / Chat_Greeting_Block template wiring — all guaranteeing byte/character-identical output under `Default_Branding`. + +Property-based tests use `fast-check` + Vitest (already project dependencies), minimum 100 iterations each, tagged with `// Feature: branding-customization, Property {number}: {property text}`. + +All paths are relative to `frontend/ai.client/`. + +## Tasks + +- [x] 1. Establish the branding configuration foundation + - [x] 1.1 Define branding types and error shape + - Create `src/branding/brand.types.ts` exporting `HexColorInput`, `BrandLogoAssets`, `BrandColors`, `BrandConfig`, and `BrandConfigError` exactly as specified in the Data Models section + - Each config field is one distinct named slot; each color role is a single hex input + - _Requirements: 1.2, 8.1, 8.3_ + + - [x] 1.2 Create Default_Branding constants + - Create `src/branding/brand.defaults.ts` with frozen constants: `DEFAULT_LOGO = { light: 'img/logo-light.png', dark: 'img/logo-dark.png' }`, `DEFAULT_APP_NAME = 'Boise State University Logo'`, `DEFAULT_ALT_LABEL = 'Logo'`, `DEFAULT_GREETING_TEMPLATES` and `DEFAULT_FALLBACK_GREETINGS` (copied verbatim from the current arrays in `session.page.ts`), `DEFAULT_GREETING = 'How can I help you today?'`, and `DEFAULT_COLORS = { primary: '#0033a0', secondary: '#d64309', tertiary: '#0072ce' }` + - _Requirements: 3.5, 4.8, 5.1, 7.1, 7.4_ + + - [x] 1.3 Create the single Brand_Config source of truth + - Create `src/branding/brand.config.ts` exporting a `BrandConfig` constant populated with the `Default_Branding` values so a clean checkout renders exactly as today + - This is the only file a Forker edits for non-logo values + - _Requirements: 1.1, 1.2, 2.1, 3.1, 4.1, 4.2, 5.1, 8.1_ + + - [x]* 1.4 Write shape/example tests for Brand_Config + - Assert `Brand_Config` exposes every named field: `logo.light`, `logo.dark`, `appName`, `greetingTemplates`, `fallbackGreetings`, `colors.primary/secondary/tertiary` + - _Requirements: 1.1, 1.2, 2.1, 8.1_ + +- [x] 2. Implement the Color_Scale_Generator + - [x] 2.1 Implement hex validation and scale generation + - Create `scripts/branding/generate-brand-theme.ts` with `STEPS`, the `LIGHTNESS_DELTA` table, the hex regex `/^#?[0-9a-fA-F]{6}$/`, `generateScale(role, hex)` emitting `--color-{role}-{step}: oklch(from {hex} calc(l {+|-} {|delta|}) c h);` for non-500 steps and `--color-{role}-500: {hex};` for step 500, and `generateBrandTheme(config)` returning `{ css, errors }` + - Invalid hex is rejected: fall back to the `Default_Branding` hex for that role and record a `BrandConfigError` identifying the offending value and role + - _Requirements: 5.2, 5.3, 5.4, 5.7, 8.5_ + + - [x] 2.2 Write property test for generated scale structure and lightness derivation + - Single fast-check property test file covering both generator structure properties for an arbitrary valid 6-digit hex (optional leading `#`, mixed case). Required, not optional: the golden regression test (3.2) only pins the three `Default_Branding` hexes, so this property test is what guarantees the contract holds for the arbitrary hex a Forker actually supplies + - **Property 1: Color scale structure** — the generated scale contains exactly 11 declarations named `--color-{role}-{step}` for steps 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950 in order, and step-500 is the literal input hex, unchanged + - **Property 2: Lightness derivation holds chroma and hue** — every non-500 step is emitted as `oklch(from {hex} calc(l ±delta) c h)`, holding chroma and hue, and the applied lightness offsets are strictly decreasing from step 50 to step 950 + - **Validates: Requirements 5.2, 5.3, 5.4** + - fast-check + Vitest, min 100 iterations, tagged with both `// Feature: branding-customization, Property 1: ...` and `// Feature: branding-customization, Property 2: ...` + + - [x]* 2.3 Write property test for generator determinism + - **Property 3: Generator determinism** — generating twice yields character-for-character identical CSS, and changing one role's hex changes only that role's declarations + - **Validates: Requirements 5.5** + + - [x]* 2.4 Write property test for invalid hex rejection + - **Property 4: Invalid hex rejection** — any value that is not a valid 6-digit hex (with/without leading `#`) is rejected, uses the `Default_Branding` hex for the role, and records an error identifying the offending value and role + - **Validates: Requirements 5.7, 8.5** + +- [x] 3. Wire the generator into the build and emit the theme partial + - [x] 3.1 Generate the theme partial and consume it from styles.css + - Add a runnable entry point to `generate-brand-theme.ts` that writes `src/styles/generated/brand-theme.css` (an `@theme` block containing only the color scales); move the color-scale declarations out of `styles.css` into that partial while leaving `--font-sans` / `--font-sans--font-feature-settings` in `styles.css`; add `@import` of the generated partial to `styles.css`; wire `prebuild` and `prestart` npm scripts in `package.json` to run the generator; commit the generated file + - _Requirements: 5.5, 5.6_ + + - [x] 3.2 Write golden regression test for default color scales + - Run the generator with the `Default_Branding` hexes and assert the emitted color block equals the current committed `@theme` color declarations character-for-character + - _Requirements: 7.2_ + +- [x] 4. Implement the BrandingService access boundary + - [x] 4.1 Implement validation/normalization helpers + - In `src/branding/branding.service.ts` (or a colocated helper), implement per-field normalization: `appName` (1–100 chars, ≥1 non-whitespace → else `DEFAULT_ALT_LABEL`), logo paths (non-empty string → else default path), `greetingTemplates`/`fallbackGreetings` (array, 1–50 entries, each 1–500 chars, drop invalid entries), and `colors.{role}` (hex regex → else default hex); record a `BrandConfigError` for each defaulted field + - _Requirements: 3.1, 3.5, 4.1, 4.2, 5.7, 7.5, 8.4, 8.5_ + + - [x] 4.2 Implement the BrandingService + - Implement `BrandingService` (`providedIn: 'root'`) that reads `Brand_Config` once behind an import/shape guard (absent/empty/unparseable → full `Default_Branding`), exposes readonly `logo`, `appName`, `greetingTemplates`, `fallbackGreetings`, and `configErrors`; never throws; emits a developer-visible console warning for recorded errors + - _Requirements: 7.5, 8.2, 8.4_ + + - [x]* 4.3 Write property test for config normalization defaults + - **Property 5: Config normalization supplies usable defaults** — for any config with absent/empty/out-of-bounds/invalid fields (or an unreadable config entirely), every value read through the service is usable (valid provided value, else the `Default_Branding` value), and an error is recorded per defaulted field + - **Validates: Requirements 3.1, 4.1, 4.2, 7.5, 8.4** + + - [x]* 4.4 Write unit tests for the access boundary + - Absent/unparseable config → all `Default_Branding` + recorded error; blank `appName` → `DEFAULT_ALT_LABEL`; empty logo path → default path; confirm the value source can change without touching consumers + - _Requirements: 7.5, 8.2, 8.4_ + +- [x] 5. Implement the GreetingProvider + - [x] 5.1 Implement resolveGreeting with replaceAll and the fallback chain + - Create `src/branding/greeting.provider.ts` (`providedIn: 'root'`) reading templates/fallbacks from `BrandingService`, choosing a random index once per session: non-blank `firstName` + non-empty templates → selected template with **every** `{name}` replaced via `replaceAll` (fixing today's first-only `.replace`); else non-empty fallbacks → a fallback entry; else the built-in `DEFAULT_GREETING` (no `{name}`) + - _Requirements: 1.7, 4.3, 4.4, 4.5, 4.6, 4.7, 4.8_ + + - [x] 5.2 Write property test for named greeting substitution + - **Property 6: Named greeting substitution** — for any non-whitespace first name and non-empty template list, the result is a configured template with every `{name}` replaced and no `{name}` remaining + - **Validates: Requirements 4.3, 4.5** + + - [x] 5.3 Write property test for fallback greeting selection + - **Property 7: Fallback greeting selection** — for any absent/null/empty/whitespace name, or empty/unreadable template list, the result is a member of the configured fallback list (when non-empty) + - **Validates: Requirements 1.7, 4.4, 4.7, 7.4** + + - [x] 5.4 Write property test for the ultimate default greeting + - **Property 8: Ultimate default greeting** — when both template and fallback lists are empty/undefined, the result equals the built-in `DEFAULT_GREETING` and contains no `{name}` + - **Validates: Requirements 4.8** + +- [x] 6. Checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +- [x] 7. Wire branding into the components + - [x] 7.1 Update Sidenav_Component to consume BrandingService + - Inject `BrandingService` into the sidenav component; keep the dual-`` `dark:hidden` / `hidden dark:block` swap; bind `[src]` from `branding.logo.light`/`branding.logo.dark` and `[alt]` from `branding.appName`; add `(error)="onLogoError($event)"`; implement `onLogoError` to reveal a same-dimension placeholder with a visible "logo failed to load" indication without collapsing layout + - _Requirements: 1.3, 2.2, 2.4, 2.5, 2.7, 2.8, 3.2, 3.4_ + + - [x] 7.2 Update Chat_Greeting_Block to consume BrandingService + - In `chat-container.component`, inject `BrandingService`; keep the dual-`` swap on the greeting logo; bind `[src]`/`[alt]` from the service; add the `(error)` handler and same-dimension placeholder + - _Requirements: 1.3, 2.3, 2.4, 2.5, 2.7, 2.8, 3.3, 3.4_ + + - [x] 7.3 Replace the hardcoded greeting logic in session.page.ts with GreetingProvider + - Remove the hardcoded `greetingTemplates`/`fallbackGreetings` arrays and the inline `.replace`; delegate the `greetingMessage` computed to `GreetingProvider.resolveGreeting(firstName)` + - _Requirements: 1.3, 4.6_ + + - [x]* 7.4 Write property test for logo alt text equals normalized app name + - **Property 9: Logo alt text equals normalized app name** — component tests driven by generated app names assert every branding logo image in the Sidenav_Component and Chat_Greeting_Block has identical alt text equal to the normalized app name (valid `App_Name`, else the fixed default label) + - **Validates: Requirements 3.2, 3.3, 3.4, 3.5** + + - [x]* 7.5 Write component unit tests for theme swap and error handling + - Assert the dual-`` visible-logo swap on theme toggle occurs via `dark:` CSS with no navigation/reload; assert `(error)` reveals the same-dimension placeholder and keeps surrounding content rendered + - _Requirements: 2.4, 2.5, 2.6, 2.8, 7.3_ + +- [x] 8. Guard the default appearance with golden regression tests + - [x]* 8.1 Write golden logo-path and greeting regression tests + - Assert `DEFAULT_LOGO` equals `img/logo-light.png` / `img/logo-dark.png`; assert `DEFAULT_GREETING_TEMPLATES` / `DEFAULT_FALLBACK_GREETINGS` equal the arrays currently defined in `session.page.ts` + - _Requirements: 7.1, 7.4_ + +- [x] 9. Author the Rebranding_Documentation + - [x] 9.1 Write the rebranding guide including the Non-Goals section + - Create the rebranding docs: ordered logo-swap steps identifying both the light and dark variant paths; ordered edit steps for `App_Name`, `Greeting_Templates`, `Fallback_Greetings`, and `Brand_Color` values (each named); a statement that `Brand_Config` is the single edit location and no other file needs editing; the `{name}` runtime-substitution behavior and the no-first-name result; the hex→scale regeneration behavior and accepted hex format; observable verification steps; and a "Non-Goals" heading stating admin UI ("Option 2"), backend persistence, runtime logo uploads, and runtime color/branding overrides are out of scope + - _Requirements: 6.1, 6.2, 6.3, 6.4, 6.5, 6.6, 9.1, 9.2, 9.3, 9.4, 9.5_ + + - [x]* 9.2 Write doc-presence test for Non-Goals coverage + - Assert a heading containing "Non-Goals" exists and each deferred capability (admin UI / "Option 2", backend persistence, runtime logo uploads, runtime color/branding overrides) is stated + - _Requirements: 9.1, 9.2, 9.3, 9.4, 9.5_ + +- [x] 10. Final checkpoint - Ensure all tests pass and verify appearance in both themes + - Ensure all tests pass, ask the user if questions arise. + - **MANUAL VERIFICATION (not an automated test)** — run the app and confirm in **both light and dark themes**: the Sidenav_Component logo and the Chat_Greeting_Block logo render the correct theme variant and are visually unchanged from before the refactor; the primary / secondary / tertiary brand colors across the UI are unchanged; and toggling the theme swaps both logos and colors instantly with no page reload. Requirement 7 (preserve existing appearance) is an eyeball check that no automated test in this plan covers. + - _Requirements: 2.4, 2.5, 2.6, 7.1, 7.2, 7.3_ + +## Notes + +- Tasks marked with `*` are optional test tasks and can be skipped for a faster MVP. +- Each task references specific requirements for traceability. +- Property tests (fast-check + Vitest, min 100 iterations) are placed next to the code they validate to catch errors early; each is tagged `// Feature: branding-customization, Property {number}: {property text}`. +- Task 2.2 is a single required property test covering Properties 1 and 2; the remaining generator property tests (2.3, 2.4) stay optional. +- Golden/snapshot tests (3.2, 8.1) are the guard that centralizing branding does not change the current appearance under `Default_Branding`. +- Checkpoints ensure incremental validation. Task 10 additionally includes a manual light/dark visual verification step because Requirement 7 (preserve existing appearance) cannot be fully covered by automated tests. + +## Task Dependency Graph + +```json +{ + "waves": [ + { "id": 0, "tasks": ["1.1"] }, + { "id": 1, "tasks": ["1.2"] }, + { "id": 2, "tasks": ["1.3", "2.1", "4.1"] }, + { "id": 3, "tasks": ["1.4", "2.2", "2.3", "2.4", "3.1", "4.2", "9.1"] }, + { "id": 4, "tasks": ["3.2", "4.3", "4.4", "5.1", "8.1", "9.2"] }, + { "id": 5, "tasks": ["5.2", "5.3", "5.4", "7.1", "7.2", "7.3"] }, + { "id": 6, "tasks": ["7.4", "7.5"] } + ] +} +``` diff --git a/.kiro/specs/managed-kb-migration/HANDOFF.md b/.kiro/specs/managed-kb-migration/HANDOFF.md index 3ccf6c8c4..bfb513e2a 100644 --- a/.kiro/specs/managed-kb-migration/HANDOFF.md +++ b/.kiro/specs/managed-kb-migration/HANDOFF.md @@ -1,6 +1,6 @@ # Managed KB Migration — Handoff -**Last updated:** 2026-08-31 14:30 · **Shipped to production in 1.16.0, inert behind flags** · +**Last updated:** 2026-09-03 · **Shipped to production in 1.16.0, inert behind flags** · **A migration has completed end to end in dev; adding a document to it needed one more IAM action** Working state for this feature so a fresh session can pick it up without @@ -16,10 +16,13 @@ Four things invalidate earlier versions of this document: platform deploy succeeded on 2026-08-28, so `GSI7`, the Bedrock service role and the four Lambdas exist in **both** dev and prod. Earlier revisions of this file said "Nothing deployed"; that is no longer true. -2. **Eleven defects were found only by running it**, each reviewed clean and - deployed clean. They are §5 items 25–36 and they are the most useful part of - this document. Items 32–36 all trace to one root cause — the two engines were - never made exclusive — and are fixed in PR #900. +2. **Sixteen defects were found only by running it**, each reviewed clean and + deployed clean. They are §5 items 25–41 and they are the most useful part of + this document. Three clusters: 32–36 trace to the two engines never being made + exclusive (PR #900); 37–39 to the ingestion consumer never actually knowing when + a document was ready (PRs #901, #908); and **40–41 are about answer quality and + are both still OPEN** — the managed backend currently gives a *worse* answer than + legacy on a question it retrieves *better*. Start there. §5.33 is also open. 3. **The `document_id` "known unknown" was a false alarm** and is now resolved with measurements — see §6. An earlier revision listed it as the top open risk. The probe was reading facade keys that have never existed. Two genuine findings came @@ -43,10 +46,10 @@ Four things invalidate earlier versions of this document: |---|---| | Spec | Complete. Requirement **8.5 was amended by measurement on 2026-08-31** — see §5.29 | | Implementation | Groups 1–14 except 14.5. A migration has completed `shadow → verify → promote → retain` in dev and serves from the managed backend | -| Tests | 640 infra (jest) · ~6,780 backend (pytest) · 1,936 frontend (vitest) · 5 pre-existing unrelated Strands failures | +| Tests | 640 infra (jest) · ~6,840 backend (pytest) · 1,936 frontend (vitest) · 5 pre-existing unrelated Strands failures | | Deployed | **dev and prod.** Flags off in prod; `migrationEnabled` on in dev | -| Open PRs | **#900** — engine exclusivity: the legacy pipeline stands down for a promoted KB (§5.32, §5.34) and deletion propagates to the managed engine (§5.36). Four CI checks green. Merging triggers **both** `backend.yml` (rag-ingestion + kb-sync images) and `platform.yml` (two new IAM grants). · **#899** — this document. · #898 merged as `ef2f4c9e` | -| Uncommitted | none — working tree clean as of 2026-08-31 14:30 | +| Open PRs | **#908** — the filtered retrievability probe (§5.38) and `TEXT_INDEXED` (§5.39), plus this document. · merged: #898 `ef2f4c9e`, #899, #900 `df93471c`, #901 `a4b660ba` | +| Uncommitted | none | ### Flag state (GitHub Environment variables) @@ -784,6 +787,169 @@ the managed engine at all. Every symptom below follows from that. --- +### The twelfth through fourteenth: the ingestion consumer never actually knew when a document was ready + +All three are one theme. The consumer had to answer "is this document usable yet?" +and every mechanism it used to answer was measuring something else. + +37. ✅ **Three stacked bugs left a fully retrievable document parked at + `uploading` forever** (fixed in PR #901). A 1.5 MB PDF, uploaded to a promoted + knowledge base: + + | | | + |---|---| + | **Wrong measurement** | The consumer polled a *retrieval* for 30 s, justified in its own header by the `INDEXED → retrievable` gap of "0.75–1.03 s". But the poll starts when the ingest call returns, so it had to cover `ingest → INDEXED → retrievable` — measured at 37–264 s for PDFs in the evaluation's §5.1, and 5 m 30 s for this file. The budget was smaller than the documented *lower bound*. | + | **Self-defeating retries** | `IngestKnowledgeBaseDocuments` is fire-and-forget and nothing asked Bedrock what it already knew, so "not indexed yet" and "never submitted" were indistinguishable. All three deliveries re-ingested, discarding progress. The document reached INDEXED **54 s after the final attempt was dead-lettered**. | + | **Fabricated timestamp** | `indexed_at = _now_iso()` ran right after the ingest call returned — recording when we *asked*, labelled as when indexing *finished*. | + + Fixed by probing `GetKnowledgeBaseDocuments` first and branching on the real + status, never re-ingesting work already in flight, and using Bedrock's own + `updatedAt`. + + ⚠️ **Lambda's async retry is capped at 2 attempts.** A hard service limit, and + it is why the wait has to happen *inside* one invocation. I first "fixed" this + with a `RetryPolicy` on the EventBridge target, which does nothing: for a + Lambda target EventBridge hands the event off and the function's own async + retry config governs. The construct now carries a comment saying so instead of + the useless setting. Do not add it back. + + **Why no test caught the fabricated timestamp:** the test asserted only that + `indexedAt` existed and was truthy, which any fabricated value satisfies. Same + shape as §5.28 — the fake modelled instant success, so a 30 s window looked + adequate for work that takes minutes. + + This is §5.30 for a second time. `verify` had the identical bug against the + identical 0.75–1.03 s figure; that fix never reached this component, which + inherited the constant. **When a wrong constant is found, grep for its other + homes.** + +38. ✅ **The retrievability probe searched for the document id as query text and + could not find its own document** (fixed in PR #908). `wait_until_retrievable` + ran `search(kb_ref, document_id, 5)` — the id *as the query* — then checked + whether that document appeared. A document id is meaningless to an embedding + model, so the search returned whatever the reranker preferred. Measured in dev + with two documents present: + + ``` + query=DOC-40e985680a63 -> 5 chunks, ALL from DOC-db44eaf8f072 FOUND ITSELF: False + ``` + + A perfectly retrievable document reported as not retrievable. **It scales the + wrong way:** the more documents a knowledge base holds, the less likely the + target lands in an unfiltered top-5, so every upload to a mature knowledge base + would burn its poll budget and dead-letter. It only ever worked while the + knowledge base held exactly one document — where anything returned was + necessarily the right thing. + + Fixed with an `equals` filter on `document_id`, so a non-empty result *is* + proof and an empty one is a true negative. Verified in dev: each document + returns 5 of its own chunks, a fabricated id returns none. + + ⚠️ **This is the third iteration on this one function, and I was wrong about + what it measured twice.** Note also that yesterday's claim that a measured + 0.9 s gap "confirmed the 0.75–1.03 s figure" was false — with the fabricated + timestamp it was measuring ingest-return → retrievable, not INDEXED → + retrievable. A number agreeing with your expectation is not confirmation. + +39. ✅ **The live service returns `TEXT_INDEXED`, which is not in the packaged + SDK's `DocumentStatus` enum** (handled in PR #908). Observed on a document with + image extraction enabled: `TEXT_INDEXED` (text searchable, media still + processing) then `INDEXED`. The enum in the packaged model lists twelve values + and this is not among them, so **do not derive status handling from the SDK + enum** — it is incomplete against the running service. + + Treated as in-flight, not done: marking a document complete at `TEXT_INDEXED` + would tell a user an image-only page is ready while the vision model is still + running, which is the exact report the consumer exists to prevent. Unrecognised + statuses now also default to "keep waiting" rather than "unknown, give up", so + the next undeclared value AWS adds does not dead-letter documents. + + **A mutation-testing note worth keeping:** removing `TEXT_INDEXED` from the + in-flight set is *behaviour-equivalent*, because the unknown-status fallback + also waits — so the mutation survived every behavioural assertion. The honest + resolution was to assert the only thing that genuinely differs: that the status + is classified, and does not fall through the unknown branch. Not every + surviving mutant means a missing test; some mean the mutation changes nothing. + +--- + +### The fifteenth and sixteenth: retrieval got better and answers got worse + +These two are different in kind from everything above. Nothing is broken, no +error is raised, every test passes — and the managed backend gives a **worse +answer than legacy** on a question it retrieves *better*. Both are open. + +40. ⚠️ **OPEN, and the most consequential item in this document. The + 2,000-character context cap silently reduces `top_k=5` to `top_k=1` on the + managed backend.** Bedrock's chunks are roughly 3× larger than Docling's, and + `MAX_CONTEXT_CHARS` was sized for Docling's. Measured on one query: + + | | chunk sizes (chars) | how many fit in 2,000 | + |---|---|---| + | legacy | 388, 130, 1035, 106, 843 | **4** (1,659 chars) | + | managed | 1111, 1091, 1151, 1197, 877 | **1** (1,111 chars) | + + So reranking does real work — §5 and the evaluation both show it separating + scores properly — and then four of its five results are discarded before the + model sees them. + + **It produced a materially wrong answer.** Asked about `CS434`, legacy said it + is Major Core and mandatory (**correct** — the source PDF lists it under + `SECTION 2: MAJOR CORE (Complete ALL)`). Managed called it "part of a + specialized track/elective list" — the opposite of the advice a student needs — + and invented a course title. Not because retrieval failed: managed's top chunk + was the *only* one containing the literal string `CS434`, which legacy never + found at all. It failed because that single surviving chunk had lost its + `SECTION 2` header, and the nearest header it did contain was + `TECHNICAL ELECTIVES`. The model reasoned correctly over a truncated window. + + ⚠️ **This contradicts a spec exclusion.** requirements.md's out-of-scope list + says raising the cap is excluded because "the §13.6 experiment measured no + correctness change from 2,000 to 20,000 characters on either backend". That + experiment presumably ran on the three-document benchmark, where the answer sat + in the top chunk anyway. It does not hold once chunks are large enough that only + one fits **and** the context needed to interpret it lives in a neighbour. Same + shape as Requirement 8.5: measured under conditions that excluded the real case. + The exclusion note now carries this amendment. + + **Do not simply raise the number.** The cap is a parity control — §9 and §13.5 + require it held constant so the engine swap stays attributable. Changing it + changes both backends and forfeits that. The honest options are to raise it for + the managed path only and accept the asymmetry, or to re-run §13.6 on a corpus + where section context lives outside the top chunk. Either needs measurement, + not a constant bump. + + ⚠️ **My earlier `CS434` demo advice was wrong and the reason matters.** I + called it a "safe crowd-pleaser" on the strength of retrieval *score spread* + (0.0561 legacy versus 0.4988 managed). I measured the retriever and never + checked the answer. Score separation is not answer quality. + +41. ⚠️ **OPEN. Column-structured diagrams yield confidently wrong answers.** The + capability in §5.35 is real — an image-only flowchart that legacy cannot ingest + at all becomes retrievable, and Bedrock's vision model genuinely decodes it. + But asked "what should they take semester 4?" from a 3.5-year curriculum + flowchart, the answer reported **11 credits when the chart says 19**, invented + `ENGR 220` (which belongs to an earlier column), missed four courses, and + misdescribed `ME 215`. + + The cause is structural, not a tuning problem: correctness depends on **which + column a box sits in**, and a retrieved chunk carries no coordinates. The + vision model's description flattens or partially covers the columns, and the + model then assembles a plausible table from courses that are genuinely adjacent + in the document but belong to different semesters. + + Worth knowing how hard this is to spot: verifying it took three attempts with + the PDF open, because the first two column reconstructions mis-assigned + boundaries — the digit after "Semester" fell into the next bucket. If it takes + that to check, a chunk of prose was never going to carry it. + + **Guidance until this is understood:** image extraction is worth demonstrating + as *retrievable where it was previously impossible*, not as a source of precise + tabular answers. Do not put a per-column question from a diagram in front of an + audience. + +--- + ## 6. Remaining work ### Do these first @@ -798,6 +964,8 @@ the managed engine at all. Every symptom below follows from that. | Group | Notes | |---|---| +| **§5.40** the 2,000-char cap — START HERE | Managed's chunks are ~3× Docling's, so only ONE reaches the model and reranking's other four results are discarded. It produced a materially wrong answer (a required course described as an elective). Needs measurement, not a constant bump: the cap is a parity control (§9, §13.5). Options are managed-only asymmetry, or re-running §13.6 on a corpus where section context sits outside the top chunk | +| **§5.41** diagram answers | Column-structured diagrams give confident wrong answers because chunks carry no coordinates. Understand the shape before promising anything about tabular image content | | **§5.33** the one fail-open line | The only finding from 2026-08-31 still open. `if not doc_ids: return vectors` in `_filter_vectors_by_document_status`. Make it fail closed with `METRIC_STATUS_FILTER_FAIL_CLOSED` like every other unprovable path in that function, and pin it with a test that mutation-fails. Lower stakes now that §5.36 removes deleted content from the managed engine, but still the one silent-serving path left | | **engine visibility** | Nothing logs *which* engine served a query — the resolver only logs on failure — so "is the new one actually working?" can only be answered from the KB record. One INFO line in the facade, plus a `Managed`/`Classic` badge in the knowledge base section, both unbuilt. Wanted before a wide rollout, because this feature's whole risk profile is silent regressions | | **14.4** one-click document retry | Req 21.2. Ingestion is S3-event-triggered and there is no reprocess endpoint, so this needs new backend against a live pipeline. The card currently directs the user to re-upload, which works today. Close it by building the endpoint **or** by amending Req 21.2 to accept re-upload | diff --git a/.kiro/specs/managed-kb-migration/design.md b/.kiro/specs/managed-kb-migration/design.md index b6e72a35c..99e5cd839 100644 --- a/.kiro/specs/managed-kb-migration/design.md +++ b/.kiro/specs/managed-kb-migration/design.md @@ -7,6 +7,13 @@ Amazon S3 Vectors) with **Amazon Bedrock Managed Knowledge Base**, one knowledge base at a time, behind a single abstraction seam, with rollback available at every step. +> **READ `HANDOFF.md` FIRST for current state.** This document is the design as +> intended. Sixteen defects were found only by *running* it, and several of them +> were caused by figures and claims in this document being applied to situations +> they did not describe — the inline ⚠️ notes mark the ones known to have misled. +> `HANDOFF.md` §5 carries the measurements that superseded them, and §6 carries +> what is still open. Where the two disagree, HANDOFF.md is the newer evidence. + The shape of the change is a **strangler fig**. There are exactly two retrieval call sites today, both routed through `search_assistant_knowledgebase_with_formatting`. That function becomes a thin @@ -45,7 +52,7 @@ in one place because they are the reason the design has the shape it does. | Per-KB cold first ingest | ~68 s, remarkably constant (68.296/68.232/68.334 s) | A fixed cost of the *knowledge base*, not the document; pay it once, in background | | Warm ingest, small text | ~2.5 s | Comparable to today; bulk migration is feasible | | Warm ingest, 50 KiB PDF | 68–264 s | Long tail; ingestion timeouts ≥300 s, treated as background work | -| INDEXED → actually retrievable | 0.75–1.03 s | Two distinct timestamps; poll for retrievable, not indexed | +| INDEXED → actually retrievable | 0.75–1.03 s | Two distinct timestamps. ⚠️ This is the gap AFTER indexing finishes — NOT a budget for `ingest → retrievable`, which is 37–264 s for PDFs (evaluation §5.1) and reached 5 m 30 s in dev. Sizing a wait from this number caused §5.30 and §5.37. Poll Bedrock's document STATUS first, then confirm retrievability with a `document_id`-filtered query | | `Retrieve` p50 / p95 | 662–695 ms / 762–800 ms | +405 ms p50, +538 ms p95 vs today; acceptable but real TTFT cost | | `StartIngestionJob` | 0.1 RPS, account-wide, **not adjustable** | Direct ingestion only; never per-document sync jobs | | `IngestKnowledgeBaseDocuments` | **10 documents max**, server-enforced | Batch at 10, not the 25 the user guide claims | @@ -58,7 +65,7 @@ in one place because they are the reason the design has the shape it does. | Empty/idle KB | $0.00000203 measured for the month | No per-KB floor; count pressure is near zero | | KB deletion | 2–6 minutes, async | Poll `ListKnowledgeBases`; "accepted" ≠ "gone" | | Filter operators | fail **closed** (measured 0 results) | A mistyped filter yields nothing rather than leaking — but see the isolation note below | -| Managed reranking | separates scores 0.89/0.38/0.25/0.21/0.19 vs flat 1.00/0.84/0.78/0.77/0.77 | The reranker is what makes a 2,000-char cap defensible | +| Managed reranking | separates scores 0.89/0.38/0.25/0.21/0.19 vs flat 1.00/0.84/0.78/0.77/0.77 | ⚠️ The separation is real, but the cap is NOT defensible on the managed path as built: Bedrock's chunks are ~3× Docling's, so only ~1 chunk clears 2,000 characters and four of reranking's five results never reach the model. Measured, with a wrong answer to show for it — HANDOFF.md §5.40 | --- @@ -536,8 +543,32 @@ sequenceDiagram - **Routing is exclusive.** A document is indexed on exactly one backend outside a deliberate migration or dual-read pilot, so no double-indexing. + + > **Implemented on both sides as of 2026-09-01, and it was not before.** The + > ingestion consumer always stood down for a legacy document, but the legacy + > pipeline had no engine gate at all and its S3 notification is still live, so + > every document added to a promoted knowledge base was handled twice — and both + > pipelines wrote `DOC#` status, so "ready" was decided by whichever finished + > last. See HANDOFF.md §5.32 and §5.34. Deletion was the mirror gap: it removed + > the legacy vectors and never touched the managed corpus (§5.36). - **Two timestamps, not one.** `indexedAt` and `retrievableAt` are recorded - separately; the gap measured 0.75–1.03 s and is a real, distinct event. + separately, because they are genuinely distinct events. + + > ⚠️ **The figure that used to appear here — "the gap measured 0.75–1.03 s" — is + > correct for `INDEXED → retrievable` and WRONG as a budget for anything else. It + > caused two defects.** Both `verify` (§5.30) and the ingestion consumer (§5.37) + > used it to size a wait that actually had to cover + > `ingest → INDEXED → retrievable`, which the evaluation's §5.1 measured at + > 37–264 s for PDFs and which reached **5 m 30 s** on a 1.5 MB file in dev. The + > second occurrence happened because the first fix was applied to `verify` only + > and nobody grepped for the constant's other homes. + > + > Two further corrections earned by measurement: `indexedAt` must be Bedrock's own + > `updatedAt`, not local time after the ingest call returns — that call is + > fire-and-forget, and recording local time made the field a fabrication that a + > truthiness assertion could not catch. And retrievability must be confirmed with + > a `document_id`-filtered retrieval; querying the id as free text cannot find its + > own document once a knowledge base holds more than one (§5.38). - **Timeouts ≥300 s.** A 50 KiB PDF has been observed at 264 s. - **No chunk-key bookkeeping.** `customDocumentIdentifier = document_id` gives a 1:1 mapping, which retires the whole `{doc_id}#{chunk_index}` scheme including diff --git a/.kiro/specs/managed-kb-migration/requirements.md b/.kiro/specs/managed-kb-migration/requirements.md index 7d4b9b4ee..3bcaa3b7d 100644 --- a/.kiro/specs/managed-kb-migration/requirements.md +++ b/.kiro/specs/managed-kb-migration/requirements.md @@ -46,6 +46,34 @@ The following are explicitly **not** in scope, each for a stated reason: - **Raising the 2,000-character context cap.** The §13.6 experiment measured no correctness change from 2,000 to 20,000 characters on either backend. Holding it constant is required to keep the swap attributable (§9, §13.5 requirement 3). + + > ⚠️ **AMENDED BY MEASUREMENT, 2026-09-02. The §13.6 conclusion does not hold on + > a real corpus, and the cap is now known to cause wrong answers on the managed + > backend.** Bedrock's chunks are roughly 3× Docling's, so the cap admits a + > different NUMBER of chunks per backend even though the character limit is + > identical — measured on one query: legacy chunks of 388/130/1035/106 characters + > let **four** through, managed's 1111/1091/1151/1197 let **one** through. So + > `top_k = 5` is honoured at retrieval and silently becomes `top_k = 1` at the + > model, discarding four of reranking's five results. + > + > Observed consequence: asked about `CS434`, legacy answered correctly (Major + > Core, mandatory) while managed called it an elective — the opposite of the + > truth — because the one chunk that fit had lost its `SECTION 2: MAJOR CORE` + > header and the nearest header it retained was `TECHNICAL ELECTIVES`. Retrieval + > was *better* on managed: its top chunk was the only one containing the literal + > string, which legacy never found. + > + > §13.6 almost certainly measured a corpus where the answer sat inside the top + > chunk, so the cap never removed anything load-bearing. It says nothing about + > the case where the interpreting context lives in a NEIGHBOURING chunk. Same + > shape as Requirement 8.5, which was also validated under conditions that + > excluded the real failure. + > + > This exclusion stands for now, because the cap is a parity control and moving + > it forfeits attributability (§9, §13.5). It is no longer justified by "no + > correctness change", which is false. Resolving it requires either accepting a + > managed-only cap and declaring the asymmetry, or re-running §13.6 on a corpus + > where section context sits outside the top chunk. See HANDOFF.md §5.40. - **0..N agent-to-KB bindings (F4).** §10.6 requires that the engine swap and the binding-cardinality change not be coupled, because a joint failure is unattributable. This spec lands the `KnowledgeBase` entity record while @@ -173,6 +201,13 @@ the upgrade. 1. THE system SHALL request `top_k = 5` on both backends. 2. THE system SHALL apply a context cap of **2,000 characters** on both backends, unchanged from today's `max_context_length` default. + + > ⚠️ Identical characters is **not** identical behaviour. Bedrock's chunks are + > ~3× Docling's, so this same number admits ~4 legacy chunks and ~1 managed + > chunk, making `top_k = 5` above effectively `top_k = 1` on the managed path. + > This is parity on the constant, not on the effect. Measured, with a wrong + > answer to show for it — see the amendment in the out-of-scope list above and + > HANDOFF.md §5.40 before treating this requirement as satisfied. 3. THE system SHALL retain the Doc_Status_Filter on **both** backends during parity, even though Managed_Backend makes it redundant. 4. THE system SHALL build citations from the same `context_chunks` structure on diff --git a/.kiro/specs/managed-kb-migration/tasks.md b/.kiro/specs/managed-kb-migration/tasks.md index ab1587a18..2b1cea33b 100644 --- a/.kiro/specs/managed-kb-migration/tasks.md +++ b/.kiro/specs/managed-kb-migration/tasks.md @@ -2,6 +2,14 @@ ## Overview +> **READ `HANDOFF.md` FIRST.** Groups 1–14 are built and deployed to dev and prod, +> but a checked box here means "written and reviewed", not "verified by running". +> **Sixteen defects were found only by running it** — every one reviewed clean and +> deployed clean — and they are recorded in `HANDOFF.md` §5, not here. Section 16 +> below carries the work those findings opened. Two of them (§5.40, §5.41) mean the +> managed backend currently gives a *worse answer* than legacy on some questions, +> which is the most important open item in the feature. + Introduce Amazon Bedrock Managed Knowledge Base as a second retrieval backend behind a single abstraction seam, then migrate knowledge bases to it one at a time, opt-in, with rollback available throughout. @@ -433,6 +441,12 @@ All three flags — managed-default, migration, and reconciler arming — ship * - [x] 9.2 Write unit tests for routing exclusivity - Legacy document routes to the old pipeline only; managed document routes to direct ingestion only; neither is double-indexed + - ⚠️ **This was only half true when first marked done.** Every test covered the + consumer standing down for a legacy document; nothing asserted the LEGACY + pipeline stands down for a managed one, and it did not — so managed documents + were double-indexed and both pipelines wrote `DOC#` status. Closed properly by + PR #900; see `HANDOFF.md` §5.32 and §5.34. A suite can be thorough about the + side that works. - File: `backend/tests/lambdas/test_kb_ingestion_consumer.py` - _Requirements: 10.3, 10.4, 10.5_ @@ -747,3 +761,62 @@ All three flags — managed-default, migration, and reconciler arming — ship * dev container - Verify every Requirement 24 item has a corresponding passing test - _Requirements: 24.1, 24.2, 24.3, 24.4, 24.5, 24.6, 24.7, 24.8, 24.9, 24.10, 24.11, 24.12, 24.13, 24.14, 24.15_ + +--- + +- [ ] 16. Post-implementation findings (opened by running it — see `HANDOFF.md` §5) + + - [ ] 16.1 Resolve the 2,000-character context cap on the managed path + - **The most consequential open item.** Bedrock's chunks are ~3× Docling's, so + only ~1 chunk clears the cap and four of reranking's five results never reach + the model. Measured: legacy 388/130/1035/106 chars (4 fit) vs managed + 1111/1091/1151/1197 (1 fits). Produced a materially wrong answer — a Major + Core course described as an elective, because the surviving chunk had lost its + section header. + - **Not a constant bump.** The cap is a parity control (§9, §13.5), so changing + it for both backends forfeits attributability. Either accept a managed-only + cap and declare the asymmetry, or re-run the §13.6 experiment on a corpus + where the interpreting context sits OUTSIDE the top chunk — which is the case + §13.6 did not cover. + - Amend Requirement 3.2 with whichever is chosen (the exclusion note already + carries the contradicting measurement). + - _HANDOFF §5.40 · Requirements: 3.1, 3.2_ + + - [ ] 16.2 Understand diagram answer quality before promising anything + - Column-structured diagrams yield confident wrong answers: a curriculum + flowchart reported 11 credits where the chart says 19, invented a course from + an adjacent column, and missed four others. Correctness depends on which + column a box occupies and chunks carry no coordinates. + - Image extraction genuinely works (§5.35) — an image-only PDF that legacy + cannot ingest at all becomes retrievable. The capability is real; precise + tabular answers from it are not established. + - _HANDOFF §5.41_ + + - [ ] 16.3 Make the document-status filter fail closed on its one open path + - `_filter_vectors_by_document_status` opens with `if not doc_ids: return + vectors`. Every other unprovable path in that function returns `[]` and emits + `METRIC_STATUS_FILTER_FAIL_CLOSED`. Predates this feature; not firing today. + - _HANDOFF §5.33 · Requirements: 5.1, 5.2_ + + - [ ] 16.4 Give the UI one vocabulary and show which engine served a query + - Document status is now written only by the owning engine (PR #900), so the + legacy `chunking`/`embedding` words never appear for a promoted knowledge + base — but nothing replaced them, so the card shows `Uploading` for the whole + indexing wait. Agreed shape: `uploading → processing → ready` (+ `failed`), + with `chunking`/`embedding` retained for legacy assistants, which still emit + them. + - Nothing logs WHICH engine served a retrieval — the resolver only logs on + failure — so "is the new one working?" is answerable only from the KB record. + One INFO line in the facade, plus a `Managed`/`Classic` badge. + - Wanted before a wide rollout, because this feature's entire risk profile is + silent regressions. + - _HANDOFF §6_ + + - [ ] 16.5 Reconcile documents whose ingestion event was dead-lettered + - A document whose event dead-letters is stranded: `status` is written only by + the consumer, so nothing ever revisits it even when the content is sitting in + the knowledge base, fully retrievable. Two such documents occurred in dev and + both needed manual repair. + - Overlaps task 14.4 (one-click retry) and the report-only reconciler, which + already knows how to join Bedrock's view against ours. + - _HANDOFF §5.37 · Requirements: 21.2_ diff --git a/.kiro/steering/tailwind-accessibility.md b/.kiro/steering/tailwind-accessibility.md index aa42d7637..6c465329d 100644 --- a/.kiro/steering/tailwind-accessibility.md +++ b/.kiro/steering/tailwind-accessibility.md @@ -20,7 +20,7 @@ For text on backgrounds, ensure sufficient contrast:

Dark on light

Light on dark

-

White on primary (verify contrast)

+

White on brand (contrast guaranteed by the token)

May fail contrast

@@ -50,7 +50,7 @@ Every interactive element must have a visible focus indicator. ```html - @@ -66,10 +66,10 @@ Use `focus-visible:` for keyboard-only focus states (hides ring on mouse click): ```html -Link +Link - + ``` ### Focus Within @@ -77,7 +77,7 @@ Use `focus-visible:` for keyboard-only focus states (hides ring on mouse click): Style parent when child is focused: ```html -
+
``` @@ -91,7 +91,7 @@ Ensure 3:1 contrast for focus indicators: @@ -174,9 +174,9 @@ Announce errors to screen readers: type="email" aria-invalid="true" aria-describedby="email-error" - class="border-red-500 focus:ring-red-500" + class="border-state-danger-500 focus:ring-state-danger-500" /> - ``` diff --git a/.kiro/steering/tailwind-colors.md b/.kiro/steering/tailwind-colors.md new file mode 100644 index 000000000..39c0fbf04 --- /dev/null +++ b/.kiro/steering/tailwind-colors.md @@ -0,0 +1,83 @@ +--- +inclusion: manual +--- + +# Color Usage Reference + +**Never use Tailwind's built-in color palettes in application code.** No `bg-blue-600`, `text-red-500`, `border-amber-300`, `ring-emerald-400`. Grays and other neutrals (`gray`, `slate`, `zinc`, `neutral`, `stone`, plus `white` / `black`) are fine — they are not part of the themed surface. + +Every color belongs to one of three groups. Identify the group, then pick the utility. + +| Group | Meaning | Utilities | Defined in | +| --- | --- | --- | --- | +| Brand | Accent and interactive: buttons, links, selected states, focus rings, active tabs | `primary-*`, `secondary-*`, `tertiary-*` | `src/styles/generated/brand-theme.css` (generated) | +| Status | Fixed meaning: error, warning, success, informational | `state-danger-*`, `state-warning-*`, `state-success-*`, `state-info-*` | `src/styles/tokens/state.css` | +| Category | Fixed identity: which vendor, which file type | `vendor-*`, `filetype-*` | `src/styles/tokens/identity.css` | + +Only the brand group follows `src/branding/brand.config.ts`. Status and category colors are deliberately fixed — a red error banner stays red for an organization whose brand color is red, and a spreadsheet badge stays green. + +Full rebranding documentation lives in `frontend/ai.client/src/branding/README.md`. + +## Brand utilities + +```html + +
@@ -126,7 +134,7 @@ Accessible, theme-aware component patterns for Tailwind CSS v4.1. px-3 py-2 bg-white text-gray-900 border border-gray-300 rounded-sm shadow-xs - focus:ring-2 focus:ring-primary-500 focus:border-primary-500 + focus:ring-2 focus:ring-primary-accessible focus:border-primary-accessible dark:bg-gray-800 dark:text-white dark:border-gray-600 " > @@ -146,8 +154,8 @@ Accessible, theme-aware component patterns for Tailwind CSS v4.1. class=" size-4 rounded-xs border-gray-300 - text-primary-500 - focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 + text-primary-accessible + focus:ring-2 focus:ring-primary-accessible focus:ring-offset-2 dark:border-gray-600 dark:bg-gray-800 dark:focus:ring-offset-gray-900 " @@ -191,7 +199,7 @@ Accessible, theme-aware component patterns for Tailwind CSS v4.1. border border-gray-200 overflow-hidden hover:shadow-md hover:border-gray-300 - focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-500 + focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-accessible transition-shadow dark:bg-gray-800 dark:border-gray-700 dark:hover:border-gray-600 @@ -227,7 +235,7 @@ Accessible, theme-aware component patterns for Tailwind CSS v4.1. px-4 py-2 rounded-sm text-sm/6 font-medium text-gray-900 hover:bg-gray-100 - focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-500 + focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-accessible dark:text-white dark:hover:bg-gray-800 " aria-current="page" @@ -252,7 +260,7 @@ Always include a skip link for keyboard users: sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 - focus:bg-primary-500 focus:text-white + focus:bg-primary-accessible focus:text-white focus:rounded-sm focus:shadow-lg " > @@ -269,9 +277,9 @@ Always include a skip link for keyboard users: role="alert" class=" flex gap-3 p-4 - bg-blue-50 text-blue-800 - border border-blue-200 rounded-sm - dark:bg-blue-900/20 dark:text-blue-300 dark:border-blue-800 + bg-state-info-50 text-state-info-800 + border border-state-info-200 rounded-sm + dark:bg-state-info-900/20 dark:text-state-info-300 dark:border-state-info-800 " > @@ -289,9 +297,9 @@ Always include a skip link for keyboard users: role="alert" class=" flex gap-3 p-4 - bg-red-50 text-red-800 - border border-red-200 rounded-sm - dark:bg-red-900/20 dark:text-red-300 dark:border-red-800 + bg-state-danger-50 text-state-danger-800 + border border-state-danger-200 rounded-sm + dark:bg-state-danger-900/20 dark:text-state-danger-300 dark:border-state-danger-800 " > @@ -309,7 +317,7 @@ Always include a skip link for keyboard users: ```html
+
Content adapts to mode
``` +Brand colors are the exception worth knowing: a solid brand fill carrying white +text needs **no** `dark:` override, because the contrast that matters is fill +against its white text, which does not change between modes. + +```html + +``` + ## Dark Mode Patterns ### Basic Pattern @@ -136,7 +138,7 @@ Don't forget borders: ```html + +
...
@@ -62,20 +66,24 @@ For detailed patterns, see: - **Components** — #[[file:tailwind-components.md]] - Accessible component patterns (buttons, forms, cards, nav) +- **Colors** — #[[file:tailwind-colors.md]] + - Which color token to use, and why never the built-in palettes + ## Core Principles -1. **Use Tailwind's scale** — Avoid arbitrary values like `ml-[16px]`; use `ml-4` -2. **Never use @apply** — Use CSS variables or framework components -3. **Gap over margins** — Use `gap-*` in flex/grid, not `space-*` or child margins -4. **Test both modes** — Always verify light AND dark mode appearance -5. **Accessibility first** — Every interactive element needs visible focus states and proper contrast +1. **Never use built-in color palettes** — No `bg-blue-600`, `text-red-500`, `border-amber-300`. Use brand (`primary-*`), status (`state-*`), or category (`vendor-*`, `filetype-*`) tokens. Neutrals (`gray`, `white`, `black`) are fine. See the Colors reference +2. **Use Tailwind's scale** — Avoid arbitrary values like `ml-[16px]`; use `ml-4` +3. **Never use @apply** — Use CSS variables or framework components +4. **Gap over margins** — Use `gap-*` in flex/grid, not `space-*` or child margins +5. **Test both modes** — Always verify light AND dark mode appearance +6. **Accessibility first** — Every interactive element needs visible focus states and proper contrast ## Common Patterns ### Focus States ```html - ``` diff --git a/.kiro/steering/tech.md b/.kiro/steering/tech.md index 7706aaade..fe000eed8 100644 --- a/.kiro/steering/tech.md +++ b/.kiro/steering/tech.md @@ -50,7 +50,7 @@ - **Version Control**: Git - **Containerization**: Docker - **Testing**: Vitest (frontend), pytest (backend) -- **Linting**: ESLint (frontend), ruff (backend) +- **Linting**: ruff (backend only; frontend linting via vitest guard specs, not ESLint) - **Formatting**: Prettier (frontend), black (backend) - **Type Checking**: TypeScript compiler, mypy diff --git a/CHANGELOG.md b/CHANGELOG.md index cab8e0de4..6f7fc356a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,87 @@ All notable changes to this project are documented in this file. Format follows For narrative release notes written for operators and product owners, see [RELEASE_NOTES.md](RELEASE_NOTES.md). +## [1.18.0] - 2026-09-06 + +Artifacts stop being a per-conversation curiosity and become a place users go. There is a library at `/artifacts` with previews, rename, delete and an in-app viewer; artifacts can be **shared** with named people or the whole tenant; and sharing a conversation now shares the artifacts in it, which previously left the recipient staring at nothing where the owner saw cards. Alongside it, two more surfaces the platform had no way to do at all: **feature announcements** — an admin-authored What's New feed with a banner, a modal and per-announcement reach stats — and **mid-turn steering**, which lets a follow-up typed while the model is still working land inside the running turn at the next tool boundary instead of interrupting it. The SPA gains a **single-file rebranding surface** so a fork can change app name, greeting, logo and the entire color system without touching a component. On the cost side the GPT-5.6 family (Sol / Terra / Luna) is curated with verified rates, and every published GPT-5.6 rate in the catalog is corrected. **Requires a CDK deploy** (new `{prefix}-announcements` table, new IAM grants); no GSI operations on any existing table. + +### 🚀 Added + +- **Artifact sharing** — owners can share an artifact with specific email addresses or with any authenticated tenant user, and revoke at any time. Share records live on the existing `{prefix}-user-artifacts` table under a `SHARE#` prefix (two rows in one `TransactWriteItems`); no new table and no GSI. Recipients open a minimal-chrome `/shared-artifact/{shareId}` view that renders the pinned version and never touches an owner endpoint (#919, #920, #922, #927, #928) +- **"Shared with you" inbox** — `GET /shared-artifacts` lists artifacts shared with the caller, backed by fan-out pointer rows in the recipient's own partition (`PK=SHARED_WITH#{email}`). **Gated by `ARTIFACT_SHARE_INBOX_ENABLED`, default off.** The fan-out rows are written regardless of the flag, so turning it on shows a complete inbox with no backfill (#968) +- **All / Yours / Shared with you tabs** on the artifact library. The SPA discovers the inbox by calling it — a 404 means "no inbox in this environment" and it renders the tab-less library it always did, so no separate frontend flag exists (#970) +- **Artifact library page** at `/artifacts` — every artifact a user owns, list or grid, backed by `GET /artifacts/library`. No new index was needed: `user-artifacts` is already partitioned by user (#940, #950) +- **Grid-card previews** — library grid cards render a live, scaled-down iframe of the artifact through the deployed render path at a fixed 1024px virtual viewport. Previews mount lazily on intersection and are never re-minted, because each one costs a mint plus a render-Lambda invocation (#967) +- **Rename and delete** for artifacts, from the library, the docked panel and the inline card. `PATCH /artifacts/{id}` and `DELETE /artifacts/{id}` (#952, #957) +- **In-app artifact viewer** — artifacts open in a docked panel instead of depending on a pop-up window (#958) +- **Shared-conversation artifacts** — sharing a conversation now shares the artifacts in it. `create_share` pins the session's artifacts at their current versions into the S3 snapshot body, which makes the snapshot both the point-in-time record and the allowlist; no artifact share records are created (#971, #973) +- **Session-delete cascade** — deleting a conversation revokes the artifact shares created from it, on both the single and bulk delete routes (#931) +- **Feature announcements** — admin-authored release notices with a full lifecycle (draft → publish → revise → archive), targeting by role, and per-user acknowledgements. New `{prefix}-announcements` table (no GSIs), `GET /announcements` + ack endpoint for users, and `/admin/announcements` CRUD (#948, #966, #969, #972) +- **What's New surfaces** — a panel, a floating banner beside the chat composer, and a modal for high-priority announcements gated by the spec's §D8 rules (#976, #977, #979, #981) +- **Announcement reach stats** — `GET /admin/announcements/{id}/stats` and a reach column on the admin list, driven by ack funnel counters (#978) +- **Mid-turn steering** — a follow-up typed while a turn is still streaming is injected into that running turn at the next tool boundary, appended to the same user-role message that carries the tool results, so the agent reads it before choosing its next action. New `steering_applied` SSE event and `POST /sessions/{id}/steer`; transport is the session's existing single-flight lease row. Gated by `MID_TURN_STEERING_ENABLED` (default on) (#916, #921) +- **Single-file rebranding** — `frontend/ai.client/src/branding/brand.config.ts` is now the only file to edit to change app name, page title, greeting text, logo paths and the brand color system. Prestart/prebuild generators derive the full theme (brand tokens, an OKLCH-banded neutral surface ramp, and favicons) from it, with golden-file and parity specs pinning the output (#933) +- **GPT-5.6 Sol, Terra and Luna** curated in the model catalog on the `bedrock-runtime` OpenAI-compatible endpoint (#980) +- **`bedrock-runtime` OpenAI Responses transport** (`provider="bedrock-responses"`) with the `bedrock:CallWithBearerToken` grant it needs (#949, #959) +- **Multi-modal fine-tuning** — a task-type registry replaces the text-only assumption, adding image and image+text tasks in the API and the SPA, with a dollar-denominated quota (#944) +- Response-feedback spec (`docs/specs/`), a prompt-caching convergence watch, and the mid-turn steering spec (#942, #961, #921) + +### ✨ Improved + +- **Prompt-cache TTL is derived from the serving model** rather than assumed, so `cacheStatus` no longer misreads a hit as expired on models with a different TTL (#951) +- **Mantle models expose the caching controls** they were previously denied in the admin catalog (#963) +- **`supportsCaching` is forced on for providers that cache unconditionally**, so a model whose provider always caches is no longer reported as uncached (#960) +- **OpenAI-family token usage normalizes to disjoint buckets**, ending the double-count where cached tokens were included in the input total (#945) +- The library view toggle no longer stretches on narrow screens, and the grid card footer no longer overflows its card (#955) + +### ⚠️ Changed + +- **Explicit GPT-5.6 prompt-cache breakpoints ship OFF.** They were built, measured, and found **57% more expensive** than the provider's automatic caching, so the code stays and the default is off (#954, #956) +- An omitted `supported_param` is now treated as **unsupported**, not as pass-through — an empty `supportedParams` previously bypassed the parameter guard entirely (#915) + +### 🐛 Fixed + +- **Artifact share cascade used `BatchWriteItem`, which the app-api task role cannot call** — it failed closed in dev, leaving share links live after their conversation was deleted. `TransactWriteItems` authorizes against the underlying item actions; `BatchWriteItem` is its own IAM action. Replaced with per-row `DeleteItem` (#932) +- **An empty "Shared with you" tab said "No artifacts match your search"** with an empty search box, because the filtered-empty state gated on the library total rather than the tab's (#975) +- **"Pop-up blocked" was reported on every artifact open**, including successful ones (#953) +- A mid-turn steer rendered once per sync tick instead of once (#930), a follow-up typed while a turn was paused was dropped instead of queued (#934), and a steer bubble used a non-standard color (#935) +- A user bubble's overflow was measured once and latched; it is now re-measured (#937) +- A duplicate error toast fired alongside the shared-artifact page's own inline 404, and the artifact card's actions overlapped its title when the panel was docked — fixed with a container query, since the card is sized by the chat column and not the viewport (#927) +- The new-announcement form's submit button could never enable (#974) +- **GPT-5.6 rates were wrong three ways**: derived from a 1000x-wrong multi-model blend, then published in the model cards all along. Every rate in the catalog is corrected (#980, and the derivation method in the same PR) +- The cache-write premium and the Global/Regional rate tier were both wrong in cost derivation (#914) +- Knowledge-base retrievability is confirmed with a filtered query and `TEXT_INDEXED` is classified correctly (#908) +- Generative VLMs are excluded from the dual-encoder fine-tuning task, and instance types are validated (#944) + +### 🔒 Security + +- **All 47 open Dependabot alerts cleared** across backend, frontend, infrastructure, docs-site and the backup/restore scripts (#924) +- **The custom HuggingFace model id is validated against an anchored repo-id pattern** before it is interpolated into a Hub request path or forwarded to the training container as `model_name_or_path`. The call site's comment had claimed this validation since before the release; only non-empty and length were actually checked. The host was always hard-coded, so this was never an arbitrary-host SSRF — but a value carrying dot-segments, extra slashes, a query or a fragment could change the meaning of both sinks +- **CodeQL alerts remediated: 11 high, 20 medium, 9 note** — log injection, unused imports and related findings across 18 backend modules and one SPA page. The nightly workflow is extended in the same pass (#925) + +### 🏗️ Infrastructure + +- **New `{prefix}-announcements` table** — one table, two item shapes (announcement rows under a fixed `ANNOUNCEMENTS` partition, per-user ack rows under `USER#`). **No GSIs.** Table name published to SSM at `/{prefix}/admin/announcements-table-name` (#966) +- **`CDK_ARTIFACT_SHARE_INBOX_ENABLED`** — new deploy variable, default off, threaded to the app-api container as `ARTIFACT_SHARE_INBOX_ENABLED`. Gates the inbox read only (#968) +- **`bedrock:CallWithBearerToken`** granted to the inference-api role. The `bedrock-runtime` OpenAI-compatible endpoint authenticates under the `bedrock` service namespace, not `bedrock-mantle` — granting only the Mantle action returns a 401 (#959) +- `infrastructure/gsi-inventory.json` gains `announcements` with an empty index list. **No index operations on any existing table.** + +### 📦 Dependencies + +- Backend: `cryptography` 48.0.1 → 50.0.1, `aiohttp` 3.14.1 → 3.14.3, `pandas` 2.3.3 added (fine-tuning dataset contract) +- Frontend: Angular 21.2.17 → 21.2.19, `mermaid` 11.15.0 → 11.16.1, `postcss` 8.5.12 → 8.5.28, `sharp` 0.33.0 and `tsx` 4.23.12 added (branding generators), `dompurify` ≥3.4.13, `undici` ≥7.29.0, `hono` ≥4.12.34 +- Infrastructure: `aws-cdk-lib` 2.262.0 → 2.265.0, `brace-expansion` ≥5.0.9 + +### 🔧 CI/CD + +- The SPA `prestart` and `prebuild` scripts now run the four branding generators (brand theme, surface theme, surface colors, favicons) before the app builds (#933) +- Nightly workflow extended alongside the CodeQL remediation (#925) + +### 📚 Docs + +- GPT-5.6 live verification, model-family findings, the prod gpt-5.4 cache-rate closure, and the corrected `global.*` SCP finding (dev only — prod is unaffected) (#962, #964, #965) +- Kaizen research and review-prep for 2026-09-04, and a prompt-caching convergence watch (#929, #961) + ## [1.17.0] - 2026-09-02 Reliability, security and observability. Every CloudWatch alarm in the stack now notifies somebody — before this the stack had 13 alarms and **none of them were routed**, two of which watched metric names that exist in no namespace and had read as healthy since the day they were created. A production outage post-mortem (session `5f34d2b0`) drives four chat-path changes: Bedrock's transient faults are retried, a retry and a long silence are both visible to the user, and attachments a failed turn never delivered are re-sent. Four security findings are closed, including a High-severity OIDC login CSRF in the BFF auth flow and a privilege-escalating stored XSS in skill resources. The Bedrock Managed Knowledge Base migration — still off by default — gets eleven defects fixed from its first real runs in dev. **Requires a CDK deploy**, and one manual step after it: subscribe your team to the new alarm topic (see [step-05-verify](.github/docs/deploy/step-05-verify.md#6-subscribe-to-platform-alarms-required--not-automated)). diff --git a/CLAUDE.MD b/CLAUDE.MD index 920e0180e..a5d6d8033 100644 --- a/CLAUDE.MD +++ b/CLAUDE.MD @@ -39,7 +39,7 @@ npx cdk deploy {prefix}-PlatformStack - **Admin endpoints** go under `/admin//`, user-facing under `//` - **Errors stream as assistant messages** via SSE (not HTTP error codes) - **Signal-based state** throughout frontend (`signal()`, `computed()`) -- **Prompt-cache stability is a contract.** Bedrock prompt caching is exact-prefix-match: any list that reaches the system prompt or `toolConfig` (skills, tools, models, MCP tool listings) must be deterministically ordered at its source, and restored conversation history must be byte-stable between compaction-state changes (see the truncation anchor in `TurnBasedSessionManager`). An order flip or history mutation between turns silently re-writes a 30k–150k-token prefix at the $2.50/MTok cache-write premium. `PROMPT_CACHE_OBSERVABILITY_ENABLED=false` disables the observability layer (fingerprint hook, cacheStatus derivation, EMF metrics) — the caching itself stays on +- **Prompt-cache stability is a contract.** Bedrock prompt caching is exact-prefix-match: any list that reaches the system prompt or `toolConfig` (skills, tools, models, MCP tool listings) must be deterministically ordered at its source, and restored conversation history must be byte-stable between compaction-state changes (see the truncation anchor in `TurnBasedSessionManager`). An order flip or history mutation between turns silently re-writes a 30k–150k-token prefix at the cache-write premium, which is **1.25× the model's own base input rate** (2× at 1h TTL; a cache read is 0.1×) — there is no flat per-MTok figure, so price it against the model actually in play: $1.375/MTok on our default Haiku 4.5, $4.125 on Sonnet 4.6. Our `us.*` ids are **Regional (CRIS)** inference profiles and price ~10% above `global.*` for the same model; rates live in `curated-models.ts` and come from the AWS Price List API, not the pricing page. `PROMPT_CACHE_OBSERVABILITY_ENABLED=false` disables the observability layer (fingerprint hook, cacheStatus derivation, EMF metrics) — the caching itself stays on - **Token cost effectiveness is a design tenet — engineer against waste, not against context.** Before merging a change that touches the model call path, answer: what does this add to the prompt, on every turn, for the life of every session? (1) Anything in the cacheable prefix (system prompt, `toolConfig`, restored history) must be deterministic and append-only — see the prompt-cache contract above. (2) Per-turn payloads (tool results, MCP responses, retrieved documents) should be bounded or offloaded, never unbounded pass-through. (3) Don't guess — verify: `cacheStatus` + fingerprint hashes on the session's `C#` rows, `GET /admin/costs/sessions/{id}/calls`, and the `AgentCoreStack/PromptCache` EMF metrics exist to prove a change's cost impact. The balance: "cost effective" means eliminating waste (avoidable cache re-writes, duplicated context, oversized payloads) — it never means stripping context the model needs for a quality answer. When cost and answer quality genuinely conflict, quality wins; look for the cheaper path to the *same* quality, not a cheaper answer - **One session can be served by more than one agent — never cache session state on an agent instance.** The agent cache keys on *configuration* (system prompt, tools, model, skills), so an `@`-mention turn builds a second `Agent`, and each `Agent` builds its own `TurnBasedSessionManager`. Both write the same DynamoDB session row and neither knows the other exists, while `initialize()` never re-runs on a cache hit — so anything a manager loads once and holds goes stale silently. This has bitten twice: conversation history (#741, fixed by aliasing the message list in `_adopt_session_conversation`) and compaction state (#751, fixed by re-reading it every turn in `_adopt_persisted_compaction_state`). Per-session state must be aliased across instances or re-read per turn, and must never move backwards — a clobbered checkpoint or truncation anchor is a prompt-cache **cost** bug before it is a correctness one - **All dependencies use exact version pins** — no `^`, `~`, or `>=` @@ -58,6 +58,7 @@ npx cdk deploy {prefix}-PlatformStack | `session_title` | Server-generated conversation title on a session's FIRST turn — payload `{type, sessionId, title}`. Title generation (Nova Micro) runs as an asyncio task concurrent with the agent stream; the finished title is interleaved between agent events (non-blocking done-check in `stream_with_quota_warning`), so the sidebar/top-nav rename while the response is still pending. Emitted at most once per stream, possibly after `done` (the SPA parser allowlists it past Completed-state gating); never carries the "New Conversation" placeholder. Best-effort: a stream that finishes before generation emits nothing — the SPA's post-close metadata refresh (`refreshTitleFromServer`) is the fallback, reading the title the task also persisted via `update_session_title` | | `quota_session_notice` | **This conversation** has reached the tier's session-notice share of the monthly limit — payload `{type, sessionId, sessionCost, quotaLimit, sessionPercentageOfLimit, thresholdPercentage, message}`. Emitted at the head of the stream right after `quota_warning`, and re-emitted every turn while over the share (dismissal is client-side, same contract as `quota_warning`). `sessionCost` is the session's **lifetime** cost — the `totalCost` aggregate on its metadata row — deliberately not period-scoped: a conversation that opened last month and is spending this month's budget is exactly the one worth surfacing. Share is tier-configurable (`sessionNoticePercentage`, default 25%, 0 disables); the whole runway rides the `QUOTA_RUNWAY_ENABLED` kill switch (default on), which also gates the 50%/75% `quota_warning` rungs. The SPA scopes it to the conversation it names — never shown above another thread's composer | | `model_retry` | Backend is retrying a failed model call instead of surfacing it — payload `{type, attempt, delaySeconds}`. Emitted from Strands' `EventLoopThrottleEvent`; `attempt` is 1-based and counted per turn in `stream_processor` (the raw event carries only the delay). **Timing caveat:** Strands sleeps the backoff *inside* its hook and yields the event afterwards, so it lands as the next attempt BEGINS, not when the wait starts — `delaySeconds` describes the gap just endured, it is not a countdown. It also cannot cover the failing model call itself, which is indistinguishable from a slow healthy one. The SPA swaps the loading indicator's cycling phrases for a fixed amber notice, cleared on `message_start`/`done` | +| `steering_applied` | A follow-up the user typed **while the turn was still streaming** was injected into that running turn at a tool boundary — payload `{type, sessionId, entryId, text}`. PR #916 queues a mid-stream Enter in the composer and flushes it on the turn's falling edge; mid-turn steering lands it at the next tool boundary instead, appended as a `{"text": ...}` block on the same user-role message that carries the tool results, so the agent reads it before choosing its next action. Emitted after that batch's `tool_result` events (so the thread renders in the order the model will see) and never after `done`. The transport in is the session's single-flight **lease row** — `steerQueue` + `steerFor`, owner-scoped exactly like `cancelRequestedFor`, armed by app-api's `POST /sessions/{id}/steer` and deleted with the lease at turn end. Consumption is **commit-on-append**: the hook peeks at `AfterToolsEvent` and clears the inbox entry only on the `MessageAddedEvent` for that same message, because `AfterToolsEvent` fires from a `finally` and so also fires on the interrupt path, where the mutated message is discarded — a hook that consumed on read would destroy the user's words whenever a steer landed on the same tool batch as an OAuth consent. Absence of this event is the fallback, not an error: a turn that calls no tools has no boundary to inject at, and a steer can lose the race with the turn's end — in both cases the entry stays queued and #916's end-of-turn flush sends it as a normal turn. Append-only against the cached prefix (the injection lands inside the segment the `strategy="auto"` message-level cachePoint covers), so it never rewrites the prefix. Gated by `MID_TURN_STEERING_ENABLED` (default on with a kill switch); while off the steer endpoint 404s and the hook returns immediately. See `docs/specs/mid-turn-steering.md` | | `stream_error` | Conversational error | | `oauth_required` | External MCP tool needs user consent — payload `{providerId, authorizationUrl, interruptId?}`, one event per provider emitted after `message_stop`. Two flavors. **Interrupt-driven** (`interruptId` present): `OAuthConsentHook` paused a tool call mid-turn; the SPA resumes that exact turn by POSTing the id back. **Pre-flight** (`interruptId` *absent*): the tool never registered because the MCP server refused the pre-flight `tools/list` — since the consent hook is `BeforeToolCall`, it can't fire for an unregistered tool, so without this event the tool vanishes with no explanation. Nothing is paused, so the SPA shows the Connect affordance and must NOT resume; a synthetic id would be worse than none, because the resume guard in `inference_api/chat/routes.py` 400s on unknown ids and the user would hit an error right after consenting. Pre-flight events are re-emitted each turn that rebuilds the agent (the pre-flight keeps failing until consent lands) and are deliberately not persisted as `pending_interrupt` breadcrumbs — those are keyed by interrupt id for the resume path. The SPA dedupes by `providerId` and suppresses a dismissed pre-flight prompt for the tab session. Before emitting, the runtime asks the AgentCore vault directly: a vaulted token is warmed into `oauth_token_cache` and the pre-flight retried, so a user who already consented gets the tool back instead of losing it for the life of the process | | `compaction` | Backend rolled older turns into a summary on this turn — payload `{previousCheckpoint, newCheckpoint, summarizedTurns, inputTokens}`, emitted after the final `metadata` event so the badge updates first, before `done` | diff --git a/README.md b/README.md index 3abc44da9..79c393cfe 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ **An open-source, production-ready Generative AI platform for institutions** *Built by Boise State University, designed for everyone.* -[![Release](https://img.shields.io/badge/Release-v1.17.0-6366f1?style=flat&logo=github&logoColor=white)](RELEASE_NOTES.md) +[![Release](https://img.shields.io/badge/Release-v1.18.0-6366f1?style=flat&logo=github&logoColor=white)](RELEASE_NOTES.md) [![Nightly](https://github.com/Boise-State-Development/agentcore-public-stack/actions/workflows/nightly.yml/badge.svg)](https://github.com/Boise-State-Development/agentcore-public-stack/actions/workflows/nightly.yml) ![Python](https://img.shields.io/badge/Python-3.13+-3776AB?style=flat&logo=python&logoColor=white) @@ -296,7 +296,7 @@ agentcore-public-stack/ See [RELEASE_NOTES.md](RELEASE_NOTES.md) for the full changelog, including new features, bug fixes, platform upgrades, and deployment notes for each release. -**Current release:** v1.17.0 +**Current release:** v1.18.0 --- diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 4ba4d936f..438984273 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,250 @@ +# Release Notes — v1.18.0 + +**Release Date:** September 6, 2026 +**Previous Release:** v1.17.0 (September 2, 2026) + +--- + +> 🏗️ **CDK deploy required.** One new DynamoDB table (`{prefix}-announcements`, **no GSIs**), new IAM grants for it, and `bedrock:CallWithBearerToken` on the inference-api role. `infrastructure/gsi-inventory.json` gains one entry with an empty index list — **no index operations on any existing table**, so this release is not subject to the one-GSI-per-`UpdateTable` split. +> +> ⚙️ **One new deploy variable: `CDK_ARTIFACT_SHARE_INBOX_ENABLED`.** It gates the artifact library's "Shared with you" tab and nothing else. Default off. Set it to `"true"` on an environment *before* the deploy to have the tab appear there; the pointer rows behind it are written regardless, so flipping it later is complete and instant with no backfill. +> +> ⚡ **Two things change on deploy with no flag:** an omitted `supported_param` is now treated as unsupported rather than passed through, and the prompt-cache TTL is derived from the serving model instead of assumed. Both are corrections, but both change behavior on the first turn after the deploy. + +--- + +## Highlights + +A release about the things users *keep*, and about telling them what changed. + +**Artifacts became a place, not a side effect.** Before this release an artifact existed inside the conversation that made it and nowhere else. Now there is a library at `/artifacts` with live previews, rename and delete; artifacts can be **shared** with named recipients or the whole tenant and revoked at any time; and sharing a conversation shares the artifacts in it — which until now silently showed the recipient nothing where the owner saw cards. Eleven PRs, all verified live on dev, and **zero new tables and zero new indexes**: the `user-artifacts` table was already partitioned by user, and share records ride the same partition under a `SHARE#` prefix. + +**The platform can now tell users what shipped.** Feature announcements are admin-authored, role-targeted notices with a real lifecycle (draft → publish → revise → archive), per-user acknowledgements, a What's New panel, a floating banner beside the chat composer, a modal for the high-priority ones, and reach stats on the admin list so an author can see whether anyone actually read it. + +**A follow-up typed mid-turn no longer has to interrupt the turn.** Type while the model is working and the text is injected into the *running* turn at the next tool boundary, appended to the same user-role message that carries the tool results — so the agent reads it before choosing its next action, rather than after the turn it should have influenced has already finished. It is append-only against the cached prefix, so it costs nothing in prompt-cache terms. + +**The SPA is now rebrandable from one file.** `brand.config.ts` holds app name, page title, greeting text, logo paths and the brand colors; generators derive the entire theme — brand tokens, an OKLCH-banded neutral surface ramp, and favicons — at prestart and prebuild. This matters because this repo is forked: previously a fork had to hunt colors through components. + +**Cost correctness took another pass.** The GPT-5.6 family (Sol, Terra, Luna) is curated and every one of its rates is corrected — they had been derived from a 1000x-wrong multi-model blend when the model cards published them all along. Explicit prompt-cache breakpoints for GPT-5.6 were built, measured at **57% more expensive** than the provider's automatic caching, and shipped **off**. + +**Every open Dependabot alert (47) and 40 CodeQL findings are closed.** + +--- + +## Artifacts: a library, and sharing + +The artifact feature shipped originally as a rendering surface attached to a conversation. This release makes it a user-level asset with an owner, a lifetime independent of the chat that produced it, and an access-control story. + +### The library + +`GET /artifacts/library` and a page at `/artifacts`, reachable from **Settings → Profile** next to My Files. + +**No new index was needed, and that was the finding that shaped the design.** `user-artifacts` is keyed `PK=USER#{uid}` / `SK=ARTIFACT#{aid}#HEAD|#V#{n}`, so a user's entire library is already one partition — ownership rides the partition key. Production was measured before deciding: 1,494 artifacts across 340 users, 4,129 rows, 2.3MB; the heaviest user holds 64 artifacts in ~110KB, about 14 RCU. The content mix (`text/markdown` 2,769 vs `text/html` 1,352) is why the library defaults to **list** while the Agents surface defaults to grid — most artifacts are documents, not visual pages. + +`GSI2PK`/`GSI2SK` are stamped on HEAD rows by a writer with **no index consuming them yet** (#940). A sparse GSI only ever contains rows that already carry its key attributes, so rows written before the attributes exist stay invisible to it forever — silently, as a library listing only post-deploy artifacts with no error anywhere. Stamping early shrinks the eventual backfill to the pre-#940 rows, and costs nothing until the index exists. + +- **Grid previews** (#967) render the artifact itself — the deployed render path in an iframe at `scale(cardWidth/1024)`, at a fixed 1024px virtual viewport so the miniature is a true reduction rather than a mobile reflow. Not a server-side screenshot, which would mean executing user HTML server-side in our own account. **Cost is the design constraint:** every mounted preview is a mint plus a render-Lambda invocation plus an S3 GetObject on a `CACHING_DISABLED` path, so previews mount lazily on intersection and a mounted one is never unmounted or re-minted. List stays the default deliberately — flipping it would put every user into the expensive view. +- **Rename and delete** (#952, #957) via `PATCH` / `DELETE /artifacts/{id}` — hard-delete in DynamoDB, tag in S3, no IAM change. Available from the library, the docked panel and the inline message card. +- **In-app viewer** (#958) — artifacts open in a docked panel rather than depending on a pop-up window. Render tokens are ~120-second bearer credentials, so a grid cannot pre-mint; the previous flow minted per click and any await before `window.open` cost the active gesture and got the pop-up blocked. + +### Sharing + +`POST /artifacts/{id}/shares` and friends, with two share types: `specific` (an email allowlist) and `public` (any authenticated tenant user). Recipients land on `/shared-artifact/{shareId}` — a minimal-chrome shell with no sidenav, since a recipient has no other business in the app. + +**Zero infrastructure.** A share is two rows on the existing artifacts table written in one `TransactWriteItems`: an owner row and a `SHARE#{id}`/`META` lookup row. No GSI, deliberately — the table's index budget stays free for the `UserArtifactsIndex` the writer is already stamping keys for. `TransactWriteItems` needs no IAM action of its own; it authorizes against the underlying Put/Delete. + +> ⚠️ **The mint sets the token's `sub` to the *owner's* user id, not the viewer's — and that is correct.** `sub` is the DynamoDB partition key the render Lambda builds (`PK = USER#{sub}`): an *address*, not an identity assertion. Setting it to the viewer points at the viewer's own partition and 404s. Viewer identity travels in the `vwr` claim and the grant in `shr`; the ACL check in app-api is what stands between sharing and reading any artifact by id. This is written down because it looks like a bug and "fixing" it would break the feature. + +The render Lambda was deliberately not modified — `_verify_token` has no extras rejection, so `vwr`/`shr` are forward-compatible with what is already deployed. A test imports `_verify_token` directly to keep that a verified fact rather than a reading, and it was confirmed against the live Lambda in dev. + +**Session-delete cascade** (#931, #932): deleting a conversation revokes the artifact shares created from it, on both the single and bulk delete routes. The lookup row is deleted **before** the owner row — the lookup row is the capability, so a half-finished cascade leaves an inert orphan rather than a live share the owner can no longer see to revoke. The test asserts the order, because both orders look identical when nothing fails. + +### The recipient inbox — the one flagged surface + +`GET /shared-artifacts` lists what has been shared *with* you, and the library grows **All / Yours / Shared with you** tabs (#968, #970). + +**A GSI cannot index a list attribute.** `allowed_emails` is a list, so no index can turn one share row into N recipient entries; any recipient lookup needs a row per (share, recipient) regardless, and the only real question is which partition it lives in. So: fan-out pointer rows at `PK=SHARED_WITH#{email_lower}` / `SK=SHARE#{createdAt}#{shareId}`, in the recipient's own partition. Still no GSI. + +Three properties hold it together. The row is a **pointer** — it carries no title, because copying one per recipient multiplies staleness by allowlist size. The read **never trusts it** — it resolves through the share lookup row and re-runs the access check, so a stranded pointer lists nothing and grants nothing. And fan-out is **discovery, never authorization**, which is what allows it to be written best-effort per item *outside* the two-row transaction — avoiding an allowlist cap of roughly 40 addresses that `TransactWriteItems`' 100-item limit would otherwise invent. + +> ⚠️ **`ARTIFACT_SHARE_INBOX_ENABLED` gates the READ only. The fan-out rows are written unconditionally.** That asymmetry is the point: if the writes were gated too, enabling the flag would reveal an inbox missing every share created while it was off — a wrong answer rather than an empty one, and one nobody can see is wrong. Do not "optimise" the write path by wrapping it in the flag. + +The SPA needs no flag of its own. It requests the inbox in parallel with the owned list; a 404 means the surface does not exist in this environment and it renders the tab-less library it always did. An inbox that 503s is allowed to fail on its own without blanking the artifacts the user owns. + +### Sharing a conversation shares its artifacts + +Closing §8 of the spec (#971, #973). A recipient of a shared conversation used to see **nothing** where the owner sees artifact cards, with no error and no explanation. + +**The conversation share *is* the grant** — no artifact share records are created. `create_share` pins the session's artifacts at their current versions into the S3 snapshot body, which simultaneously preserves point-in-time semantics and makes the snapshot the allowlist; `resolve_shared_artifact` checks the conversation ACL *and* snapshot membership before minting. Parallel artifact shares were rejected because each would need cascading on update, revoke and delete, and one missed cascade leaves an artifact readable after its conversation was locked down. + +The snapshot's `artifacts` key is **optional on read** — conversation sharing is already in production, so pre-existing bodies read as `[]`. No migration. + +The recipient UI is its own card and dialog rather than a mode of the owner's: the owner components carry download, share, rename, delete, version picker and code view, all keyed on endpoints a conversation-share recipient has no handle for. The shared layer is `ArtifactViewerComponent`, which absorbed a third mint path with **no change** — the sign the split was drawn in the right place. + +### Test coverage + +Over 6,700 lines of new tests across the share service, the cascade, the inbox fan-out, the library page and the recipient surfaces — including a test that pins the DynamoDB **API surface** the cascade may use, mutation-checked by reintroducing the bug and confirming it fails. + +--- + +## Feature announcements + +Admins can now publish release notices to users, target them by role, and see whether anyone read them. + +### Backend + +- **`{prefix}-announcements`** — one table, two item shapes: announcement rows under a fixed `ANNOUNCEMENTS` partition and each user's ack rows under `USER#`. No GSIs. +- `/admin/announcements` — list, get, create, patch, `publish`, `archive`, `revise`, delete, and `{id}/stats` (#966, #972, #978). +- `GET /announcements` + an acknowledgement endpoint for users (#969). +- Gated by `ANNOUNCEMENTS_ENABLED`, **default on with a kill switch**. While off the routers are unmounted and the surface 404s; data and code remain intact. +- **Who may author** is the delegable `admin.announcements` scope. **Who sees** a published announcement is the announcement's own `targetRoles` — a display filter, deliberately *not* an RBAC grant. + +### Frontend + +- **What's New panel** with the user's feed and ack state (#969). +- **Banner** (#976, #979, #981) — floats rather than occupying layout, and sits beside the chat composer on the side the composer leaves free. Chat view only. +- **Modal** for high-priority announcements, gated by the spec's §D8 rules (#977). +- **Reach column** on the admin list, driven by ack funnel counters (#978). + +> ⚠️ Announcement ack counters are **incremented, never backfilled**. An announcement published before this release has no counters and will read as zero reach rather than as unknown. + +--- + +## Mid-turn steering + +Type a follow-up while a turn is still streaming and it now lands *inside* that turn. + +The text is injected at the next tool boundary as a `{"text": ...}` block on the same user-role message that carries the tool results, so the agent reads it before choosing its next action. A new `steering_applied` SSE event is emitted after that batch's `tool_result` events, so the thread renders in the order the model will see. + +**The transport is the session's existing single-flight lease row** — `steerQueue` + `steerFor`, owner-scoped exactly like `cancelRequestedFor`, armed by `POST /sessions/{id}/steer` and deleted with the lease at turn end. No new table, no new stream. + +Consumption is **commit-on-append**: the hook peeks at `AfterToolsEvent` and clears the inbox entry only on the `MessageAddedEvent` for that same message. `AfterToolsEvent` fires from a `finally`, so it also fires on the interrupt path where the mutated message is discarded — a hook that consumed on read would destroy the user's words whenever a steer landed on the same tool batch as an OAuth consent. + +**Absence of the event is a fallback, not an error.** A turn that calls no tools has no boundary to inject at, and a steer can lose the race with the turn's end; in both cases the entry stays queued and is sent as a normal turn. + +It is **append-only against the cached prefix** — the injection lands inside the segment the message-level cachePoint covers — so it never rewrites the prefix and costs nothing in cache terms. A test locks that placement. + +Gated by `MID_TURN_STEERING_ENABLED`, default on with a kill switch; while off the steer endpoint 404s and the hook returns immediately. Spec: `docs/specs/mid-turn-steering.md`. + +Four defects were found by validating this on dev and fixed in the same release: a steer rendered once per sync tick instead of once (#930), a follow-up typed while a turn was *paused* was dropped rather than queued (#934), the steer bubble used a non-standard color (#935), and a user bubble's overflow was measured once and latched (#937). + +--- + +## Single-file rebranding + +This repo is forked by institutions that are not Boise State, and until now rebranding meant hunting colors through components. + +`frontend/ai.client/src/branding/brand.config.ts` is now the only file to edit for every non-logo brand value: app name, page title, greeting templates and fallbacks, logo paths, brand colors, and surface anchors. Consumers never import it directly — they read through `BrandingService`, which normalizes and defends against missing or invalid values. + +- **Surface colors are validated, not merely accepted.** Each surface anchor must fall inside a per-role OKLCH band (`light` L≥0.90 / C≤0.04, `raised` L≥0.95 and above `light` / C≤0.03, `dark` L≤0.32 / C≤0.05) or it is rejected and reset to the default neutral for that role. The bands keep page and card backgrounds legible while still allowing a brand tint. +- **Four generators** run at `prestart` and `prebuild`: brand theme, surface theme, surface colors, and favicons (`sharp` + `tsx` are new dev dependencies for this). +- **Golden-file and parity specs** pin the generated output, including a spec that fails if the rebranding guide goes missing — documentation kept honest by test. +- New token layers: `styles/tokens/identity.css` and `styles/tokens/state.css`, plus generated `brand-theme.css`, `surface-theme.css` and `surface-colors.ts`. + +--- + +## Models and cost correctness + +### GPT-5.6 Sol, Terra and Luna + +Curated in the model catalog on the `bedrock-runtime` OpenAI-compatible endpoint (`us.openai.gpt-5.6-*`), with a new `provider="bedrock-responses"` transport (#949) and the IAM grant it turned out to need (#959). + +> ⚠️ **The OpenAI-compatible endpoint on `bedrock-runtime` authenticates under the `bedrock` service namespace, not `bedrock-mantle`.** Granting only `bedrock-mantle:CallWithBearerToken` returns `401 ... is not authorized to perform: bedrock:CallWithBearerToken`. + +**Every published GPT-5.6 rate in the catalog is corrected** (#980). They had been *derived* — from a blend across models that came out 1000x wrong — when AWS had published them in the model cards the whole time. The rates now come from the model cards; the derivation, where still used, reads a single-model day rather than a blend. + +### Explicit prompt-cache breakpoints: built, measured, shipped off + +GPT-5.6 supports explicit cache breakpoints. They were implemented (#954), measured, and came out **57% more expensive** than the provider's automatic caching, so the default is off (#956). The code stays for the next model family that prices it differently. + +Verified separately: GPT-5.6 caching **works** on the automatic path, at 10.6x on warm turns (#962). + +### Other cost fixes + +- **Prompt-cache TTL is derived from the serving model** rather than assumed, so `cacheStatus` stops misreading a hit as expired on models with a different TTL (#951). +- **`supportsCaching` is forced on where the provider caches unconditionally** (#960), and **Mantle models expose the caching controls** they were previously denied (#963). +- **OpenAI-family usage normalizes to disjoint buckets** (#945) — cached tokens were being counted inside the input total. +- **The cache-write premium and the Global/Regional rate tier were both wrong** in cost derivation (#914). +- **An omitted `supported_param` is now unsupported, not pass-through** (#915) — an empty `supportedParams` previously bypassed the guard entirely. + +--- + +## Multi-modal fine-tuning + +The fine-tuning surface assumed text. A **task-type registry** replaces that assumption, adding image and image+text tasks through the API and the SPA, with a **dollar-denominated quota** rather than a job count (#944). Generative VLMs are excluded from the dual-encoder task, instance types are validated, and the dataset tests are no longer optional. `pandas==2.3.3` is pinned in the backend to exercise the dataset contract — 2.x is what the py3.10 SageMaker text DLC resolves to and the only line compatible with the existing numpy pin. + +--- + +## 🐛 Bug fixes + +- **Artifact share links stayed live after their conversation was deleted.** The cascade used `table.batch_writer()`, and the app-api task role has no `dynamodb:BatchWriteItem` — it has the individual item actions, which is why the rest of the feature needed no IAM change (`TransactWriteItems` authorizes against those; `BatchWriteItem` does not). It failed closed in dev with an `AccessDeniedException` that reached no client, because the cascade runs in a never-raising background task after the 204. Fixed with per-row `DeleteItem`, which also isolates failures and keeps the zero-IAM-change property (#932). + > **The generalizable rule: moto proves *shape*, never *permission*.** All 14 cascade tests passed against a call the deployed role cannot make. +- **An empty "Shared with you" tab claimed "No artifacts match your search"** — with an empty search box. The filtered-empty state gated on the *library* total, so any non-empty library made an empty *tab* look like a failed search. Now three ordered states: nothing anywhere > nothing in this tab > nothing matching the filter. The specs missed it because every empty-state test asserted which rows rendered, never which sentence appeared when none did (#975). +- **"Pop-up blocked" was reported on every artifact open**, successful ones included (#953). +- **A duplicate error toast** fired alongside the shared-artifact page's own inline 404 — share calls now set `SUPPRESS_ERROR_TOAST`. `listShares` deliberately keeps its toast, because it degrades silently and the toast is its only signal (#927). +- **Artifact card actions overlapped the title** when the panel was docked and the chat column halved. Fixed with a **container query** — the card is sized by the chat column, not the viewport, so a media query is the wrong instrument — dropping labels to icons below 26rem with the labels visually hidden rather than removed (#927). +- The library view toggle stretched on narrow screens, and the grid card footer overflowed its card (#955). +- The new-announcement form's submit button could never enable (#974). +- Knowledge-base retrievability is now confirmed with a filtered query, and `TEXT_INDEXED` is classified correctly (#908). + +--- + +## 🔒 Security + +- **The custom HuggingFace model id reached a URL unvalidated.** The fine-tuning call site's comment has claimed to validate the id format since before this release; the code only checked non-empty and length. That was latent on `main` — the multi-modal work made it reachable by interpolating the value into a Hub request path, which CodeQL flags as a critical `py/partial-ssrf`. The host was always hard-coded, so this was never an arbitrary-host SSRF; what it allowed was a value carrying URL structure changing the meaning of two sinks — the pre-flight path, and `model_name_or_path` as forwarded to the training container. Now validated against an anchored repo-id pattern at **both** sinks, rather than one relying on the other's branch having run. + > The pattern uses `\Z`, not `$` — `$` also matches immediately before a trailing newline, so an otherwise-anchored pattern accepts `org/model\n`. A test pins that. +- **All 47 open Dependabot alerts cleared** (#924), across the backend, the SPA, infrastructure, the docs site, and the backup/restore scripts. Notable: `cryptography` 48.0.1 → 50.0.1, `dompurify` ≥3.4.13, `undici` ≥7.29.0, `hono` ≥4.12.34, `brace-expansion` ≥5.0.9, `postcss` 8.5.12 → 8.5.28. +- **CodeQL: 11 high, 20 medium and 9 note findings remediated** (#925) — principally log injection, across 18 backend modules and one SPA page. The nightly workflow is extended in the same pass. + +--- + +## 🏗️ Infrastructure + +- **`{prefix}-announcements`** DynamoDB table, **no GSIs**, name published to SSM at `/{prefix}/admin/announcements-table-name`. App-api gains `AnnouncementsTableAccess` (`GetItem`/`PutItem`/`UpdateItem`/`DeleteItem`/`Query`/`Scan`). +- **`bedrock:CallWithBearerToken`** on the inference-api role, for the `bedrock-runtime` OpenAI-compatible endpoint. +- **`CDK_ARTIFACT_SHARE_INBOX_ENABLED`** deploy variable → `ARTIFACT_SHARE_INBOX_ENABLED` on the app-api container. Default off. +- `infrastructure/gsi-inventory.json` gains `"announcements": []`. **No GSI operations on any existing table** — this release is not subject to the one-index-per-`UpdateTable` split. + +--- + +## 📦 Dependencies + +| Component | Package | From | To | +|---|---|---|---| +| Backend | `cryptography` | 48.0.1 | 50.0.1 | +| Backend | `aiohttp` | 3.14.1 | 3.14.3 | +| Backend | `pandas` | — | 2.3.3 (added) | +| Frontend | `@angular/*` | 21.2.17 | 21.2.19 | +| Frontend | `mermaid` | 11.15.0 | 11.16.1 | +| Frontend | `postcss` | 8.5.12 | 8.5.28 | +| Frontend | `sharp` | — | 0.33.0 (added) | +| Frontend | `tsx` | — | 4.23.12 (added) | +| Frontend | `dompurify` | ≥3.4.0 | ≥3.4.13 | +| Frontend | `undici` | ≥7.28.0 | ≥7.29.0 | +| Frontend | `hono` | ≥4.12.25 | ≥4.12.34 | +| Infrastructure | `aws-cdk-lib` | 2.262.0 | 2.265.0 | +| Infrastructure | `brace-expansion` | — | ≥5.0.9 | + +--- + +## 🚀 Deployment notes + +1. **Deploy order is `platform.yml` → `backend.yml` → `frontend-deploy.yml`.** This release changes `infrastructure/lib/constructs/**` and `config.ts`, so the push to `main` triggers `platform.yml` automatically. That order is **not enforced by the workflows** — they share a concurrency group and queue, but nothing guarantees CDK wins the slot. If `backend.yml` runs first, the announcements routes will 500 on a missing table until the CDK deploy lands. Watch both runs. + +2. **Decide `CDK_ARTIFACT_SHARE_INBOX_ENABLED` before the deploy, not after.** It reaches the running service only through a `platform.yml` deploy (CDK writes it into the ECS task definition), and `platform.yml` is path-filtered to infra changes — so a later backend- or frontend-only merge will *not* pick up a variable change. Set it now and it rides this release's CDK deploy; set it afterwards and you must dispatch `platform.yml` manually. + - Off (default): owner-side sharing, the library, previews, rename/delete and shared-conversation artifacts all ship. Only the "Shared with you" tab is absent, and its absence is silent by design. + - On: the tab appears, already populated — the pointer rows have been accumulating since a share was first created. + +3. **No data migration.** The conversation-share snapshot's `artifacts` key is optional on read, so pre-existing shares read as `[]`. `GSI2PK`/`GSI2SK` stamping is inert until an index consumes it — DynamoDB charges no index write when no index exists. + +4. **Two unflagged behavior changes.** An omitted `supported_param` is now treated as unsupported rather than passed through, and the prompt-cache TTL is derived from the serving model rather than assumed. Both are corrections; both take effect on the first turn after the deploy. + +5. **Announcement reach counters are not backfilled.** Anything published before this release reads as zero reach. + +6. **Post-deploy verification.** Open `/artifacts` and confirm the library lists and previews. Create a share, open it from a second account, then revoke it and confirm the link dies. Publish a test announcement and confirm the banner appears on the chat view. If the inbox flag is on, confirm the third tab appears and — with nothing shared — says "nothing in this tab" rather than "no artifacts match your search". + +--- + # Release Notes — v1.17.0 **Release Date:** September 2, 2026 diff --git a/VERSION b/VERSION index 092afa15d..84cc52946 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.17.0 +1.18.0 diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 93db6cd8b..a2e25c757 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "agentcore-stack" -version = "1.17.0" +version = "1.18.0" requires-python = ">=3.10" description = "Multi-agent conversational AI system with AWS Bedrock AgentCore" readme = "README.md" @@ -36,9 +36,9 @@ dependencies = [ # Security: pin transitive deps to fix Dependabot alerts "pillow==12.3.0", - "cryptography==48.0.1", + "cryptography==50.0.1", "python-multipart==0.0.31", - "aiohttp==3.14.1", + "aiohttp==3.14.3", "urllib3==2.7.0", "idna==3.15", "pyasn1==0.6.4", @@ -93,6 +93,12 @@ dev = [ "types-aiofiles==25.1.0.20260409", "tiktoken==0.12.0", "numpy==2.2.6", + # Exercises the fine-tuning dataset contract (manifest reading, label + # normalisation, the image-archive path). The SageMaker containers install + # their own unpinned pandas; 2.x is what the py3.10 text DLC resolves to, + # and it is the only line compatible with the numpy pin above — pandas 3.x + # requires numpy>=2.3.3 on py3.14+. + "pandas==2.3.3", ] # All dependencies (convenience) diff --git a/backend/scripts/backfill_session_static_sk.py b/backend/scripts/backfill_session_static_sk.py index afd56b8b8..4d54b4d59 100644 --- a/backend/scripts/backfill_session_static_sk.py +++ b/backend/scripts/backfill_session_static_sk.py @@ -33,7 +33,7 @@ import logging import os import time -from typing import Any, Dict, Optional, Tuple +from typing import Any, Dict, Optional import boto3 from boto3.dynamodb.conditions import Attr diff --git a/backend/scripts/probe_gpt56_cache_rates.py b/backend/scripts/probe_gpt56_cache_rates.py new file mode 100644 index 000000000..a0f303c9c --- /dev/null +++ b/backend/scripts/probe_gpt56_cache_rates.py @@ -0,0 +1,596 @@ +"""Empirically derive GPT-5.6 rates, and verify the caching path end to end. + +The Price List API publishes no commercial-region rates for the GPT-5.6 family +(checked 2026-09-05 across every Bedrock service code — only GovCloud Mantle +rows exist, and `sol` is absent entirely), so +`docs/specs/gpt-5-6-prompt-caching.md` PR-3 cannot verify catalog rates the way +it specifies. This drives real turns to produce a known token split, which a +later Cost Explorer read divides into to recover $/MTok per bucket. + +It deliberately does **not** touch the shared dev catalog, RBAC, or the agent +loop. It calls the model through our own transport +(`apis.shared.models.bedrock_responses`), which means one run also exercises, +against a live model for the first time: + +- PR-2 the bedrock-runtime base URL, the per-request bearer-token mint, and + the inference-profile model id +- PR-1 usage normalization — whether the reported buckets are disjoint +- PR-4 explicit cache breakpoints, by comparing `--mode explicit` against + `--mode implicit` + +**Reads only, apart from the model invocations themselves.** Nothing is written +to DynamoDB or to the catalog. + +⚠️ Real spend. A run is `--turns` calls against a `--prefix-tokens`-sized +prompt; the script prints an estimate before starting and totals actual tokens +after. Keep it small. + +Usage: + + cd backend + AWS_PROFILE=dev-ai uv run python scripts/probe_gpt56_cache_rates.py \ + --model-id us.openai.gpt-5.6-sol --turns 4 + + # A/B the explicit breakpoint against stock implicit caching + AWS_PROFILE=dev-ai uv run python scripts/probe_gpt56_cache_rates.py \ + --mode both --turns 3 + +Then, once Cost Explorer has settled (~24h), recover the rates: + + AWS_PROFILE=dev-ai uv run python scripts/probe_gpt56_cache_rates.py \ + --rates-only --since 2026-09-05 \ + --table dev-boisestateai-v2-sessions-metadata + +⚠️ Cost Explorer bills these models through AWS Marketplace, under usage types +that name the token bucket and the service tier but NOT the model. Every +OpenAI-family model in the account shares those four rows. A derived rate is +therefore only a given model's rate on a day when it was the ONLY OpenAI-family +model to run — which is what ``--table`` checks and prints. +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import re +import sys +import time +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + + +# A stable, boring preamble. Repeated to reach the requested size so the prefix +# is deterministic across turns — an unstable prefix would defeat the cache and +# make the whole measurement meaningless. +_PREFIX_UNIT = ( + "You are a careful assistant for a university platform. Answer briefly. " + "Follow institutional policy. Do not speculate. Cite sources when asked. " +) + + +def build_system_prompt(approx_tokens: int) -> str: + """Build a deterministic system prompt of roughly ``approx_tokens`` tokens.""" + # ~4 chars/token is the standard rough conversion; exactness does not + # matter because the model reports the true count back. + target_chars = approx_tokens * 4 + repeats = max(1, target_chars // len(_PREFIX_UNIT)) + return _PREFIX_UNIT * repeats + + +@dataclass +class TurnObservation: + turn: int + input_tokens: int + cache_read: int + cache_write: int + output_tokens: int + total_tokens: int + latency_s: float + + @property + def disjoint(self) -> bool: + """Do the buckets partition the reported input total?""" + return ( + self.input_tokens + self.cache_read + self.cache_write + == self.total_tokens - self.output_tokens + ) + + +@dataclass +class ArmResult: + mode: str + turns: List[TurnObservation] = field(default_factory=list) + error: Optional[str] = None + + def totals(self) -> Dict[str, int]: + return { + "inputTokens": sum(t.input_tokens for t in self.turns), + "cacheReadInputTokens": sum(t.cache_read for t in self.turns), + "cacheWriteInputTokens": sum(t.cache_write for t in self.turns), + "outputTokens": sum(t.output_tokens for t in self.turns), + } + + +async def _run_turn( + model: Any, system_prompt: str, messages: List[Dict[str, Any]] +) -> TurnObservation: + """One streamed call; returns the normalized usage the model reported.""" + usage: Dict[str, int] = {} + started = time.monotonic() + async for event in model.stream( + messages, + system_prompt=system_prompt, + ): + if "metadata" in event: + usage = event["metadata"].get("usage", {}) or {} + elapsed = time.monotonic() - started + return TurnObservation( + turn=0, + input_tokens=usage.get("inputTokens", 0), + cache_read=usage.get("cacheReadInputTokens", 0), + cache_write=usage.get("cacheWriteInputTokens", 0), + output_tokens=usage.get("outputTokens", 0), + total_tokens=usage.get("totalTokens", 0), + latency_s=elapsed, + ) + + +async def run_arm( + mode: str, + *, + model_id: str, + region: str, + turns: int, + prefix_tokens: int, + gap_seconds: float, + grow_history: bool = False, + history_chunk_tokens: int = 0, + prefix_salt: str = "", +) -> ArmResult: + """Run one arm: N sequential calls sharing one static prefix. + + With ``grow_history`` each turn carries the accumulated conversation, which + is the scenario PR-4 exists for: history churn behind a stable prefix. A + fixed single-message workload only shows that explicit mode does not *hurt*. + """ + from apis.shared.models.bedrock_responses import ( + EXPLICIT_CACHE_ENABLED_ENV, + build_bedrock_responses_model, + ) + + # The flag is read per call, so flipping it here is enough — no rebuild. + os.environ[EXPLICIT_CACHE_ENABLED_ENV] = "true" if mode == "explicit" else "false" + + result = ArmResult(mode=f"{mode}+churn" if grow_history else mode) + system_prompt = build_system_prompt(prefix_tokens) + if prefix_salt: + # Distinct prefix bytes -> a distinct cache entry, so the arm starts cold. + system_prompt = f"[{prefix_salt}] " + system_prompt + model = build_bedrock_responses_model( + model_id=model_id, region=region, params={"max_output_tokens": 32} + ) + + label = f"{mode}+churn" if grow_history else mode + print(f"\n▸ arm={label} model={model_id} region={region} turns={turns}") + history: List[Dict[str, Any]] = [] + for i in range(1, turns + 1): + question = f"Reply with the number {i}." + if history_chunk_tokens: + question += " Context: " + ("filler context words " * (history_chunk_tokens // 3)) + if grow_history: + history.append({"role": "user", "content": [{"text": question}]}) + messages = list(history) + else: + messages = [{"role": "user", "content": [{"text": question}]}] + try: + obs = await _run_turn(model, system_prompt, messages) + except Exception as exc: # noqa: BLE001 — a probe reports, never raises + result.error = f"{type(exc).__name__}: {exc}" + print(f" turn {i}: FAILED — {result.error}") + return result + obs.turn = i + result.turns.append(obs) + if grow_history: + # Cheap stand-in for the assistant's reply; its exact text does not + # matter, only that the history grows deterministically. + history.append({"role": "assistant", "content": [{"text": str(i)}]}) + print( + f" turn {i}: input={obs.input_tokens:>7,} " + f"cacheRead={obs.cache_read:>7,} cacheWrite={obs.cache_write:>7,} " + f"output={obs.output_tokens:>4,} " + f"disjoint={'yes' if obs.disjoint else 'NO'} " + f"({obs.latency_s:.1f}s)" + ) + if i < turns and gap_seconds: + await asyncio.sleep(gap_seconds) + return result + + +def print_verdicts(results: List[ArmResult]) -> None: + """Say what each arm proves, or fails to.""" + print("\n" + "=" * 72) + print("VERDICTS") + print("=" * 72) + + for r in results: + print(f"\n▸ arm={r.mode}") + if r.error: + print(f" TRANSPORT: FAILED — {r.error}") + continue + if not r.turns: + print(" no turns recorded") + continue + + print(" PR-2 transport: reached the model and streamed usage — OK") + + bad = [t.turn for t in r.turns if not t.disjoint] + print( + " PR-1 disjoint buckets: " + + ("OK on every turn" if not bad else f"VIOLATED on turns {bad}") + ) + + first, rest = r.turns[0], r.turns[1:] + print( + f" turn 1 (cold): write={first.cache_write:,} read={first.cache_read:,}" + ) + if rest: + reads = [t.cache_read for t in rest] + writes = [t.cache_write for t in rest] + print(f" turns 2+ read: min={min(reads):,} max={max(reads):,}") + print(f" turns 2+ write: min={min(writes):,} max={max(writes):,}") + # The check the spec names: a warm turn should READ the static + # prefix rather than re-write it. + if max(reads) == 0: + print(" ⚠️ NO cache reads on warm turns — caching is not engaging.") + elif min(reads) >= 0.8 * first.total_tokens - first.output_tokens: + print(" ✅ warm turns read ~the whole prefix — boundary looks right.") + else: + print( + " ⚠️ warm reads are well under the prefix — boundary may be " + "misplaced (PR-4 kill switch is the way out)." + ) + if all(t.cache_write == 0 for t in r.turns): + print( + " ⚠️ cacheWrite is 0 on every turn — either the model reports no " + "writes, or the cache_write_tokens mapping is not landing." + ) + + if len(results) == 2: + a, b = results + print(f"\n▸ {a.mode} vs {b.mode} (totals)") + ta, tb = a.totals(), b.totals() + for key in ("inputTokens", "cacheReadInputTokens", "cacheWriteInputTokens"): + print(f" {key:<24} {ta.get(key,0):>9,} {tb.get(key,0):>9,}") + + +# Cost Explorer names no model. OpenAI-family models on Bedrock bill through +# AWS Marketplace, under usage types that carry the token bucket and the +# service tier but NOT the model id — every OpenAI model in the account lands +# in the same four rows. Verified 2026-09-06 against USAGE_TYPE grouped by +# OPERATION and by BILLING_ENTITY; no finer dimension exists. +_MARKETPLACE_TOKEN_USAGE = re.compile( + r"MP:\w+?_(?Pinput_tokens|output_tokens|cache_read_tokens|cache_write_tokens)" + r"_(?P[A-Za-z0-9-]+)-Units$" +) +# The PascalCase twin is the Converse-family (Claude) naming. Matched only so a +# run can SAY it saw them — attributing these to a GPT model is the exact +# mistake this guard exists to prevent. +_CONVERSE_TOKEN_USAGE = re.compile(r"MP:\w+?_(?:Cache(?:Read|Write)Input|Input|Output)TokenCount-Units$") + +_BUCKET_TO_USAGE_KEY = { + "input_tokens": "inputTokens", + "output_tokens": "outputTokens", + "cache_read_tokens": "cacheReadInputTokens", + "cache_write_tokens": "cacheWriteInputTokens", +} + + +def _is_openai_family(model_id: str) -> bool: + lowered = model_id.lower() + return "openai" in lowered or "gpt" in lowered + + +def models_that_ran(table_name: str, region: str, since: str, until: str) -> Dict[str, Dict[str, float]]: + """Per-model token totals we recorded, for the same window. + + This is the attribution guard. Cost Explorer cannot say which model spent + the money, so a derived rate is only trustworthy on a day when exactly one + OpenAI-family model ran. + """ + import boto3 + + table = boto3.resource("dynamodb", region_name=region).Table(table_name) + totals: Dict[str, Dict[str, float]] = {} + kwargs: Dict[str, Any] = { + "FilterExpression": "begins_with(GSI_SK, :c) AND #ts BETWEEN :s AND :u", + "ExpressionAttributeValues": {":c": "C#", ":s": since, ":u": until}, + "ExpressionAttributeNames": {"#ts": "timestamp"}, + "ProjectionExpression": "modelInfo, tokenUsage", + } + start_key = None + while True: + if start_key: + kwargs["ExclusiveStartKey"] = start_key + response = table.scan(**kwargs) + for item in response.get("Items", []): + info = item.get("modelInfo") or {} + model_id = info.get("modelId") or info.get("model") or "(unknown)" + usage = item.get("tokenUsage") or {} + bucket = totals.setdefault(model_id, {"calls": 0.0}) + bucket["calls"] += 1 + for key in _BUCKET_TO_USAGE_KEY.values(): + try: + bucket[key] = bucket.get(key, 0.0) + float(usage.get(key) or 0) + except (TypeError, ValueError): + continue + start_key = response.get("LastEvaluatedKey") + if not start_key: + break + return totals + + +def derive_rates( + since: str, + until: Optional[str], + region: str, + table_name: Optional[str] = None, +) -> int: + """Recover $/MTok per bucket from Cost Explorer usage + cost. + + Rate = unblended cost / usage quantity, per usage type. The Price List API + cannot supply this: there is no Marketplace service code in it at all + (checked 2026-09-06 — 269 service codes, none for Marketplace), and the + four Bedrock codes carry no commercial GPT-5.6 rows. + + The unit is read from Cost Explorer's own ``Unit`` field rather than + assumed. Marketplace token rows report ``1M tokens``; the natively-billed + Bedrock rows (Nova, Titan, Mantle-served models) report ``1K tokens``. An + earlier version of this function assumed 1K for everything, which + overstated every Marketplace rate by 1000x. + """ + import boto3 + + from datetime import date, timedelta + + end = until or (date.today() + timedelta(days=1)).isoformat() + ce = boto3.client("ce", region_name="us-east-1") + resp = ce.get_cost_and_usage( + TimePeriod={"Start": since, "End": end}, + Granularity="DAILY", + Metrics=["UnblendedCost", "UsageQuantity"], + GroupBy=[{"Type": "DIMENSION", "Key": "USAGE_TYPE"}], + ) + + per_day: Dict[str, List[Dict[str, Any]]] = {} + converse_days: Dict[str, float] = {} + for period in resp.get("ResultsByTime", []): + day = period["TimePeriod"]["Start"] + for group in period.get("Groups", []): + usage_type = group["Keys"][0] + cost = float(group["Metrics"]["UnblendedCost"]["Amount"]) + qty = float(group["Metrics"]["UsageQuantity"]["Amount"]) + if _CONVERSE_TOKEN_USAGE.search(usage_type): + converse_days[day] = converse_days.get(day, 0.0) + cost + continue + match = _MARKETPLACE_TOKEN_USAGE.search(usage_type) + if not match or not qty: + continue + unit = group["Metrics"]["UsageQuantity"].get("Unit", "") + per_mtok = _to_per_mtok(cost / qty, unit) + per_day.setdefault(day, []).append({ + "bucket": match.group("bucket"), + "tier": match.group("tier"), + "usage_type": usage_type, + "qty": qty, + "unit": unit, + "cost": cost, + "per_mtok": per_mtok, + }) + + if not per_day: + print( + f"No Marketplace token usage types in Cost Explorer for {since}..{end}.\n" + "Marketplace line items settle slower than native AWS ones — allow " + "24-48h, not 24h." + ) + return 1 + + for day in sorted(per_day): + print(f"\n▸ {day}") + attribution = _print_attribution(table_name, region, day) + print(f" {'bucket':<20}{'tier':<10}{'unit':>12}{'qty':>14}{'cost USD':>11}{'$/MTok':>11}") + for row in sorted(per_day[day], key=lambda r: r["bucket"]): + rate = f"{row['per_mtok']:.4f}" if row["per_mtok"] is not None else "unit?" + print( + f" {row['bucket']:<20}{row['tier']:<10}{row['unit']:>12}" + f"{row['qty']:>14,.6f}{row['cost']:>11.4f}{rate:>11}" + ) + if converse_days.get(day): + print( + f" (also ${converse_days[day]:.4f} of Converse-family " + "*TokenCount rows that day — Claude, not GPT; excluded)" + ) + if attribution is False: + print( + " ⚠️ NOT ATTRIBUTABLE. More than one OpenAI-family model ran " + "this day and Cost Explorer does not break the buckets down by " + "model. Re-run the probe on a day when only one model runs." + ) + + print( + "\nMethod: these rows are model-agnostic, so a rate is only a given " + "model's rate on a day when that model was the only OpenAI-family " + "model to run. Check the attribution line above before using a number." + ) + return 0 + + +def _to_per_mtok(rate_per_unit: float, unit: str) -> Optional[float]: + """Convert a $/unit rate to $/MTok using Cost Explorer's declared unit.""" + normalized = (unit or "").strip().lower() + if normalized in ("1m tokens", "1m token"): + return rate_per_unit + if normalized in ("1k tokens", "1k token"): + return rate_per_unit * 1000 + if normalized in ("tokens", "token"): + return rate_per_unit * 1_000_000 + return None + + +def _print_attribution(table_name: Optional[str], region: str, day: str) -> Optional[bool]: + """Print which models we recorded that day. Returns False if ambiguous.""" + if not table_name: + print(" models that ran: unknown (pass --table to attribute)") + return None + from datetime import date, timedelta + + nxt = (date.fromisoformat(day) + timedelta(days=1)).isoformat() + try: + ran = models_that_ran(table_name, region, day, nxt) + except Exception as exc: # noqa: BLE001 - diagnostic only + print(f" models that ran: lookup failed ({exc})") + return None + + openai_models = sorted(m for m in ran if _is_openai_family(m)) + others = sorted(m for m in ran if not _is_openai_family(m)) + if not openai_models: + print(" models that ran: no OpenAI-family model recorded " + "(usage may be from a direct-transport probe, which is not recorded)") + else: + print(f" models that ran: {', '.join(openai_models)}") + if others: + print(f" (also non-OpenAI: {', '.join(others)} — billed separately)") + return len(openai_models) == 1 + + +async def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--model-id", default="us.openai.gpt-5.6-sol") + parser.add_argument("--region", default="us-west-2") + parser.add_argument("--turns", type=int, default=4) + parser.add_argument( + "--prefix-tokens", + type=int, + default=8000, + help="Approximate size of the stable system prompt. Must exceed the " + "1024-token minimum cacheable prefix by a wide margin.", + ) + parser.add_argument("--gap-seconds", type=float, default=3.0) + parser.add_argument( + "--mode", + default="explicit", + choices=["explicit", "implicit", "both"], + help="explicit = PR-4 breakpoints on; implicit = stock caching.", + ) + parser.add_argument("--json-out", default=None) + parser.add_argument( + "--history-chunk-tokens", + type=int, + default=0, + help="Pad each history message to roughly this size, so the effect of " + "history growth on the cache is large enough to read.", + ) + parser.add_argument( + "--prefix-salt", + default="", + help="Change the static prefix so the run starts against a COLD cache.", + ) + parser.add_argument( + "--grow-history", + action="store_true", + help="Accumulate conversation history across turns — the churn " + "scenario explicit breakpoints are meant to protect against.", + ) + parser.add_argument( + "--rates-only", + action="store_true", + help="Skip the turns; just read Cost Explorer and derive rates.", + ) + parser.add_argument("--since", default=None, help="YYYY-MM-DD for --rates-only") + parser.add_argument( + "--table", + default=None, + help="sessions-metadata table, for the --rates-only attribution guard " + "(e.g. dev-boisestateai-v2-sessions-metadata).", + ) + parser.add_argument("--until", default=None) + args = parser.parse_args() + + if args.rates_only: + if not args.since: + parser.error("--rates-only requires --since YYYY-MM-DD") + return derive_rates(args.since, args.until, args.region, args.table) + + modes = ["explicit", "implicit"] if args.mode == "both" else [args.mode] + approx_calls = args.turns * len(modes) + print( + f"About to make {approx_calls} real model calls against {args.model_id} " + f"with a ~{args.prefix_tokens:,}-token prefix.\n" + f"Rough worst case if nothing caches: " + f"~{approx_calls * args.prefix_tokens:,} input tokens." + ) + + results = [] + for mode in modes: + results.append( + await run_arm( + mode, + model_id=args.model_id, + region=args.region, + turns=args.turns, + prefix_tokens=args.prefix_tokens, + gap_seconds=args.gap_seconds, + grow_history=args.grow_history, + history_chunk_tokens=args.history_chunk_tokens, + prefix_salt=args.prefix_salt, + ) + ) + + print_verdicts(results) + + grand = { + "inputTokens": sum(r.totals()["inputTokens"] for r in results), + "cacheReadInputTokens": sum(r.totals()["cacheReadInputTokens"] for r in results), + "cacheWriteInputTokens": sum(r.totals()["cacheWriteInputTokens"] for r in results), + "outputTokens": sum(r.totals()["outputTokens"] for r in results), + } + print("\n▸ tokens consumed this run (the denominator for rate derivation)") + for key, value in grand.items(): + print(f" {key:<24} {value:>10,}") + print( + "\nNext: wait for Cost Explorer to settle (~24h), then\n" + f" AWS_PROFILE=dev-ai uv run python {os.path.basename(__file__)} " + f"--rates-only --since {time.strftime('%Y-%m-%d')}" + ) + + if args.json_out: + with open(args.json_out, "w", encoding="utf-8") as fh: + json.dump( + { + "modelId": args.model_id, + "region": args.region, + "prefixTokens": args.prefix_tokens, + "totals": grand, + "arms": [ + { + "mode": r.mode, + "error": r.error, + "turns": [vars(t) for t in r.turns], + } + for r in results + ], + }, + fh, + indent=2, + ) + print(f"\nRaw observations -> {args.json_out}") + + return 1 if any(r.error for r in results) else 0 + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main())) diff --git a/backend/scripts/refresh_instance_pricing.py b/backend/scripts/refresh_instance_pricing.py new file mode 100755 index 000000000..14cd6961e --- /dev/null +++ b/backend/scripts/refresh_instance_pricing.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Resync the fine-tuning instance rates in ``fine_tuning/pricing.py``. + +Queries the AWS Price List API for the SageMaker ``Training`` and +``BatchTransform`` components in us-west-2 and prints the two rate maps. Run +it when AWS changes prices or when adding an instance family, then paste the +output into ``pricing.py`` — the maps stay literal so they are reviewable in a +diff and need no network access at import time. + +Usage: + python backend/scripts/refresh_instance_pricing.py --profile dev-ai +""" + +import argparse +import json +import subprocess +import sys + +# Instance families we are willing to offer. Anything not listed here is not +# priced, and therefore rejected at job creation. +INSTANCES = [ + f"ml.{fam}.{size}" + for fam in ("g5", "g6", "g6e") + for size in ("xlarge", "2xlarge", "4xlarge", "8xlarge", "12xlarge", "24xlarge", "48xlarge") +] + ["ml.g5.16xlarge"] + +REGION = "us-west-2" + + +def fetch(instance: str, profile: str) -> dict: + """Return {component: usd_per_hour} for one instance type.""" + result = subprocess.run( + [ + "aws", "pricing", "get-products", + "--profile", profile, "--region", "us-east-1", + "--service-code", "AmazonSageMaker", + "--filters", f"Type=TERM_MATCH,Field=regionCode,Value={REGION}", + f"Type=TERM_MATCH,Field=instanceName,Value={instance}", + "--max-results", "40", "--output", "json", + ], + capture_output=True, + text=True, + ) + if result.returncode != 0: + print(f" ! {instance}: {result.stderr.strip()[:120]}", file=sys.stderr) + return {} + + rates = {} + for entry in json.loads(result.stdout).get("PriceList", []): + obj = json.loads(entry) + component = obj["product"]["attributes"].get("component") + if component not in ("Training", "BatchTransform"): + continue + for term in obj["terms"].get("OnDemand", {}).values(): + for dimension in term["priceDimensions"].values(): + usd = float(dimension["pricePerUnit"]["USD"]) + if usd > 0: + rates[component] = usd + return rates + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--profile", default="dev-ai", help="AWS profile to query with") + args = parser.parse_args() + + training, transform = {}, {} + for instance in INSTANCES: + rates = fetch(instance, args.profile) + if "Training" in rates: + training[instance] = rates["Training"] + if "BatchTransform" in rates: + transform[instance] = rates["BatchTransform"] + + for name, rates in (("TRAINING_COST_PER_HOUR", training), ("TRANSFORM_COST_PER_HOUR", transform)): + print(f"\n{name}: Dict[str, float] = {{") + for instance, usd in rates.items(): + print(f' "{instance}": {usd},') + print("}") + + missing = [i for i in INSTANCES if i not in training] + if missing: + print(f"\n# No on-demand training rate (do not offer these): {', '.join(missing)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/src/.env.example b/backend/src/.env.example index 4a42dff6f..24a71394a 100644 --- a/backend/src/.env.example +++ b/backend/src/.env.example @@ -915,6 +915,24 @@ S3_SKILL_RESOURCES_BUCKET_NAME= # Default: true SKILLS_ENABLED=true +# --------------------------------------------------------------------- +# Artifact share inbox ("Shared with you") +# --------------------------------------------------------------------- +# Whether recipients can DISCOVER artifacts shared with them, i.e. the +# GET /shared-artifacts endpoint behind the artifact library's +# "Shared with you" tab. +# +# Default OFF and opt-in — the reverse of SKILLS_ENABLED and the other +# kill-switch flags above. Only the literal "true" enables it; unset, +# empty and anything else resolve to off. The surface shipped ahead of +# the product decision about it, so revealing it is a deliberate act. +# +# This gates the READ ONLY. The recipient fan-out rows the inbox reads +# are written by every share whether this is on or off, so turning it on +# shows a complete inbox rather than one that starts from the flip. +# Do not "optimise" by gating the write path. +ARTIFACT_SHARE_INBOX_ENABLED=false + # ============================================================================= # FINE-TUNING (OPTIONAL) # ============================================================================= diff --git a/backend/src/agents/builtin_tools/artifacts/service.py b/backend/src/agents/builtin_tools/artifacts/service.py index a47c3e264..71e16ff6d 100644 --- a/backend/src/agents/builtin_tools/artifacts/service.py +++ b/backend/src/agents/builtin_tools/artifacts/service.py @@ -9,11 +9,43 @@ HEAD row : PK=USER#{user_id} SK=ARTIFACT#{aid}#HEAD + GSI1PK=SESSION#{session_id} + GSI1SK=ARTIFACT#{updated_at}#{aid} (SessionIndex) + + GSI2PK=USER#{user_id} + + GSI2SK=ARTIFACT#{updated_at}#{aid} (index NOT YET CREATED) S3 layout : {user_id}/{aid}/v{n}/index.html Versions are immutable (no DeleteObject grant in inference-api) — an update writes a new version and re-points HEAD. +Immutable, but not permanent: app-api's `ArtifactLifecycleService` +deletes whole artifacts (every version row, the HEAD row, and every +share of them) and retitles them. Two consequences for anything written +here. Rows can vanish between a read and a write, so every write-back on +an existing row — `set_produced_by_message_index` is the one today — +must carry `attribute_exists(SK)` rather than resurrect a deleted +artifact. And `title` is no longer writer-owned: a rename updates it on +HEAD and on every version row, deliberately touching neither `version` +(the optimistic lock below) nor `updated_at` (embedded in the GSI sort +keys, which only this module maintains). + +GSI2PK/GSI2SK are written ahead of the index that will consume them. +Nothing queries them today — a user-wide artifact list is served by a +base-table Query on PK=USER#{uid}, which is adequate while the heaviest +user holds well under a 1MB page. They are stamped now because a sparse +GSI only ever contains rows that already carry its key attributes: rows +written before the attributes exist stay invisible to it forever unless +a migration script backfills them, and that omission fails silently (a +library page that lists only artifacts created after the deploy, with no +error anywhere). Writing them from now on means the eventual +`UserArtifactsIndex` — GSI2PK hash, GSI2SK range, queried with +ScanIndexForward=False for newest-first — backfills every row stamped +since this change, shrinking the manual backfill to the rows that +predate it. Until then they are inert ordinary attributes: DynamoDB +charges no index write when no index consumes them. + +Stamped on HEAD rows only, deliberately: one indexed row per artifact +rather than one per version. Both write paths below must stamp them, or +an artifact's HEAD loses the attributes on its next update. + Markdown artifacts: when `content_type` is a Markdown type, the model authors raw Markdown but S3 stores a self-contained HTML render wrapper (the writer owns rendering — the render Lambda is a pass-through). The @@ -340,6 +372,8 @@ def create_artifact_record( "updated_at": now, "GSI1PK": f"SESSION#{session_id}", "GSI1SK": f"ARTIFACT#{now}#{artifact_id}", + "GSI2PK": pk, + "GSI2SK": f"ARTIFACT#{now}#{artifact_id}", }, ConditionExpression="attribute_not_exists(SK)", ) @@ -414,6 +448,8 @@ def update_artifact_record( "updated_at": now, "GSI1PK": f"SESSION#{head.get('session_id', '')}", "GSI1SK": f"ARTIFACT#{now}#{artifact_id}", + "GSI2PK": pk, + "GSI2SK": f"ARTIFACT#{now}#{artifact_id}", }, ConditionExpression="version = :cur", ExpressionAttributeValues={":cur": current}, diff --git a/backend/src/agents/main_agent/base_agent.py b/backend/src/agents/main_agent/base_agent.py index 4b6c05528..e87d4ba10 100644 --- a/backend/src/agents/main_agent/base_agent.py +++ b/backend/src/agents/main_agent/base_agent.py @@ -13,6 +13,7 @@ from agents.main_agent.core import ModelConfig, SystemPromptBuilder, AgentFactory from agents.main_agent.session import SessionFactory from agents.main_agent.session.hooks import ( + SteeringHook, StopHook, OAuthConsentHook, MCPExternalApprovalHook, @@ -194,6 +195,7 @@ async def stream_async( interrupt_responses: Optional[List[Dict[str, Any]]] = None, continue_truncated: bool = False, turn_agent_id: Optional[str] = None, + turn_lease: Any = None, ) -> AsyncGenerator[str, None]: """Stream agent responses. Subclasses must implement. @@ -272,6 +274,8 @@ def _create_hooks(self) -> List: Includes: - StopHook: Always enabled, cancels tool execution on user stop + - SteeringHook: Injects a follow-up queued mid-turn at the next tool + boundary - OAuthConsentHook: Pauses the agent (Strands interrupt) when an OAuth-gated MCP tool is about to run without a cached token - Approval hooks: Gate dangerous operations for user confirmation @@ -284,6 +288,18 @@ def _create_hooks(self) -> List: # Always-on: session cancellation hooks.append(StopHook(self.session_manager)) + # Mid-turn steering: a follow-up the user typed while this turn was + # streaming is injected into the tool-result message at the next tool + # boundary. Registered unconditionally and inert unless the turn's + # lease carries a queued entry; MID_TURN_STEERING_ENABLED=false makes + # it return immediately. See docs/specs/mid-turn-steering.md. + # Held on the wrapper so the stream coordinator can drain the + # injections it confirmed and emit `steering_applied` for each. The + # hook itself holds no per-turn state beyond that ack — the lease is + # read off the session manager every boundary. + self.steering_hook = SteeringHook(self.session_manager) + hooks.append(self.steering_hook) + # OAuth consent gate for external MCP tools. Registered unconditionally; # the hook is a no-op for tools that don't have a registered provider. hooks.append(self._build_oauth_consent_hook()) diff --git a/backend/src/agents/main_agent/chat_agent.py b/backend/src/agents/main_agent/chat_agent.py index 91ced9959..efeaaaf8c 100644 --- a/backend/src/agents/main_agent/chat_agent.py +++ b/backend/src/agents/main_agent/chat_agent.py @@ -90,6 +90,7 @@ async def stream_async( interrupt_responses: Optional[List[Dict[str, Any]]] = None, continue_truncated: bool = False, turn_agent_id: Optional[str] = None, + turn_lease: Any = None, ) -> AsyncGenerator[str, None]: """ Stream agent responses. @@ -116,6 +117,11 @@ async def stream_async( event loop re-runs against restored history whose tail is the truncated assistant message — the model continues it (assistant-prefill) instead of answering a new instruction. + turn_lease: This turn's single-flight `SessionLease`, which doubles + as the mid-turn steering inbox. Passed per turn rather than read + off the agent for the same reason as `turn_agent_id`: the agent + instance is cached across turns, so per-turn state must never + live on it (#741/#751). Yields: str: SSE formatted events @@ -149,5 +155,6 @@ async def stream_async( citations=citations, original_message=original_message, turn_agent_id=turn_agent_id, + turn_lease=turn_lease, ): yield event diff --git a/backend/src/agents/main_agent/core/agent_factory.py b/backend/src/agents/main_agent/core/agent_factory.py index 273069802..0d1f05366 100644 --- a/backend/src/agents/main_agent/core/agent_factory.py +++ b/backend/src/agents/main_agent/core/agent_factory.py @@ -12,7 +12,9 @@ from agents.main_agent.core.bedrock_count_tokens import CountTokensBedrockModel from agents.main_agent.core.model_config import ModelConfig, ModelProvider from agents.main_agent.config.constants import EnvVars +from apis.shared.models.bedrock_responses import build_bedrock_responses_model from apis.shared.models.mantle import build_mantle_model +from apis.shared.models.usage_normalization import usage_normalized logger = logging.getLogger(__name__) @@ -60,7 +62,11 @@ def _create_openai_model(model_config: ModelConfig) -> OpenAIModel: client_args = {"api_key": api_key} logger.info(f"Creating OpenAI model with model_id={model_config.model_id}") - return OpenAIModel(client_args=client_args, **openai_config) + # Wrapped for Bedrock-Converse token-bucket semantics — OpenAI's + # `input_tokens` is inclusive of the cache buckets, which our cost and + # context-size math treats as disjoint. See + # apis/shared/models/usage_normalization.py. + return usage_normalized(OpenAIModel)(client_args=client_args, **openai_config) @staticmethod def _create_mantle_model(model_config: ModelConfig): @@ -107,6 +113,43 @@ def _create_mantle_model(model_config: ModelConfig): params=mantle_config.get("params"), ) + @staticmethod + def _create_bedrock_responses_model(model_config: ModelConfig): + """ + Create an OpenAI Responses model on the bedrock-runtime endpoint + + The only Bedrock path that serves prompt caching for GPT-5.6. Same + OpenAI wire protocol as Mantle, different host and model-id shape + (cross-Region inference profiles). + + Args: + model_config: Model configuration + + Returns: + OpenAIResponsesModel: Configured model targeting bedrock-runtime + + Raises: + ValueError: If no AWS region can be resolved for the endpoint + """ + # Same precedence as the Mantle path: model override, then AWS_REGION. + # Unlike Mantle, an unresolvable region raises rather than defaulting — + # the builder owns that, so both the URL and the token signature stay + # on one value. + region = model_config.mantle_region or os.getenv(EnvVars.AWS_REGION) + responses_config = model_config.to_bedrock_responses_config() + logger.info( + f"Creating bedrock-runtime Responses model " + f"with model_id={model_config.model_id} " + f"region={region or ''}" + ) + # Shared builder — also used by the API-key /chat/api-converse handler + # (apis/app_api) so the transport construction is never forked. + return build_bedrock_responses_model( + model_id=responses_config["model_id"], + region=region, + params=responses_config.get("params"), + ) + @staticmethod def _create_gemini_model(model_config: ModelConfig) -> GeminiModel: """ @@ -173,6 +216,8 @@ def create_agent( model = AgentFactory._create_openai_model(model_config) elif provider == ModelProvider.MANTLE: model = AgentFactory._create_mantle_model(model_config) + elif provider == ModelProvider.BEDROCK_RESPONSES: + model = AgentFactory._create_bedrock_responses_model(model_config) elif provider == ModelProvider.GEMINI: model = AgentFactory._create_gemini_model(model_config) else: diff --git a/backend/src/agents/main_agent/core/model_config.py b/backend/src/agents/main_agent/core/model_config.py index a1027a8e1..13c4a5cbf 100644 --- a/backend/src/agents/main_agent/core/model_config.py +++ b/backend/src/agents/main_agent/core/model_config.py @@ -38,6 +38,17 @@ class ModelProvider(str, Enum): # bearer token, not the Converse API with SigV4. Never auto-detected from # model_id — admins set it explicitly on the managed model. MANTLE = "mantle" + # The OpenAI **Responses** API on `bedrock-runtime..amazonaws.com` + # — the second OpenAI-compatible Bedrock surface. Same wire protocol and + # bearer-token auth as MANTLE, different host, IAM and model-id shape + # (cross-Region inference profiles: `us.` / `global.`). + # + # It exists for one reason: GPT-5.6 serves prompt caching ONLY over the + # Responses API. Routing the same model over Converse (which + # `bedrock-runtime` also supports) would drop into the BEDROCK path with + # no caching at all — ~10x the input cost on a long stable prefix. + # Never auto-detected from model_id; admins set it on the managed model. + BEDROCK_RESPONSES = "bedrock-responses" # Canonical param name -> provider-native key path (dot-separated for nested SDK fields). @@ -293,11 +304,16 @@ class ModelConfig: # Completions vs Responses). Only consulted on the MANTLE provider path, # where the factory uses it to pick OpenAIModel vs OpenAIResponsesModel. mantle_api_mode: MantleApiMode = MantleApiMode.CHAT_COMPLETIONS - # Bedrock Mantle: optional AWS region override for the inference endpoint. + # Optional AWS region override for an OpenAI-compatible Bedrock endpoint. # ``None`` -> the agent's AWS_REGION. Lets a model pin inference to the # region where it's hosted (e.g. openai.gpt-5.x in us-east-1) independent - # of where the app runs. Drives both the Mantle base URL and the region - # the bearer token is signed for (via bedrock_mantle_config). + # of where the app runs. Drives both the base URL and the region the + # bearer token is signed for, on BOTH OpenAI-compatible surfaces — Mantle + # (via bedrock_mantle_config) and bedrock-runtime Responses. + # + # The attribute name is historical: Mantle was the only such surface when + # it was added. The wire/persisted field is already the transport-neutral + # `region`, so only this Python name lags. mantle_region: Optional[str] = None def get_provider(self) -> ModelProvider: @@ -480,6 +496,27 @@ def to_mantle_config(self) -> Dict[str, Any]: config["params"] = params return config + def to_bedrock_responses_config(self) -> Dict[str, Any]: + """Convert to OpenAI Responses kwargs for the bedrock-runtime surface. + + The Responses API's native param names are the same ones Mantle-Responses + uses — they belong to the API, not the transport — so the map is shared. + The builder supplies the client (base_url + per-request bearer token) + via ``client_args``; see ``apis.shared.models.bedrock_responses``. + """ + params: Dict[str, Any] = {} + _apply_canonical_params( + params, + self.inference_params, + _MANTLE_RESPONSES_PARAM_MAP, + "bedrock-responses", + self.model_id, + ) + config: Dict[str, Any] = {"model_id": self.model_id} + if params: + config["params"] = params + return config + def to_gemini_config(self) -> Dict[str, Any]: """Convert to GeminiModel kwargs, translating canonical inference params.""" params: Dict[str, Any] = {} @@ -517,7 +554,8 @@ def from_params( Args: model_id: Model ID (provider-specific format) caching_enabled: Whether to enable prompt caching (Bedrock only) - provider: Provider name ("bedrock", "openai", "gemini", or "mantle") + provider: Provider name ("bedrock", "openai", "gemini", "mantle", + or "bedrock-responses") inference_params: Canonical-name -> value map (temperature, top_p, max_tokens, thinking, ...). Each provider's translation table drops unsupported keys silently. diff --git a/backend/src/agents/main_agent/quota/repository.py b/backend/src/agents/main_agent/quota/repository.py index 07c757721..4baf3a483 100644 --- a/backend/src/agents/main_agent/quota/repository.py +++ b/backend/src/agents/main_agent/quota/repository.py @@ -8,6 +8,7 @@ import os from .models import QuotaTier, QuotaAssignment, QuotaEvent, QuotaAssignmentType, QuotaOverride from agents.main_agent.config.constants import EnvVars, Defaults +from apis.shared.security.log_sanitize import scrub_log logger = logging.getLogger(__name__) @@ -54,7 +55,7 @@ async def get_tier(self, tier_id: str) -> Optional[QuotaTier]: return QuotaTier(**item) except ClientError as e: - logger.error(f"Error getting tier {tier_id}: {e}") + logger.error(f"Error getting tier {scrub_log(tier_id)}: {scrub_log(e)}") return None async def list_tiers(self, enabled_only: bool = False) -> List[QuotaTier]: @@ -140,7 +141,7 @@ async def update_tier(self, tier_id: str, updates: dict) -> Optional[QuotaTier]: return QuotaTier(**item) except ClientError as e: - logger.error(f"Error updating tier {tier_id}: {e}") + logger.error(f"Error updating tier {scrub_log(tier_id)}: {scrub_log(e)}") return None async def delete_tier(self, tier_id: str) -> bool: @@ -154,7 +155,7 @@ async def delete_tier(self, tier_id: str) -> bool: ) return True except ClientError as e: - logger.error(f"Error deleting tier {tier_id}: {e}") + logger.error(f"Error deleting tier {scrub_log(tier_id)}: {scrub_log(e)}") return False # ========== Quota Assignments ========== @@ -179,7 +180,7 @@ async def get_assignment(self, assignment_id: str) -> Optional[QuotaAssignment]: return QuotaAssignment(**item) except ClientError as e: - logger.error(f"Error getting assignment {assignment_id}: {e}") + logger.error(f"Error getting assignment {scrub_log(assignment_id)}: {scrub_log(e)}") return None async def query_user_assignment(self, user_id: str) -> Optional[QuotaAssignment]: @@ -208,7 +209,7 @@ async def query_user_assignment(self, user_id: str) -> Optional[QuotaAssignment] return QuotaAssignment(**item) except ClientError as e: - logger.error(f"Error querying user assignment for {user_id}: {e}") + logger.error(f"Error querying user assignment for {scrub_log(user_id)}: {scrub_log(e)}") return None async def query_app_role_assignments(self, app_role_id: str) -> List[QuotaAssignment]: @@ -235,7 +236,7 @@ async def query_app_role_assignments(self, app_role_id: str) -> List[QuotaAssign return assignments except ClientError as e: - logger.error(f"Error querying app role assignments for {app_role_id}: {e}") + logger.error(f"Error querying app role assignments for {scrub_log(app_role_id)}: {scrub_log(e)}") return [] async def query_role_assignments(self, role: str) -> List[QuotaAssignment]: @@ -298,7 +299,7 @@ async def list_assignments_by_type( return assignments except ClientError as e: - logger.error(f"Error listing assignments for type {assignment_type}: {e}") + logger.error(f"Error listing assignments for type {scrub_log(assignment_type)}: {scrub_log(e)}") return [] async def list_all_assignments(self, enabled_only: bool = False) -> List[QuotaAssignment]: @@ -388,7 +389,7 @@ async def update_assignment(self, assignment_id: str, updates: dict) -> Optional return QuotaAssignment(**item) except ClientError as e: - logger.error(f"Error updating assignment {assignment_id}: {e}") + logger.error(f"Error updating assignment {scrub_log(assignment_id)}: {scrub_log(e)}") return None async def delete_assignment(self, assignment_id: str) -> bool: @@ -402,7 +403,7 @@ async def delete_assignment(self, assignment_id: str) -> bool: ) return True except ClientError as e: - logger.error(f"Error deleting assignment {assignment_id}: {e}") + logger.error(f"Error deleting assignment {scrub_log(assignment_id)}: {scrub_log(e)}") return False def _build_gsi_keys(self, assignment: QuotaAssignment) -> dict: @@ -478,7 +479,7 @@ async def get_user_events( return events except ClientError as e: - logger.error(f"Error getting events for user {user_id}: {e}") + logger.error(f"Error getting events for user {scrub_log(user_id)}: {scrub_log(e)}") return [] async def get_tier_events( @@ -512,7 +513,7 @@ async def get_tier_events( return events except ClientError as e: - logger.error(f"Error getting events for tier {tier_id}: {e}") + logger.error(f"Error getting events for tier {scrub_log(tier_id)}: {scrub_log(e)}") return [] async def get_recent_event( @@ -587,7 +588,7 @@ async def get_override(self, override_id: str) -> Optional[QuotaOverride]: return QuotaOverride(**item) except ClientError as e: - logger.error(f"Error getting override {override_id}: {e}") + logger.error(f"Error getting override {scrub_log(override_id)}: {scrub_log(e)}") return None async def get_active_override(self, user_id: str) -> Optional[QuotaOverride]: @@ -709,7 +710,7 @@ async def update_override(self, override_id: str, updates: dict) -> Optional[Quo return QuotaOverride(**item) except ClientError as e: - logger.error(f"Error updating override {override_id}: {e}") + logger.error(f"Error updating override {scrub_log(override_id)}: {scrub_log(e)}") return None async def delete_override(self, override_id: str) -> bool: @@ -723,5 +724,5 @@ async def delete_override(self, override_id: str) -> bool: ) return True except ClientError as e: - logger.error(f"Error deleting override {override_id}: {e}") + logger.error(f"Error deleting override {scrub_log(override_id)}: {scrub_log(e)}") return False diff --git a/backend/src/agents/main_agent/quota/resolver.py b/backend/src/agents/main_agent/quota/resolver.py index 717e2547d..45d82056f 100644 --- a/backend/src/agents/main_agent/quota/resolver.py +++ b/backend/src/agents/main_agent/quota/resolver.py @@ -6,6 +6,7 @@ from apis.shared.auth.models import User from .models import QuotaTier, QuotaAssignment, ResolvedQuota from .repository import QuotaRepository +from apis.shared.security.log_sanitize import scrub_log logger = logging.getLogger(__name__) @@ -208,7 +209,7 @@ def invalidate_cache(self, user_id: Optional[str] = None): keys_to_remove = [k for k in self._cache.keys() if k.startswith(f"{user_id}:")] for key in keys_to_remove: del self._cache[key] - logger.info(f"Invalidated cache for user {user_id}") + logger.info(f"Invalidated cache for user {scrub_log(user_id)}") else: # Clear entire cache self._cache.clear() diff --git a/backend/src/agents/main_agent/session/hooks/__init__.py b/backend/src/agents/main_agent/session/hooks/__init__.py index 017112a59..840944ff0 100644 --- a/backend/src/agents/main_agent/session/hooks/__init__.py +++ b/backend/src/agents/main_agent/session/hooks/__init__.py @@ -3,6 +3,7 @@ from agents.main_agent.session.hooks.context_attribution import ContextAttributionHook from agents.main_agent.session.hooks.oauth_consent import OAuthConsentHook from agents.main_agent.session.hooks.prefix_fingerprint import PrefixFingerprintHook +from agents.main_agent.session.hooks.steering import SteeringHook from agents.main_agent.session.hooks.stop import StopHook from agents.main_agent.session.hooks.tool_approval import MCPExternalApprovalHook @@ -10,6 +11,7 @@ "ContextAttributionHook", "OAuthConsentHook", "PrefixFingerprintHook", + "SteeringHook", "StopHook", "MCPExternalApprovalHook", ] diff --git a/backend/src/agents/main_agent/session/hooks/steering.py b/backend/src/agents/main_agent/session/hooks/steering.py new file mode 100644 index 000000000..ddb448bf4 --- /dev/null +++ b/backend/src/agents/main_agent/session/hooks/steering.py @@ -0,0 +1,175 @@ +"""Mid-turn steering: inject a queued follow-up at the next tool boundary. + +See ``docs/specs/mid-turn-steering.md``. PR #916 made Enter mean "say this" +while a response streams, but the follow-up sits in the composer until the turn +ends. This hook lands it at the next tool boundary instead, so the agent reads +it *before* choosing its next action — the user who sees the wrong file being +opened no longer has to choose between paying for the rest of a doomed turn and +stopping it (which discards a partial generation and re-establishes the prefix, +the more expensive of the two). + +Two Strands events, and the split between them is the whole correctness story: + +``AfterToolsEvent`` + Fires with the assembled tool-result message **before** it is appended to + history, so appending a ``{"text": ...}`` block puts the user's words into + the same user-role message that carries the tool results. Valid Bedrock + Converse shape, persists through the normal ``append_message`` path, and + append-only against the cached prefix — the injection lands inside the + segment the ``strategy="auto"`` message-level cachePoint covers, behind + both static points, so the next model call still reads the stable prefix + from cache. + +``MessageAddedEvent`` + Fires from ``Agent._append_messages``, which is the first point at which + the injection is really in the conversation. Only there is the inbox entry + consumed. + +That split exists because ``AfterToolsEvent`` fires from a ``finally`` and so +*also* fires on the cancel, error, and interrupt paths. On the interrupt path +``_stop_for_interrupts`` runs and ``_append_messages`` is never reached: the +message this hook just mutated is discarded. A hook that consumed on read would +therefore destroy the user's words on every steer that happened to land on the +same tool batch as an OAuth consent or an approval prompt — silent data loss, +low frequency, very hard to reproduce. So the hook peeks, and commits on +append. If the turn ends with entries unconsumed, the lease row is deleted with +the lease and the SPA's un-acked queue entries flush the PR #916 way. + +**SDK-boundary caveat.** ``HookEvent.__setattr__`` is write-guarded by +``_can_write``, which for ``AfterToolsEvent`` allows only ``end_turn``. +Mutating the message dict *in place* is not blocked, but it is also not +explicitly sanctioned. ``tests/agents/main_agent/session/test_steering_hook.py`` +carries a contract test that asserts the mutation still reaches +``agent.messages``; it is the canary for a ``strands-agents`` bump. +""" + +from __future__ import annotations + +import logging +from typing import Any, List, Optional + +from strands.hooks import AfterToolsEvent, HookProvider, HookRegistry, MessageAddedEvent + +logger = logging.getLogger(__name__) + +# The injected text is framed so the model reads it as the user speaking during +# the turn, rather than as tool output or as its own scratch notes. +STEER_OPEN_TAG = "" +STEER_CLOSE_TAG = "" + + +def wrap_steering_text(texts: List[str]) -> str: + """Frame queued follow-ups as one tagged block, in arrival order.""" + body = "\n\n".join(text.strip() for text in texts if text and text.strip()) + return f"{STEER_OPEN_TAG}\n{body}\n{STEER_CLOSE_TAG}" + + +class SteeringHook(HookProvider): + """Inject queued follow-ups into the running turn at each tool boundary. + + Holds no per-turn state beyond the in-flight injection it must ack: the + lease is read off the session manager on every boundary (per the CLAUDE.md + rule that per-session state is never cached on an agent instance — the + cached agent outlives the turn, and an ``@``-mention turn builds a second + ``Agent`` with its own manager and its own hook). + + Fail-soft in every direction. Any error degrades to PR #916's end-of-turn + flush; the user's text is either injected exactly once or sent as a normal + turn, never both and never neither. + """ + + def __init__(self, session_manager: Any): + self.session_manager = session_manager + # The message object this hook last mutated, and the entry ids inside + # it. Identity, not equality: the dict Strands appends is the same + # object we appended to, and a discarded (interrupt-path) message is + # simply overwritten by the next boundary's injection. + self._pending_message: Optional[dict] = None + self._pending_entry_ids: List[dict] = [] + # Entry ids whose injection is confirmed in history, drained by the + # stream coordinator to emit `steering_applied`. + self._applied: List[dict] = [] + + def register_hooks(self, registry: HookRegistry, **kwargs: Any) -> None: + registry.add_callback(AfterToolsEvent, self.inject_pending_steering) + registry.add_callback(MessageAddedEvent, self.commit_pending_steering) + + def drain_applied(self) -> List[dict]: + """Take the injections confirmed since the last drain.""" + applied, self._applied = self._applied, [] + return applied + + # -- injection --------------------------------------------------------- + + async def inject_pending_steering(self, event: AfterToolsEvent) -> None: + """Append the user's queued follow-ups to this batch's tool results.""" + from apis.shared.feature_flags import mid_turn_steering_enabled + + if not mid_turn_steering_enabled(): + return + + content = event.message.get("content") + if not content: + # Nothing was committed this batch (cancelled before any tool ran). + # There is no message to ride, and one will not be appended. + return + + lease = getattr(self.session_manager, "turn_lease", None) + if lease is None: + return + + try: + from apis.shared.sessions.session_lease import peek_steer_queue + + entries = await peek_steer_queue(lease) + except Exception: + logger.warning("Steering peek failed; leaving the follow-up queued", exc_info=True) + return + + if not entries: + return + + texts = [str(entry.get("text", "")) for entry in entries] + content.append({"text": wrap_steering_text(texts)}) + + self._pending_message = event.message + self._pending_entry_ids = [ + {"id": str(entry.get("id")), "text": str(entry.get("text", ""))} + for entry in entries + ] + logger.info( + "Injected %d steering message(s) at a tool boundary for session %s", + len(entries), + lease.session_id, + ) + + # -- commit ------------------------------------------------------------ + + async def commit_pending_steering(self, event: MessageAddedEvent) -> None: + """Consume the inbox entries now that their message is in history. + + Identity-matched against the message this hook mutated: every other + message added this turn (the user's own turn, assistant messages, tool + results with no injection) falls through untouched. + """ + if self._pending_message is None or event.message is not self._pending_message: + return + + pending, self._pending_message = self._pending_entry_ids, None + self._pending_entry_ids = [] + + lease = getattr(self.session_manager, "turn_lease", None) + if lease is None: + return + + from apis.shared.sessions.session_lease import clear_steer_entry + + for entry in pending: + try: + await clear_steer_entry(lease, entry["id"]) + except Exception: + # A failed clear re-delivers at the next boundary; the entry id + # makes the SPA's ack idempotent. Losing the text would not be. + logger.warning("Steering entry clear failed", exc_info=True) + continue + self._applied.append(entry) diff --git a/backend/src/agents/main_agent/session/tests/test_history_repair.py b/backend/src/agents/main_agent/session/tests/test_history_repair.py index 0b68b8f9d..ba579e9e4 100644 --- a/backend/src/agents/main_agent/session/tests/test_history_repair.py +++ b/backend/src/agents/main_agent/session/tests/test_history_repair.py @@ -244,3 +244,85 @@ def test_wrapper_noop_on_healthy_history(self, monkeypatch): agent = _FakeAgent(healthy) _make_manager()._repair_restored_history(agent) assert agent.messages is healthy # identity preserved, no rebuild + + +class TestSteeringInjectionSurvivesRepair: + """A mid-turn steering block rides the tool-result message (see + docs/specs/mid-turn-steering.md), so a repair pass now sees a user turn + with mixed ``toolResult`` + ``text`` content. + + Both helpers key on tool pairing, so neither treats the mixed message as a + violation — but the rebuild path synthesizes result turns from the id map + rather than copying them, which would drop the user's words along with + everything else that isn't a toolResult. These are the tests that keep + that from regressing: losing the text deletes something the user said and + the model already read. + """ + + @staticmethod + def _steered_res(*ids, text="actually, check the other file"): + msg = _res(*ids) + msg["content"].append({"text": text}) + return msg + + def test_healthy_history_with_an_injection_is_untouched(self): + healthy = [_txt("user"), _use("a"), self._steered_res("a"), _txt("assistant")] + repaired, violations = T._repair_tool_pairing(healthy) + assert violations == 0 + assert repaired is healthy # identity: no rebuild, nothing to strip + + def test_injection_survives_a_rebuild(self): + # The observed corruption shape: a duplicate result turn (parallel + # tool calls interrupted by a Stop) forces the rebuild path over a + # turn whose result message carries an injection. + msgs = [ + _txt("user"), + _use("a"), + self._steered_res("a"), + _res("a"), + _txt("assistant"), + ] + repaired, violations = T._repair_tool_pairing(msgs) + + assert violations > 0 + ok, why = _is_valid(repaired) + assert ok, why + texts = [ + block["text"] + for msg in repaired + for block in msg["content"] + if "text" in block + ] + # Present, and exactly once: a re-emitted residual would show the user + # saying the same thing twice. + assert texts.count("actually, check the other file") == 1 + + def test_injection_is_ordered_after_the_tool_results(self): + """Bedrock wants toolResults leading their user turn; the injection + follows them, which is exactly where the hook appends it live.""" + msgs = [ + _txt("user"), + _use("a", "b"), + self._steered_res("a", "b"), + _res("a", "b"), + _txt("assistant"), + ] + repaired, _ = T._repair_tool_pairing(msgs) + + result_turn = next( + m + for m in repaired + if m["role"] == "user" + and any("toolResult" in b for b in m["content"]) + ) + keys = ["toolResult" if "toolResult" in b else "text" for b in result_turn["content"]] + assert keys == ["toolResult", "toolResult", "text"] + + def test_ordinary_result_turns_gain_nothing(self): + """The carry-across must not invent content on unsteered turns.""" + msgs = [_txt("user"), _use("a"), _res("a"), _res("a"), _txt("assistant")] + repaired, _ = T._repair_tool_pairing(msgs) + + for msg in repaired: + for block in msg["content"]: + assert "text" not in block or msg["role"] != "user" or "hi" in block.get("text", "") diff --git a/backend/src/agents/main_agent/session/turn_based_session_manager.py b/backend/src/agents/main_agent/session/turn_based_session_manager.py index b9dd5bdb3..5f8227d73 100644 --- a/backend/src/agents/main_agent/session/turn_based_session_manager.py +++ b/backend/src/agents/main_agent/session/turn_based_session_manager.py @@ -105,6 +105,14 @@ def __init__( # Session control self.cancelled = False + # This turn's single-flight lease, which doubles as the mid-turn + # steering inbox (docs/specs/mid-turn-steering.md). Per-turn state, so + # the stream coordinator stamps it at the head of every turn — never + # loaded once and held, per the CLAUDE.md rule about state on a cached + # agent. None whenever the guard is inactive (preview sessions, local + # dev without DynamoDB), which makes steering inert. + self.turn_lease: Optional[Any] = None + # Message count tracking (for stream_coordinator compatibility) self.message_count: int = 0 @@ -501,8 +509,8 @@ def _adopt_persisted_compaction_state(self) -> None: anchor is what keeps the restored prefix byte-stable (see the prompt-cache contract in ``CLAUDE.md``). Moving it backwards re-truncates messages that were previously sent whole, which rewrites the prefix — a full cache write - at the $2.50/MTok premium over a 35k–150k-token prefix, on a turn where - nothing about the conversation appeared to change. + over a 35k–150k-token prefix at 1.25x the model's base input rate, on a + turn where nothing about the conversation appeared to change. Re-reading rather than sharing the state object: the sibling may live in another replica, where object aliasing (#750's fix for the message list) @@ -1124,12 +1132,19 @@ def missing_result(tid: str) -> Dict: # are re-emitted from the map at the correct slot). rebuilt: List[Dict] = [] last_index = len(messages) - 1 + # Result turns whose non-toolResult content was already carried onto a + # rebuilt turn below. Populated one iteration ahead of its use (i adds + # i+1), so the standalone branch does not re-emit the same block and + # duplicate a mid-turn steering injection. + carried_residual_indices: set = set() for i, msg in enumerate(messages): has_use, has_result = cls._block_keys(msg) if has_result and not has_use: # Standalone tool-result turn: keep only non-toolResult content # (rare mixed text), drop the results themselves. + if i in carried_residual_indices: + continue residual = [b for b in msg.get("content", []) if not (isinstance(b, dict) and "toolResult" in b)] if residual: rebuilt.append({"role": msg.get("role", "user"), "content": residual}) @@ -1138,10 +1153,19 @@ def missing_result(tid: str) -> Dict: if has_use and i < last_index: rebuilt.append(msg) use_ids = cls._tool_use_ids(msg) - rebuilt.append({ - "role": "user", - "content": [result_by_id.get(t, missing_result(t)) for t in use_ids], - }) + # The result turn is rebuilt from the id map rather than + # copied, so any NON-toolResult block on the original result + # turn would be silently dropped. That block is not always + # incidental: mid-turn steering appends the user's own words + # to the tool-result message (docs/specs/mid-turn-steering.md), + # and a repair that discards them deletes something the user + # said and the model already read. Carry them across. + content = [result_by_id.get(t, missing_result(t)) for t in use_ids] + residual = cls._non_tool_result_blocks(messages, i + 1) + if residual: + content.extend(residual) + carried_residual_indices.add(i + 1) + rebuilt.append({"role": "user", "content": content}) continue # Trailing tool-use turn (last message) or any non-tool turn: emit @@ -1173,6 +1197,25 @@ def missing_result(tid: str) -> Dict: return merged, violations + @staticmethod + def _non_tool_result_blocks(messages: List[Dict], index: int) -> List[Dict]: + """Non-toolResult content on the user turn at ``index``, if it is one. + + Used by the repair rebuild to carry a mid-turn steering injection (a + ``{"text": ...}`` block riding the tool-result message) onto the + rebuilt result turn. Returns ``[]`` for anything that is not a + result-bearing user turn, so ordinary histories are unaffected. + """ + if index >= len(messages): + return [] + candidate = messages[index] + if candidate.get("role") != "user": + return [] + content = candidate.get("content") or [] + if not any(isinstance(b, dict) and "toolResult" in b for b in content): + return [] + return [b for b in content if not (isinstance(b, dict) and "toolResult" in b)] + def _truncate_tool_contents( self, messages: List[Dict], diff --git a/backend/src/agents/main_agent/streaming/stream_coordinator.py b/backend/src/agents/main_agent/streaming/stream_coordinator.py index f00e8b17e..ac644d2e2 100644 --- a/backend/src/agents/main_agent/streaming/stream_coordinator.py +++ b/backend/src/agents/main_agent/streaming/stream_coordinator.py @@ -221,6 +221,7 @@ async def stream_response( citations: Optional[List] = None, original_message: Optional[str] = None, turn_agent_id: Optional[str] = None, + turn_lease: Any = None, ) -> AsyncGenerator[str, None]: """ Stream agent responses with proper lifecycle management @@ -242,6 +243,12 @@ async def stream_response( nondeterministic-ordering regression the fingerprints exist to catch. Passed per turn rather than read off the agent: the agent instance is cached and shared across turns, so per-turn state must never live on it (#741/#751). + turn_lease: This turn's single-flight ``SessionLease``, which doubles as the + mid-turn steering inbox. Stamped onto the session manager for the life of + the turn so ``SteeringHook`` can read it at each tool boundary — and + stamped *unconditionally*, including to None, for the same reason + ``reset_cancellation_state`` exists: a lease left behind by a previous + turn on a cached agent would be read against a row that no longer names us. Yields: str: SSE formatted events @@ -259,6 +266,12 @@ async def stream_response( # brick this one. See ``reset_cancellation_state``. reset_cancellation_state(agent, session_manager) + # This turn's steering inbox handle. Set unconditionally (None included) + # so a lease from a previous turn on the cached agent can never be read + # against a row a later turn now owns. + if session_manager is not None: + session_manager.turn_lease = turn_lease + # Likewise a pause armed by a previous turn: if the user abandoned an # OAuth/tool-approval consent and just typed again, the still-armed # interrupt state makes Strands reject this turn's prompt outright. @@ -706,6 +719,11 @@ async def stream_response( # cacheReadInputTokens. Summing all three buckets # below is the only correct "current context size" # under caching. + # + # The sum is only correct because the buckets are + # disjoint. OpenAI-family models report an inclusive + # inputTokens and are normalized to this convention at + # the model seam — apis/shared/models/usage_normalization.py. if hasattr(session_manager, "update_after_turn"): usage = accumulated_metadata.get("usage", {}) total_input_tokens = ( @@ -929,6 +947,20 @@ async def stream_response( # Skip the original error event and exit the loop - we've handled the error return + # Mid-turn steering: ack any follow-up the SteeringHook + # injected at a tool boundary and has since confirmed in + # history. Drained *before* this event is yielded rather than + # after, so an injection confirmed on the turn's final tool + # batch still lands ahead of `done` — the SPA gates events on + # the stream state and drops anything past it. Ordering is + # unaffected for every other case: the frame still follows the + # `tool_result` events of the batch it rode. + # See docs/specs/mid-turn-steering.md. + for steering_sse in self._drain_steering_events( + main_agent_wrapper, session_id + ): + yield steering_sse + # Format as SSE event and yield (including done event after metadata) sse_event = self._format_sse_event(event) yield sse_event @@ -2104,6 +2136,44 @@ def _emit_tool_input_partial( logger.warning("Failed to emit ui_tool_input_partial event: %s", e) return [] + def _drain_steering_events( + self, main_agent_wrapper: Any, session_id: str + ) -> List[str]: + """Emit one `steering_applied` SSE per confirmed mid-turn injection. + + Drained rather than pushed because the hook runs inside Strands' event + loop, which has no route to the SSE stream. An entry appears here only + once its carrying message is in history *and* its inbox entry is + cleared — so the event is the client's signal that the follow-up is + genuinely in the conversation and its queued composer entry can be + dropped without risk of the text being sent twice. + + Best-effort: a wrapper without a steering hook (voice, tests) and any + failure both yield nothing, leaving the entry queued for PR #916's + end-of-turn flush. + """ + hook = getattr(main_agent_wrapper, "steering_hook", None) + if hook is None: + return [] + try: + applied = hook.drain_applied() + except Exception: # noqa: BLE001 - never break the stream on an ack + logger.warning("Steering ack drain failed", exc_info=True) + return [] + + events = [] + for entry in applied: + payload = { + "type": "steering_applied", + "sessionId": session_id, + "entryId": entry.get("id"), + "text": entry.get("text", ""), + } + events.append( + f"event: steering_applied\ndata: {json.dumps(payload)}\n\n" + ) + return events + def _format_sse_event(self, event: Dict[str, Any]) -> str: """ Format processed event as SSE (Server-Sent Event) diff --git a/backend/src/agents/main_agent/tools/gateway_integration.py b/backend/src/agents/main_agent/tools/gateway_integration.py index 149e1c0fe..3391da788 100644 --- a/backend/src/agents/main_agent/tools/gateway_integration.py +++ b/backend/src/agents/main_agent/tools/gateway_integration.py @@ -8,6 +8,7 @@ get_gateway_client_if_enabled, ) from apis.shared.tools.scoped_ids import parse_scoped_tool_id +from apis.shared.security.log_sanitize import scrub_log logger = logging.getLogger(__name__) @@ -59,7 +60,7 @@ async def expand_gateway_tool_ids( else: logger.warning( "Cannot resolve scoped gateway tool '%s' (no target_name); skipping", - tool_id, + scrub_log(tool_id), ) continue diff --git a/backend/src/agents/main_agent/voice_agent.py b/backend/src/agents/main_agent/voice_agent.py index 6fef5b09b..41da05588 100644 --- a/backend/src/agents/main_agent/voice_agent.py +++ b/backend/src/agents/main_agent/voice_agent.py @@ -343,8 +343,10 @@ async def stream_async( original_message: Optional[str] = None, interrupt_responses: Optional[List] = None, # Accepted only to satisfy the base signature. Voice has no `@`-mention - # surface, so there is no turn-scoped Agent to record (#756). + # surface, so there is no turn-scoped Agent to record (#756), and no + # composer to steer from mid-turn. turn_agent_id: Optional[str] = None, + turn_lease: Any = None, ) -> AsyncGenerator[str, None]: """ BaseAgent interface compatibility — not used for voice mode. diff --git a/backend/src/apis/app_api/admin/announcements/__init__.py b/backend/src/apis/app_api/admin/announcements/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/src/apis/app_api/admin/announcements/routes.py b/backend/src/apis/app_api/admin/announcements/routes.py new file mode 100644 index 000000000..4e8d3f303 --- /dev/null +++ b/backend/src/apis/app_api/admin/announcements/routes.py @@ -0,0 +1,232 @@ +"""Admin API routes for feature announcements. + +Authoring, scheduling, and lifecycle for the notices users see, plus the +reach numbers for one. The user-facing counterpart is ``GET /announcements`` +and its ack endpoint in ``apis/app_api/announcements/``. + +See ``docs/specs/feature-announcements.md`` §6. +""" + +import logging +from typing import List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, status + +from apis.shared.announcements.models import ( + AnnouncementCreate, + AnnouncementState, + AnnouncementListResponse, + AnnouncementResponse, + AnnouncementStatsResponse, + AnnouncementUpdate, +) +from apis.shared.announcements.service import get_announcements_service +from apis.shared.auth import User, require_admin_scope + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/announcements", tags=["admin-announcements"]) + +# Every route in this package is guarded by this one scope, so the +# permission boundary is the package boundary. Enforced by +# tests/architecture/test_admin_scope_coverage.py. +require_announcements_admin = require_admin_scope("admin.announcements") + + +def _not_found(announcement_id: str) -> HTTPException: + return HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Announcement '{announcement_id}' not found", + ) + + +@router.get( + "/", + response_model=AnnouncementListResponse, + summary="List announcements", +) +async def list_announcements( + state: Optional[List[AnnouncementState]] = Query( + None, description="Filter to these states (repeatable)" + ), + admin_user: User = Depends(require_announcements_admin), +) -> AnnouncementListResponse: + """List every announcement, in every state — drafts included.""" + service = get_announcements_service() + announcements = await service.list_announcements(states=state) + return AnnouncementListResponse( + announcements=[ + AnnouncementResponse.from_announcement(a) for a in announcements + ], + total=len(announcements), + ) + + +@router.get( + "/{announcement_id}", + response_model=AnnouncementResponse, + summary="Get an announcement", +) +async def get_announcement( + announcement_id: str, + admin_user: User = Depends(require_announcements_admin), +) -> AnnouncementResponse: + service = get_announcements_service() + announcement = await service.get_announcement(announcement_id) + if not announcement: + raise _not_found(announcement_id) + return AnnouncementResponse.from_announcement(announcement) + + +@router.post( + "/", + response_model=AnnouncementResponse, + status_code=status.HTTP_201_CREATED, + summary="Create an announcement", +) +async def create_announcement( + data: AnnouncementCreate, + admin_user: User = Depends(require_announcements_admin), +) -> AnnouncementResponse: + """Create an announcement. Defaults to ``draft`` — publishing is its own + action, so an in-progress edit is never live.""" + try: + service = get_announcements_service() + announcement = await service.create_announcement( + data, created_by=admin_user.email + ) + return AnnouncementResponse.from_announcement(announcement) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) + + +@router.patch( + "/{announcement_id}", + response_model=AnnouncementResponse, + summary="Update an announcement", +) +async def update_announcement( + announcement_id: str, + updates: AnnouncementUpdate, + admin_user: User = Depends(require_announcements_admin), +) -> AnnouncementResponse: + """Edit body/title/targeting. ``revision`` is untouched, so an edit does + **not** re-show the announcement to anyone who already dismissed it — that + is what ``/revise`` is for (§D4).""" + try: + service = get_announcements_service() + announcement = await service.update_announcement(announcement_id, updates) + if not announcement: + raise _not_found(announcement_id) + return AnnouncementResponse.from_announcement(announcement) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) + + +@router.post( + "/{announcement_id}/publish", + response_model=AnnouncementResponse, + summary="Publish an announcement", +) +async def publish_announcement( + announcement_id: str, + admin_user: User = Depends(require_announcements_admin), +) -> AnnouncementResponse: + try: + service = get_announcements_service() + announcement = await service.publish(announcement_id) + if not announcement: + raise _not_found(announcement_id) + return AnnouncementResponse.from_announcement(announcement) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) + + +@router.post( + "/{announcement_id}/archive", + response_model=AnnouncementResponse, + summary="Archive an announcement", +) +async def archive_announcement( + announcement_id: str, + admin_user: User = Depends(require_announcements_admin), +) -> AnnouncementResponse: + """Stop showing it. Acknowledgements are kept.""" + service = get_announcements_service() + announcement = await service.archive(announcement_id) + if not announcement: + raise _not_found(announcement_id) + return AnnouncementResponse.from_announcement(announcement) + + +@router.post( + "/{announcement_id}/revise", + response_model=AnnouncementResponse, + summary='Bump the revision ("show this again")', +) +async def revise_announcement( + announcement_id: str, + admin_user: User = Depends(require_announcements_admin), +) -> AnnouncementResponse: + """Increment ``revision``, so everyone's suppression lapses at once (§D4). + + The old acks stay readable under their own revision, which is what lets the + panel mark the entry *Updated* rather than plain unread. + """ + service = get_announcements_service() + announcement = await service.revise(announcement_id) + if not announcement: + raise _not_found(announcement_id) + return AnnouncementResponse.from_announcement(announcement) + + +@router.delete( + "/{announcement_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete an announcement", +) +async def delete_announcement( + announcement_id: str, + admin_user: User = Depends(require_announcements_admin), +) -> None: + service = get_announcements_service() + deleted = await service.delete_announcement(announcement_id) + if not deleted: + raise _not_found(announcement_id) + + +@router.get( + "/{announcement_id}/stats", + response_model=AnnouncementStatsResponse, + summary="Reach for one announcement", +) +async def get_announcement_stats( + announcement_id: str, + admin_user: User = Depends(require_announcements_admin), +) -> AnnouncementStatsResponse: + """Funnel counts for the announcement's **current** revision. + + ``seen``/``dismissed``/``acknowledged`` are cumulative, not disjoint — a + user who acknowledged is counted in all three, because the stored rank + only ever rises through them (§D2). + + Every number is approximate and the UI must say so (§11): the counters are + incremented on a second write after the ack lands, and ``targeted`` is a + denominator that moves as people join and roles change. ``targeted`` is + null when the audience is role-scoped rather than everyone — that means + "not estimated", not zero. + """ + service = get_announcements_service() + stats = await service.get_stats(announcement_id) + if stats is None: + raise _not_found(announcement_id) + return stats diff --git a/backend/src/apis/app_api/admin/fine_tuning/models.py b/backend/src/apis/app_api/admin/fine_tuning/models.py index ccca85112..fde3ea820 100644 --- a/backend/src/apis/app_api/admin/fine_tuning/models.py +++ b/backend/src/apis/app_api/admin/fine_tuning/models.py @@ -3,17 +3,18 @@ from pydantic import BaseModel, Field from typing import List from apis.app_api.fine_tuning.models import FineTuningAccessGrant +from apis.app_api.fine_tuning.repository import DEFAULT_QUOTA_USD class GrantAccessRequest(BaseModel): """Request body for granting fine-tuning access.""" email: str - monthly_quota_hours: float = Field(default=10.0, gt=0) + monthly_quota_usd: float = Field(default=DEFAULT_QUOTA_USD, gt=0) class UpdateQuotaRequest(BaseModel): - """Request body for updating a user's GPU-hour quota.""" - monthly_quota_hours: float = Field(gt=0) + """Request body for updating a user's monthly dollar quota.""" + monthly_quota_usd: float = Field(gt=0) class AccessListResponse(BaseModel): diff --git a/backend/src/apis/app_api/admin/fine_tuning/routes.py b/backend/src/apis/app_api/admin/fine_tuning/routes.py index f19388503..9f1de32cd 100644 --- a/backend/src/apis/app_api/admin/fine_tuning/routes.py +++ b/backend/src/apis/app_api/admin/fine_tuning/routes.py @@ -91,7 +91,7 @@ async def grant_access( grant = repo.grant_access( email=request.email, granted_by=admin_user.email, - monthly_quota_hours=request.monthly_quota_hours, + monthly_quota_usd=request.monthly_quota_usd, ) return FineTuningAccessGrant(**grant) except ValueError as e: @@ -126,11 +126,11 @@ async def update_quota( admin_user: User = Depends(require_fine_tuning_admin), repo: FineTuningAccessRepository = Depends(get_repository), ): - """Update GPU-hour quota for a user (admin only).""" + """Update the monthly dollar quota for a user (admin only).""" logger.info("Admin updating fine-tuning quota") try: - result = repo.update_quota(email, request.monthly_quota_hours) + result = repo.update_quota(email, request.monthly_quota_usd) if result is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, diff --git a/backend/src/apis/app_api/admin/routes.py b/backend/src/apis/app_api/admin/routes.py index e16cf5934..470202c93 100644 --- a/backend/src/apis/app_api/admin/routes.py +++ b/backend/src/apis/app_api/admin/routes.py @@ -30,7 +30,7 @@ ModelRoleAssignment, ) from apis.shared.auth import User, require_admin_scope -from apis.shared.feature_flags import skills_enabled +from apis.shared.feature_flags import announcements_enabled, skills_enabled from apis.shared.models.managed_models import ( create_managed_model, get_managed_model, @@ -942,6 +942,15 @@ async def get_managed_model_roles( router.include_router(system_prompts_admin_router) +# ========== Include Announcements Admin Subrouter (conditional) ========== +# Default ON with a kill switch. While ANNOUNCEMENTS_ENABLED=false the admin +# authoring API is unmounted so the surface 404s, but the data and code remain +# intact (the SKILLS_ENABLED mount pattern). +if announcements_enabled(): + from .announcements.routes import router as announcements_admin_router + + router.include_router(announcements_admin_router) + # ========== Include Fine-Tuning Admin Subrouter (conditional) ========== if os.environ.get("FINE_TUNING_ENABLED", "false").lower() == "true": from .fine_tuning.routes import router as fine_tuning_admin_router diff --git a/backend/src/apis/app_api/agent_designer/routes.py b/backend/src/apis/app_api/agent_designer/routes.py index 7850ae83c..3ffa2e0c9 100644 --- a/backend/src/apis/app_api/agent_designer/routes.py +++ b/backend/src/apis/app_api/agent_designer/routes.py @@ -355,7 +355,7 @@ async def list_bindable_endpoint( except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: - logger.error(f"Error listing bindable '{kind}': {e}", exc_info=True) + logger.error(f"Error listing bindable '{scrub_log(kind)}': {scrub_log(e)}", exc_info=True) raise HTTPException(status_code=500, detail=f"Failed to list bindable '{kind}': {str(e)}") diff --git a/backend/src/apis/app_api/announcements/__init__.py b/backend/src/apis/app_api/announcements/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/src/apis/app_api/announcements/routes.py b/backend/src/apis/app_api/announcements/routes.py new file mode 100644 index 000000000..930194612 --- /dev/null +++ b/backend/src/apis/app_api/announcements/routes.py @@ -0,0 +1,147 @@ +"""User-facing announcement surface: read the feed, record an acknowledgement. + +Cookie session auth on every route (CLAUDE.md's rule — the SPA sends an +httpOnly session cookie, and a Bearer-only dependency here would 401 into the +centralized redirect loop). + +Admin authoring lives at ``/admin/announcements``. See +``docs/specs/feature-announcements.md`` §6. +""" + +import logging +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Response, status + +from apis.shared.announcements.models import ( + AnnouncementAckRequest, + AnnouncementFeedResponse, + UserAnnouncement, +) +from apis.shared.announcements.service import get_announcements_service +from apis.shared.announcements.visibility import VisibleAnnouncement +from apis.shared.auth import User, get_current_user_from_session +from apis.shared.feature_flags import announcements_enabled +from apis.shared.users.repository import UserRepository + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/announcements", tags=["announcements"]) + + +async def require_announcements_user( + user: User = Depends(get_current_user_from_session), +) -> User: + """Cookie auth plus the environment kill switch. + + 404 while ``ANNOUNCEMENTS_ENABLED`` is off, so the surface behaves as if it + were never mounted (the ``memory_spaces`` / ``schedules`` pattern). Checked + per request rather than at import so a test can flip the flag without a + module reload. + """ + if not announcements_enabled(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found") + return user + + +async def _user_created_at(user_id: str) -> Optional[str]: + """This user's signup timestamp, for the new-user suppression rule (§D6). + + Returns None if the profile is missing or the lookup fails, which + ``compute_feed`` reads as "treat them as an existing user and show the + announcement". Failing toward showing a message is recoverable; failing + toward silence is not — and a directory blip must not decide what a user + reads. + """ + try: + repo = UserRepository() + if not repo.enabled: + return None + profile = await repo.get_user(user_id) + return profile.created_at if profile else None + except Exception: + logger.warning( + "Could not read created_at for %s; treating as an existing user", + user_id, + exc_info=True, + ) + return None + + +def _to_response(visible: Optional[VisibleAnnouncement]) -> Optional[UserAnnouncement]: + if visible is None: + return None + return UserAnnouncement.from_announcement( + visible.announcement, + is_unread=visible.is_unread, + is_updated=visible.is_updated, + ) + + +@router.get( + "/", + response_model=AnnouncementFeedResponse, + summary="Announcements visible to the current user", +) +async def get_announcements( + current_user: User = Depends(require_announcements_user), +) -> AnnouncementFeedResponse: + """Return only what this user should see, already filtered and capped. + + The client renders this; it does not evaluate targeting, dates, or ack + state (§D5). + """ + service = get_announcements_service() + feed = await service.build_feed( + user_id=current_user.user_id, + user_roles=current_user.roles or [], + user_created_at=await _user_created_at(current_user.user_id), + ) + return AnnouncementFeedResponse( + panel=[_to_response(v) for v in feed.panel], + banner=_to_response(feed.banner), + modal=_to_response(feed.modal), + unread_count=feed.unread_count, + ) + + +@router.post( + "/{announcement_id}/ack", + status_code=status.HTTP_204_NO_CONTENT, + summary="Record an acknowledgement", +) +async def acknowledge_announcement( + announcement_id: str, + body: AnnouncementAckRequest, + current_user: User = Depends(require_announcements_user), +) -> Response: + """Record ``seen`` / ``dismissed`` / ``acknowledged`` for this user. + + Idempotent and monotonic — a weaker action arriving late is a no-op, not an + error (§D2), so this returns 204 either way. + + **404 when the id is not visible to this caller**, deliberately not 403: + an announcement targeted at another role should not have its existence + confirmed by the error code on a guessed id. + """ + service = get_announcements_service() + feed = await service.build_feed( + user_id=current_user.user_id, + user_roles=current_user.roles or [], + user_created_at=await _user_created_at(current_user.user_id), + ) + + announcement = feed.get(announcement_id) + if announcement is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Announcement '{announcement_id}' not found", + ) + + await service.record_ack( + user_id=current_user.user_id, + announcement=announcement, + action=body.action, + surface=body.surface, + ) + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/src/apis/app_api/artifacts/models.py b/backend/src/apis/app_api/artifacts/models.py index a36ce7c43..a59897978 100644 --- a/backend/src/apis/app_api/artifacts/models.py +++ b/backend/src/apis/app_api/artifacts/models.py @@ -1,10 +1,21 @@ -"""Request/response models for the render-token endpoint.""" +"""Request/response models for the artifacts API. + +Covers the render-token, session-list and content endpoints, plus the +artifact-sharing surface (`artifacts/shares.py`). + +JSON casing is split by design and matches what already shipped: the +render-token / list / content models are snake_case (this domain's +original REST shape), while the sharing models are camelCase aliases to +mirror the conversation-sharing API the SPA share modal is adapted from. +""" from __future__ import annotations -from typing import Optional +from typing import List, Literal, Optional + +from pydantic import BaseModel, ConfigDict, Field, model_validator -from pydantic import BaseModel, Field +from .service import MAX_ARTIFACT_TITLE_LENGTH class RenderTokenRequest(BaseModel): @@ -52,6 +63,65 @@ class ArtifactListResponse(BaseModel): artifacts: list[ArtifactSummary] = Field(default_factory=list) +class LibraryArtifact(BaseModel): + """One artifact at its current HEAD, for the user-wide library page. + + Distinct from `ArtifactSummary` in cardinality, not just fields: the + session list returns one row per *version* so the SPA can anchor a + card under the turn that produced it, while the library returns one + row per *artifact*. Carries `session_id` so the library can link back + to the conversation that produced it — the summary has no need for it + (the caller already supplied the session id). + """ + + artifact_id: str + version: int + title: str + content_type: str + created_at: str + updated_at: str + session_id: str + + +class ArtifactLibraryResponse(BaseModel): + artifacts: list[LibraryArtifact] = Field(default_factory=list) + + +class RenameArtifactRequest(BaseModel): + """Request body for retitling an artifact. + + A PATCH body with one field rather than a `/title` sub-resource, so + a future editable field (a description, say) is an added key rather + than another endpoint. The length cap is enforced here *and* in the + service — the model guards the HTTP surface, the service guards any + other caller, and they read from the same constant. + """ + + title: str = Field( + ..., + min_length=1, + max_length=MAX_ARTIFACT_TITLE_LENGTH, + description="New display title for the artifact", + ) + + +class RenamedArtifactResponse(BaseModel): + """The artifact's HEAD after a rename. + + Returns the whole record rather than an echo of the title so the SPA + can reconcile against the server's view — the same shape the library + endpoint already hands it, minus nothing it uses. + """ + + artifact_id: str + version: int + title: str + content_type: str + created_at: str + updated_at: str + session_id: str + + class ArtifactContentResponse(BaseModel): """Raw source of one artifact version, for the panel's code view. @@ -64,3 +134,183 @@ class ArtifactContentResponse(BaseModel): content: str content_type: str version: int + + +# --------------------------------------------------------------------- +# Artifact sharing +# --------------------------------------------------------------------- + + +class CreateArtifactShareRequest(BaseModel): + """Request body for sharing one artifact version. + + `version` is required and never defaults to the artifact's HEAD: a + share pins one immutable version, and a pointer that moves under the + recipient is a different feature with different consent semantics. + """ + + model_config = ConfigDict(populate_by_name=True) + + version: int = Field( + ..., ge=1, description="Artifact version to share (never #HEAD)" + ) + access_level: Literal["public", "specific"] = Field( + ..., + alias="accessLevel", + description="'public' = any authenticated tenant user; " + "'specific' = email allowlist", + ) + allowed_emails: Optional[List[str]] = Field( + default=None, + alias="allowedEmails", + description="Email addresses allowed to view " + "(required when accessLevel is 'specific')", + ) + + @model_validator(mode="after") + def validate_allowed_emails(self) -> "CreateArtifactShareRequest": + if self.access_level == "specific" and not self.allowed_emails: + raise ValueError( + "allowed_emails is required when access_level is 'specific'" + ) + return self + + +class UpdateArtifactShareRequest(BaseModel): + """Request body for changing an existing share's access controls. + + The share target — `(artifact_id, version)` — is immutable; only who + may view it can change. + """ + + model_config = ConfigDict(populate_by_name=True) + + access_level: Optional[Literal["public", "specific"]] = Field( + default=None, alias="accessLevel", description="New access level" + ) + allowed_emails: Optional[List[str]] = Field( + default=None, + alias="allowedEmails", + description="Updated email allowlist", + ) + + @model_validator(mode="after") + def validate_allowed_emails(self) -> "UpdateArtifactShareRequest": + if self.access_level == "specific" and not self.allowed_emails: + raise ValueError( + "allowed_emails is required when access_level is 'specific'" + ) + return self + + +class ArtifactShareResponse(BaseModel): + """An artifact share, as its owner sees it. + + Owner-only: `allowedEmails` is other people's addresses, so this + shape must never be returned on a recipient-facing route. + """ + + model_config = ConfigDict(populate_by_name=True) + + share_id: str = Field(..., alias="shareId") + artifact_id: str = Field(..., alias="artifactId") + version: int = Field(..., description="Pinned artifact version") + owner_id: str = Field(..., alias="ownerId") + access_level: Literal["public", "specific"] = Field( + ..., alias="accessLevel" + ) + allowed_emails: Optional[List[str]] = Field( + default=None, alias="allowedEmails" + ) + title: str = Field(default="", description="Denormalized artifact title") + content_type: str = Field(default="", alias="contentType") + created_at: str = Field(..., alias="createdAt") + updated_at: Optional[str] = Field(default=None, alias="updatedAt") + share_url: str = Field( + ..., + alias="shareUrl", + description="SPA-relative recipient route for this share", + ) + + +class ArtifactShareListResponse(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + shares: List[ArtifactShareResponse] = Field(default_factory=list) + + +class SharedArtifactResponse(BaseModel): + """Recipient-facing share metadata. Never carries artifact content. + + Deliberately omits `ownerId`, `artifactId` and `allowedEmails`: a + recipient needs enough to render a header and decide what chrome to + show, not the owner's internal ids or the rest of the allowlist. + """ + + model_config = ConfigDict(populate_by_name=True) + + share_id: str = Field(..., alias="shareId") + title: str = Field(default="") + content_type: str = Field(default="", alias="contentType") + version: int + created_at: str = Field(..., alias="createdAt") + owner_email: str = Field( + ..., alias="ownerEmail", description="Who shared this" + ) + can_download: bool = Field( + default=True, + alias="canDownload", + description="Whether the recipient may save a local copy. The " + "download path is the same render URL with ?download=1, so a " + "recipient who can view can also download — surfaced as a field " + "so a future policy can withhold it without a shape change.", + ) + + +class SharedWithMeArtifact(BaseModel): + """One artifact somebody else shared with the caller. + + Deliberately the recipient shape, not the owner one: no `ownerId`, + no `artifactId`, no `allowedEmails`. A recipient addresses a shared + artifact by its share id and nothing else — `shareUrl` is the only + route they have to it, and handing them the owner's artifact id + would imply an addressability they do not have. + """ + + model_config = ConfigDict(populate_by_name=True) + + share_id: str = Field(..., alias="shareId") + title: str = Field(default="") + content_type: str = Field(default="", alias="contentType") + version: int + owner_email: str = Field( + ..., alias="ownerEmail", description="Who shared this" + ) + shared_at: str = Field( + ..., + alias="sharedAt", + description="When the share was created — not when the artifact " + "was made or last edited, which are the owner's timestamps and " + "mean nothing to a recipient", + ) + share_url: str = Field(..., alias="shareUrl") + + +class SharedWithMeResponse(BaseModel): + """A page of the caller's share inbox. + + `nextCursor` terminates the listing, not the page size: rows are + dropped after the underlying query (a share revoked since it was + fanned out, an allowlist edited), so a short page can still have + more behind it. Page until the cursor is null. + """ + + model_config = ConfigDict(populate_by_name=True) + + artifacts: List[SharedWithMeArtifact] = Field(default_factory=list) + next_cursor: Optional[str] = Field( + default=None, + alias="nextCursor", + description="Opaque continuation token; null when the listing is " + "complete", + ) diff --git a/backend/src/apis/app_api/artifacts/routes.py b/backend/src/apis/app_api/artifacts/routes.py index 6b265eecd..8fe4d71d0 100644 --- a/backend/src/apis/app_api/artifacts/routes.py +++ b/backend/src/apis/app_api/artifacts/routes.py @@ -3,26 +3,33 @@ import logging from datetime import datetime, timezone -from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from apis.shared.auth import User, get_current_user_from_session from .models import ( ArtifactContentResponse, + ArtifactLibraryResponse, ArtifactListResponse, ArtifactSummary, + LibraryArtifact, + RenameArtifactRequest, + RenamedArtifactResponse, RenderTokenRequest, RenderTokenResponse, ) from .service import ( ArtifactContentService, + ArtifactLifecycleService, ArtifactListService, ArtifactNotFoundError, ArtifactQueryError, + ArtifactTitleError, ArtifactTooLargeError, RenderTokenConfigError, RenderTokenService, get_artifact_content_service, + get_artifact_lifecycle_service, get_artifact_list_service, get_render_token_service, ) @@ -110,6 +117,48 @@ async def list_session_artifacts( ) +@router.get("/library", response_model=ArtifactLibraryResponse) +async def list_user_artifacts( + user: User = Depends(get_current_user_from_session), + service: ArtifactListService = Depends(get_artifact_list_service), +) -> ArtifactLibraryResponse: + """Every artifact the caller owns, at HEAD, newest-first. + + Backs the artifact library page. Takes no scoping parameter by + design: the only user it can ever describe is the authenticated one, + since the underlying Query is keyed on `PK=USER#{uid}`. There is no + way to ask this endpoint about somebody else. + + A separate route rather than an optional `session_id` on the list + endpoint above, because the two differ in cardinality: that one + returns a row per *version* (the SPA anchors each to its turn), this + one a row per *artifact*. Overloading one path with both shapes would + make the response type depend on which query params were present. + + Unpaginated, matching the shape of the data: the heaviest partition + in production holds well under a single 1MB Query page. Pagination + lands with the user index when it is needed, not before. + """ + try: + rows = service.list_for_user(user_id=user.user_id) + except RenderTokenConfigError: + logger.exception("artifact library service misconfigured") + raise HTTPException( + status.HTTP_500_INTERNAL_SERVER_ERROR, + "Artifact listing is unavailable", + ) + except ArtifactQueryError: + logger.exception("artifact library query failed") + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Artifact listing is temporarily unavailable", + ) + + return ArtifactLibraryResponse( + artifacts=[LibraryArtifact(**row) for row in rows] + ) + + @router.get("/{artifact_id}/content", response_model=ArtifactContentResponse) async def get_artifact_content( artifact_id: str, @@ -128,7 +177,9 @@ async def get_artifact_content( """ try: content, content_type = service.get( - user_id=user.user_id, + # The authenticated session user — self-scoping. See the + # access-control note on ArtifactContentService. + owner_id=user.user_id, artifact_id=artifact_id, version=version, ) @@ -159,3 +210,101 @@ async def get_artifact_content( content_type=content_type, version=version, ) + + +# --------------------------------------------------------------------- +# Lifecycle +# +# Both paths are single-segment under `/artifacts`, so neither collides +# with the two-segment share routes next door (`/artifacts/shares/{id}`) +# that `shares.py` mounts on the same prefix. +# --------------------------------------------------------------------- + + +@router.patch("/{artifact_id}", response_model=RenamedArtifactResponse) +async def rename_artifact( + artifact_id: str, + request: RenameArtifactRequest, + user: User = Depends(get_current_user_from_session), + service: ArtifactLifecycleService = Depends(get_artifact_lifecycle_service), +) -> RenamedArtifactResponse: + """Retitle an artifact. Owner only. + + Ownership needs no explicit check: the lookup key is built from the + authenticated session, so another user's artifact id simply resolves + to no row and 404s — the same shape as every other route here. + + The new title applies to the HEAD row and to every version row, so + the library and the originating conversation cannot disagree about + what the artifact is called. + """ + try: + head = service.rename( + user_id=user.user_id, + artifact_id=artifact_id, + title=request.title, + ) + except ArtifactNotFoundError: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Artifact not found") + except ArtifactTitleError as exc: + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) + except RenderTokenConfigError: + logger.exception("artifact lifecycle service misconfigured") + raise HTTPException( + status.HTTP_500_INTERNAL_SERVER_ERROR, + "Artifacts are unavailable", + ) + except ArtifactQueryError: + logger.exception("artifact rename failed") + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Renaming is temporarily unavailable", + ) + + return RenamedArtifactResponse( + artifact_id=str(head.get("artifact_id", artifact_id)), + version=int(head.get("version", 0)), + title=str(head.get("title", "")), + content_type=str( + head.get("content_type", "text/html; charset=utf-8") + ), + created_at=str(head.get("created_at") or ""), + updated_at=str(head.get("updated_at") or ""), + session_id=str(head.get("session_id") or ""), + ) + + +@router.delete("/{artifact_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_artifact( + artifact_id: str, + user: User = Depends(get_current_user_from_session), + service: ArtifactLifecycleService = Depends(get_artifact_lifecycle_service), +) -> Response: + """Delete an artifact, every version of it, and every share of it. + + Owner only, by the same key-scoping as the rest of this router. + + Permanent from the caller's side — there is no trash and no undo. + The DynamoDB rows go immediately (which is what stops it rendering + everywhere at once) while the S3 objects are tagged for the bucket's + retention-window expiry. See the block comment on + `ArtifactLifecycleService` for why the two halves differ. + """ + try: + service.delete(user_id=user.user_id, artifact_id=artifact_id) + except ArtifactNotFoundError: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Artifact not found") + except RenderTokenConfigError: + logger.exception("artifact lifecycle service misconfigured") + raise HTTPException( + status.HTTP_500_INTERNAL_SERVER_ERROR, + "Artifacts are unavailable", + ) + except ArtifactQueryError: + logger.exception("artifact delete failed") + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Deleting is temporarily unavailable", + ) + + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/src/apis/app_api/artifacts/service.py b/backend/src/apis/app_api/artifacts/service.py index a740d72eb..ee078b428 100644 --- a/backend/src/apis/app_api/artifacts/service.py +++ b/backend/src/apis/app_api/artifacts/service.py @@ -17,6 +17,8 @@ import re import threading import time +import uuid +from datetime import datetime, timezone from typing import Optional import boto3 @@ -24,6 +26,7 @@ from boto3.dynamodb.conditions import Key from botocore.exceptions import ClientError +from apis.shared.auth import User from apis.shared.security.log_sanitize import scrub_log logger = logging.getLogger(__name__) @@ -82,6 +85,15 @@ class ArtifactQueryError(RenderTokenError): feature is set up correctly, the request just couldn't be served.""" +class ArtifactTitleError(RenderTokenError): + """A caller-supplied artifact title is empty or over the length cap. + + A 400, not a 500 — it describes the request, not the service. Kept in + this exception family so the routes' existing except-ladder shape + still applies. + """ + + class ArtifactTooLargeError(RenderTokenError): """The artifact body exceeds the inline code-view cap. The caller should fall back to the download path rather than streaming a huge @@ -230,6 +242,154 @@ def mint( ) return f"{origin}/?t={token}", exp + def mint_for_share( + self, *, share_id: str, viewer: User + ) -> tuple[str, int]: + """Mint a render token for a *shared* artifact version. + + Returns (render_url, exp_unix). Raises ShareNotFoundError, + ShareAccessDeniedError, ArtifactNotFoundError, or + RenderTokenConfigError. Origin resolves first so a misconfigured + deploy fails closed before any DDB call. + + ############################################################ + # SECURITY — READ BEFORE CHANGING `sub` BELOW. + # + # `sub` is the OWNER's user id, not the viewer's. That is + # deliberate and load-bearing: the render Lambda uses `sub` + # purely as the DynamoDB partition key it builds the lookup + # from (PK = USER#{sub}), and performs no ownership comparison + # of its own — it never sees the viewer. `sub` here is an + # ADDRESS, not an identity assertion. + # + # Setting `sub` to the viewer would not "fix" anything; it + # would point the Lambda at the viewer's own partition and the + # shared artifact would simply 404. + # + # The consequence is that `_check_share_access` immediately + # below is the ONLY thing standing between "sharing" and "read + # any artifact by id". Do not reorder it, do not make it + # conditional, and do not move minting ahead of it. + # + # The real viewer identity travels in `vwr`, and the grant it + # was issued under in `shr`, so the render log can attribute + # the view correctly rather than crediting it to the owner. + # The deployed verifier validates a fixed claim list and has no + # extras rejection, so these are forward-compatible additions + # requiring no Lambda change or deploy sequencing. + ############################################################ + """ + origin = _origin() + share = _get_share_lookup(share_id) + if not share: + raise ShareNotFoundError("share not found") + _check_share_access(share, viewer) + + owner_id = str(share.get("owner_id", "")) + artifact_id = str(share.get("artifact_id", "")) + version = int(share.get("version", 0)) + # The share row is denormalized metadata; the version row is the + # truth. Re-assert it so a share whose artifact version has gone + # away 404s here rather than minting a token that renders the + # Lambda's error page inside the recipient's iframe. + _assert_version_exists(owner_id, artifact_id, version) + + now = int(time.time()) + exp = now + _TTL_SECONDS + claims = { + "iss": _ISS, + "aud": _AUD, + "sub": owner_id, # DynamoDB partition — NOT an identity claim. + "aid": artifact_id, + "ver": version, + "sid": "", + "vwr": viewer.user_id, # who actually looked (audit) + "shr": share_id, # under which grant (audit) + "iat": now, + "exp": exp, + } + token = jwt.encode(claims, _signing_key(), algorithm="HS256") + logger.info( + "minted shared render token share=%s owner=%s viewer=%s " + "artifact=%s v=%s", + scrub_log(share_id), + scrub_log(owner_id), + scrub_log(viewer.user_id), + scrub_log(artifact_id), + scrub_log(version), + ) + return f"{origin}/?t={token}", exp + + + def mint_for_conversation_share( + self, + *, + owner_id: str, + artifact_id: str, + version: int, + conversation_share_id: str, + viewer: User, + ) -> tuple[str, int]: + """Mint a render token for an artifact inside a *shared conversation*. + + Returns (render_url, exp_unix). Raises ArtifactNotFoundError or + RenderTokenConfigError. + + ############################################################ + # SECURITY — this method performs NO access control. + # + # Unlike `mint_for_share`, which resolves and checks its own + # share record, this one is handed an owner id and an artifact + # id by its caller. Both the conversation-share ACL check and + # the "is this artifact actually in that share's snapshot" + # check happen in `shares/service.py`, because the grant lives + # in the shared-conversations table, which this module does not + # read. + # + # So the ONLY safe caller is one that has already done both. Do + # not expose this on a route, do not call it with an + # artifact id taken from a request, and do not add a default + # for `owner_id`. Given `sub` is a partition address (see + # `mint_for_share`), an unchecked call here is "read any + # artifact by id" with extra steps. + ############################################################ + """ + origin = _origin() + # The snapshot is denormalized metadata; the version row is the + # truth. Re-assert it so an artifact deleted since the + # conversation was shared 404s here rather than minting a token + # that renders the Lambda's error page in the recipient's frame. + _assert_version_exists(owner_id, artifact_id, version) + + now = int(time.time()) + exp = now + _TTL_SECONDS + claims = { + "iss": _ISS, + "aud": _AUD, + "sub": owner_id, # DynamoDB partition — NOT an identity claim. + "aid": artifact_id, + "ver": version, + "sid": "", + "vwr": viewer.user_id, # who actually looked (audit) + # The grant is a CONVERSATION share, not an artifact share. + # Prefixed so a log or a later Lambda can tell the two apart + # rather than silently reading it as an artifact share id. + "shr": f"conv:{conversation_share_id}", + "iat": now, + "exp": exp, + } + token = jwt.encode(claims, _signing_key(), algorithm="HS256") + logger.info( + "minted conversation-share render token share=%s owner=%s " + "viewer=%s artifact=%s v=%s", + scrub_log(conversation_share_id), + scrub_log(owner_id), + scrub_log(viewer.user_id), + scrub_log(artifact_id), + scrub_log(version), + ) + return f"{origin}/?t={token}", exp + def get_render_token_service() -> RenderTokenService: return RenderTokenService() @@ -304,6 +464,145 @@ def list_for_session( ) return summaries + def heads_for_session( + self, *, user_id: str, session_id: str + ) -> list[dict]: + """Each artifact the session produced, at HEAD, newest-first. + + One row per *artifact*, unlike `list_for_session`, which returns + one per version so the session view can anchor a card under the + turn that made it. This is the shape a point-in-time snapshot + wants: the version each artifact stood at when the conversation + was shared. + + Only HEAD rows carry `GSI1PK`, so the index query alone is the + answer — no per-artifact expansion, and no base-table read. + + `user_id` filters rather than keys, because `SessionIndex` is + partitioned by session and is NOT user-scoped. Dropping that + filter would let a borrowed session id enumerate somebody else's + artifacts, which is the same reason `list_for_session` re-checks + it per row. + """ + table = _table() + items: list[dict] = [] + kwargs: dict = { + "IndexName": _SESSION_INDEX, + "KeyConditionExpression": Key("GSI1PK").eq( + f"SESSION#{session_id}" + ), + "ScanIndexForward": False, # GSI1SK embeds updated_at → newest first + } + try: + while True: + resp = table.query(**kwargs) + items.extend(resp.get("Items", [])) + last = resp.get("LastEvaluatedKey") + if not last: + break + kwargs["ExclusiveStartKey"] = last + except ClientError as exc: + raise ArtifactQueryError("artifact head query failed") from exc + + heads: list[dict] = [] + seen: set = set() + for item in items: + artifact_id = str(item.get("artifact_id", "")) + if not artifact_id or item.get("user_id") != user_id: + continue + if artifact_id in seen: + continue + seen.add(artifact_id) + produced_by = item.get("produced_by_message_index") + heads.append( + { + "artifact_id": artifact_id, + "version": int(item.get("version", 1)), + "title": str(item.get("title", "")), + "content_type": str( + item.get("content_type", "text/html; charset=utf-8") + ), + "produced_by_message_index": ( + int(produced_by) if produced_by is not None else None + ), + } + ) + return heads + + def list_for_user(self, *, user_id: str) -> list[dict]: + """Every artifact the user owns, at HEAD, newest-first. + + One base-table Query, no index. The table is already partitioned + by user (`PK=USER#{uid}`), so ownership is enforced by the key + rather than re-checked per row the way `list_for_session` has to + be — a user-wide list is the query this schema was already + shaped for. + + Deliberately not using a GSI. `SessionIndex` is partitioned by + session, not user, so it cannot serve this at all; the sparse + user index the writer stamps keys for (`GSI2PK`/`GSI2SK`) does + not exist yet, and is not needed while the heaviest partition + sits far under a 1MB page. See the writer's module docstring. + + Two consequences of reading the base table, both deliberate: + + * The Query spans version rows as well as HEAD rows, so it reads + roughly 3x what it returns. Filtering happens here rather than + in a FilterExpression because the obvious server-side + discriminator (`attribute_exists(GSI1PK)`) would couple "is + HEAD" to "is session-indexed" — two facts that only happen to + coincide today — and a FilterExpression saves payload, not + read capacity, so it buys nothing worth that coupling. + * The base table sorts by artifact id (a random uuid4), not by + time, so recency ordering is applied here in memory. This is + the part that would move server-side behind the user index. + """ + table = _table() + items: list[dict] = [] + kwargs: dict = { + "KeyConditionExpression": Key("PK").eq(f"USER#{user_id}") + & Key("SK").begins_with("ARTIFACT#"), + } + try: + while True: + resp = table.query(**kwargs) + items.extend(resp.get("Items", [])) + last = resp.get("LastEvaluatedKey") + if not last: + break + kwargs["ExclusiveStartKey"] = last + except ClientError as exc: + raise ArtifactQueryError("artifact library query failed") from exc + + heads = [ + item for item in items + if str(item.get("SK", "")).endswith("#HEAD") + ] + rows = [ + { + "artifact_id": item.get("artifact_id", ""), + "version": int(item.get("version", 0)), + "title": item.get("title", ""), + "content_type": item.get( + "content_type", "text/html; charset=utf-8" + ), + # Rows written before these attributes existed degrade to + # an empty string rather than dropping out of the library. + "created_at": item.get("created_at") or "", + "updated_at": item.get("updated_at") or "", + "session_id": item.get("session_id") or "", + } + for item in heads + if item.get("artifact_id") + ] + # Newest-first. Undated legacy rows sort last rather than first, + # which an empty-string key would otherwise do. + rows.sort( + key=lambda row: (bool(row["updated_at"]), row["updated_at"]), + reverse=True, + ) + return rows + @staticmethod def _versions_for_artifact( user_id: str, artifact_id: str @@ -382,17 +681,28 @@ def _s3(): def _get_version_item( - user_id: str, artifact_id: str, version: int + owner_id: str, artifact_id: str, version: int ) -> dict: - """Fetch the exact version row, scoped to the authenticated user. - - Building the PK from the session user's id is what prevents reading - another user's artifact. SK zero-pad matches the writer/verifier - `V#{version:05d}` contract.""" + """Fetch the exact version row from `owner_id`'s partition. + + `owner_id` is an ADDRESS — the partition this read targets — not an + identity assertion, exactly like the render token's `sub` claim. + Two callers pass two different things, and the difference is the + whole access-control model: + + - Owner routes pass the *authenticated session user*. Building the + PK from the session is what prevents reading someone else's + artifact; there is no other check. + - Share routes pass the share's *owner*, and may only do so AFTER + `_check_share_access` has admitted the viewer. Reaching this + function with an owner id the caller has not ACL-checked is a + read-any-artifact-by-id bug. + + SK zero-pad matches the writer/verifier `V#{version:05d}` contract.""" sk = f"ARTIFACT#{artifact_id}#V#{version:05d}" try: result = _table().get_item( - Key={"PK": f"USER#{user_id}", "SK": sk} + Key={"PK": f"USER#{owner_id}", "SK": sk} ) except ClientError as exc: raise ArtifactQueryError( @@ -427,20 +737,26 @@ def _unwrap_markdown(html_body: str) -> Optional[str]: class ArtifactContentService: - """Return one artifact version's raw source for the panel code view. + """Return one artifact version's raw source for the code view. - Ownership is enforced by the PK lookup. For Markdown the stored S3 - object is a rendered HTML wrapper; we unwrap it back to the authored - Markdown so code view shows what the model actually wrote, and - normalize `content_type` to `text/markdown` to match. Anything that - can't be unwrapped falls back to the raw stored bytes + real type so - the view still shows something truthful instead of erroring.""" + For Markdown the stored S3 object is a rendered HTML wrapper; we + unwrap it back to the authored Markdown so code view shows what the + model actually wrote, and normalize `content_type` to + `text/markdown` to match. Anything that can't be unwrapped falls + back to the raw stored bytes + real type so the view still shows + something truthful instead of erroring. + + ACCESS CONTROL: this service performs none of its own. It reads + whichever partition `owner_id` names — see `_get_version_item`. The + owner route passes the authenticated session user (self-scoping); + the shared route passes the share's owner and is responsible for + having run the share ACL first.""" def get( - self, *, user_id: str, artifact_id: str, version: int + self, *, owner_id: str, artifact_id: str, version: int ) -> tuple[str, str]: bucket = _bucket_name() - item = _get_version_item(user_id, artifact_id, version) + item = _get_version_item(owner_id, artifact_id, version) content_key = item.get("content_key") stored_type = item.get( "content_type", "text/html; charset=utf-8" @@ -477,3 +793,1337 @@ def get( def get_artifact_content_service() -> ArtifactContentService: return ArtifactContentService() + + +# --------------------------------------------------------------------- +# Artifact sharing +# +# Two rows per share, on this same table, written in one transaction: +# +# owner row PK = USER#{owner_id} +# SK = SHARE#{artifact_id}#V#{version:05d}#{share_id} +# lookup row PK = SHARE#{share_id} +# SK = META +# +# The owner row makes "list my shares for this artifact" a begins_with +# query on the owner's existing partition; the lookup row makes the +# recipient path — which knows only a share id — a single GetItem. Two +# items in a transaction is deliberately chosen over a GSI: an index +# would mean an infra deploy that has to land before the code that +# queries it, one UpdateTable at a time. +# +# Both rows carry the identical attribute set. The duplication is +# bounded (access_level / allowed_emails are the only mutable fields) +# and every write below rewrites both rows together, so they cannot +# drift. +# --------------------------------------------------------------------- + +_SHARE_LOOKUP_SK = "META" + +# --------------------------------------------------------------------- +# Recipient fan-out rows ("shared with you") +# +# recipient row PK = SHARED_WITH#{email_lower} +# SK = SHARE#{created_at}#{share_id} +# +# One row per (share, recipient). This is the only shape that answers +# "what has been shared with me" without a table scan, and it is not a +# convenience choice over an index: `allowed_emails` is a LIST, and a +# DynamoDB GSI cannot project one item into N index entries, so *any* +# recipient-lookup design needs a row per recipient. Given that, the row +# belongs in the recipient's own partition, where the query is already +# partitioned by exactly the access dimension, ordered by time via the +# sort key, and natively paginable. Moving those same rows into the +# owner's partition and adding a GSI over them would cost an index for +# no gain. (The table's index budget is better spent on the +# `UserArtifactsIndex` the writer already stamps GSI2PK/GSI2SK for — +# that one buys server-side ordering and pagination for the library.) +# +# The row is a POINTER, deliberately carrying no title or content type. +# Share rows denormalize those, and until this module's `rename` cascade +# they went stale on rename; copying them across N recipients would +# multiply that. The inbox resolves display fields from the share lookup +# row at read time instead — one GetItem per row, always current. +# +# Fan-out is NOT part of the two-row share transaction. It is written +# per item, after the core rows commit, and torn down before them. A +# transaction would cap the allowlist at ~40 (TransactWriteItems allows +# 100 items, and a full allowlist swap is N deletes + N puts + 2), which +# is a product limit invented by a storage choice. Per-item writes have +# no such ceiling and isolate failures. +# +# The failure direction is what makes that safe: these rows are a +# DISCOVERY surface, never an authorization one. `_check_share_access` +# against the share row remains the only thing that grants access, so a +# fan-out row that failed to write costs a recipient a listing, not +# their access — and a fan-out row that outlives its share grants +# nothing, because the inbox resolves every row through the share +# lookup row and drops the ones that no longer exist. +# --------------------------------------------------------------------- + +_RECIPIENT_PK_PREFIX = "SHARED_WITH#" +_RECIPIENT_SK_PREFIX = "SHARE#" + + +def _normalize_email(email: str) -> str: + """Fold an address to its partition-key form. + + Share rows store addresses exactly as the owner typed them and + lowercase only at compare time (`_check_share_access`), so folding + here is load-bearing rather than tidy: a share addressed to + `Ada.Lovelace@x.edu` read by a viewer whose token says + `ada.lovelace@x.edu` would land on a different partition and return + an empty inbox — a wrong answer indistinguishable from "nobody has + shared anything with you". + """ + return (email or "").strip().lower() + + +def _recipient_pk(email: str) -> str: + return f"{_RECIPIENT_PK_PREFIX}{_normalize_email(email)}" + + +def _recipient_sk(created_at: str, share_id: str) -> str: + """Recipient-row sort key: time first, so a Query returns newest-first + with no sort at read time and pages without a filter.""" + return f"{_RECIPIENT_SK_PREFIX}{created_at}#{share_id}" + + +# Largest inbox page a caller may ask for. Each row costs a GetItem to +# resolve, so this caps the fan-out of one request, not just its payload. +_MAX_INBOX_PAGE = 100 + + +def _encode_inbox_cursor(sort_key: Optional[str]) -> Optional[str]: + """Opaque continuation token for the inbox — the sort key, and only + the sort key. See the security note in `list_for_recipient`.""" + if not sort_key: + return None + return base64.urlsafe_b64encode(str(sort_key).encode()).decode() + + +def _decode_inbox_cursor(cursor: Optional[str]) -> Optional[str]: + """Recover a sort key from a cursor, or None if it is unusable. + + A malformed cursor restarts the listing rather than erroring: it is + an opaque token the caller was handed, so the only way it can be + wrong is if it was tampered with or truncated, and neither deserves + a 500. It also cannot be used to reach another partition — the + caller of this function rebuilds the partition key from the session. + """ + if not cursor: + return None + try: + decoded = base64.urlsafe_b64decode(cursor.encode()).decode() + except (ValueError, UnicodeDecodeError): + return None + return decoded if decoded.startswith(_RECIPIENT_SK_PREFIX) else None + + +class ArtifactShareError(Exception): + """Base class for artifact-share failures.""" + + +class ShareNotFoundError(ArtifactShareError): + """No share row for the requested share id (never created, or revoked).""" + + +class ShareAccessDeniedError(ArtifactShareError): + """The viewer is not permitted to open this share.""" + + +class NotShareOwnerError(ArtifactShareError): + """A non-owner attempted to mutate or revoke a share.""" + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _owner_share_sk(artifact_id: str, version: int, share_id: str) -> str: + """Owner-row sort key. The `V#{version:05d}` zero-pad matches the + artifact version rows so the two key spaces read consistently.""" + return f"SHARE#{artifact_id}#V#{version:05d}#{share_id}" + + +def _owner_share_prefix(artifact_id: str) -> str: + return f"SHARE#{artifact_id}#V#" + + +def _share_lookup_key(share_id: str) -> dict: + return {"PK": f"SHARE#{share_id}", "SK": _SHARE_LOOKUP_SK} + + +def _get_share_lookup(share_id: str) -> Optional[dict]: + """Resolve a share id to its record without knowing the owner. + + Returns None when the share does not exist — a revoked share is a + deleted row, which is what makes revocation effective within one + token TTL.""" + try: + result = _table().get_item(Key=_share_lookup_key(share_id)) + except ClientError as exc: + raise ArtifactQueryError("share lookup failed") from exc + return result.get("Item") + + +def _check_share_access(share: dict, viewer: User) -> None: + """Decide whether `viewer` may open `share`. + + Direct port of ShareService._check_access (conversation sharing). + `public` means "any authenticated tenant user", never anonymous — + every route reaching here is already behind the session dependency. + + This is the security boundary for the whole feature: the share-scoped + mint hands the viewer a credential addressed to the *owner's* + DynamoDB partition, so this check is the only thing between + "sharing" and "read any artifact by id". Fail closed — an unknown or + missing access level is treated as `specific` with no allowlist. + """ + if viewer.user_id and viewer.user_id == share.get("owner_id"): + return + + access_level = share.get("access_level", "specific") + if access_level == "public": + return + + if access_level == "specific": + allowed = [ + str(e).lower() for e in (share.get("allowed_emails") or []) + ] + viewer_email = (viewer.email or "").lower() + if viewer_email and viewer_email in allowed: + return + + raise ShareAccessDeniedError("access denied") + + +def _resolve_allowed_emails( + access_level: str, + allowed_emails: Optional[list[str]], + owner_email: str, +) -> Optional[list[str]]: + """Normalize the allowlist, keeping the owner on it. + + Port of ShareService._resolve_allowed_emails: `public` carries no + list at all, and the owner is always implicitly allowed (they also + pass the owner branch of _check_share_access, but keeping them on + the list makes the row self-describing in the share UI).""" + if access_level != "specific": + return None + emails = list(allowed_emails or []) + if owner_email and owner_email.lower() not in [ + e.lower() for e in emails + ]: + emails.insert(0, owner_email) + return emails + + +class ArtifactShareService: + """Owner-side CRUD for artifact shares. + + A share pins one immutable `(artifact_id, version)` pair — never + `#HEAD`. Version rows are append-only, so the recipient's view can + never change under them and no snapshot copy is needed. + """ + + def create( + self, + *, + owner: User, + artifact_id: str, + version: int, + access_level: str, + allowed_emails: Optional[list[str]], + ) -> dict: + """Create a share for one artifact version. + + The version row is fetched with a PK built from the *owner's* + session id, so a caller can only ever share their own artifact — + an unknown or someone else's version is an indistinguishable + 404. Title and content type are denormalized off that row so the + recipient header needs no second read. + """ + item = _get_version_item(owner.user_id, artifact_id, version) + + share_id = str(uuid.uuid4()) + now = _now_iso() + attrs = { + "share_id": share_id, + "artifact_id": artifact_id, + "version": version, + "owner_id": owner.user_id, + "owner_email": owner.email, + "access_level": access_level, + "title": item.get("title", ""), + "content_type": item.get( + "content_type", "text/html; charset=utf-8" + ), + "session_id": item.get("session_id", ""), + "created_at": now, + "updated_at": now, + } + resolved = _resolve_allowed_emails( + access_level, allowed_emails, owner.email + ) + if resolved is not None: + attrs["allowed_emails"] = resolved + + self._write_share_rows(attrs) + # After, never before: a fan-out row must never point at a share + # that does not exist yet. + self._sync_recipient_rows(attrs) + logger.info( + "created artifact share share=%s artifact=%s v=%s access=%s", + scrub_log(share_id), + scrub_log(artifact_id), + scrub_log(version), + scrub_log(access_level), + ) + return attrs + + def list_for_artifact( + self, *, owner_id: str, artifact_id: str + ) -> list[dict]: + """Every share the caller owns for one artifact. + + Partition-scoped by construction: PK is the authenticated user, + so this can never surface another owner's shares.""" + table = _table() + items: list[dict] = [] + kwargs: dict = { + "KeyConditionExpression": Key("PK").eq(f"USER#{owner_id}") + & Key("SK").begins_with(_owner_share_prefix(artifact_id)), + } + try: + while True: + resp = table.query(**kwargs) + items.extend(resp.get("Items", [])) + last = resp.get("LastEvaluatedKey") + if not last: + break + kwargs["ExclusiveStartKey"] = last + except ClientError as exc: + raise ArtifactQueryError("share list query failed") from exc + return [self._strip_keys(item) for item in items] + + def get_for_viewer(self, *, share_id: str, viewer: User) -> dict: + """Access-checked read of a share record. Never returns content.""" + share = _get_share_lookup(share_id) + if not share: + raise ShareNotFoundError("share not found") + _check_share_access(share, viewer) + return self._strip_keys(share) + + def update( + self, + *, + share_id: str, + owner: User, + access_level: Optional[str], + allowed_emails: Optional[list[str]], + ) -> dict: + """Change access level / allowlist on an existing share. + + Rewrites both rows so the owner row and the lookup row the + recipient path reads can never disagree about who may view.""" + share = _get_share_lookup(share_id) + if not share: + raise ShareNotFoundError("share not found") + if share.get("owner_id") != owner.user_id: + raise NotShareOwnerError("not the share owner") + + updated = self._strip_keys(share) + # Snapshot before mutation — the fan-out diff needs the allowlist + # as it was, and `updated` is edited in place below. + previous = dict(updated) + new_access = access_level or updated.get("access_level", "specific") + updated["access_level"] = new_access + + if new_access == "specific": + emails = allowed_emails or updated.get("allowed_emails") or [] + updated["allowed_emails"] = _resolve_allowed_emails( + new_access, emails, str(updated.get("owner_email", "")) + ) + else: + # Switching to public — drop the stale allowlist rather than + # leaving a list that no longer gates anything. + updated.pop("allowed_emails", None) + + updated["version"] = int(updated.get("version", 0)) + updated["updated_at"] = _now_iso() + + self._write_share_rows(updated) + # Covers specific→public too: the allowlist is gone, so every + # fan-out row is a removal and the share drops out of every + # inbox while staying reachable by link. + self._sync_recipient_rows(updated, previous=previous) + logger.info( + "updated artifact share share=%s access=%s", + scrub_log(share_id), + scrub_log(new_access), + ) + return updated + + def revoke(self, *, share_id: str, owner: User) -> None: + """Delete both rows. Effective within one render-token TTL.""" + share = _get_share_lookup(share_id) + if not share: + raise ShareNotFoundError("share not found") + if share.get("owner_id") != owner.user_id: + raise NotShareOwnerError("not the share owner") + + table = _table() + # Discovery first, then reachability, then visibility — the same + # ordering principle as `ArtifactLifecycleService.delete`. + self._delete_recipient_rows(table, share) + owner_sk = _owner_share_sk( + str(share.get("artifact_id", "")), + int(share.get("version", 0)), + share_id, + ) + try: + table.meta.client.transact_write_items( + TransactItems=[ + { + "Delete": { + "TableName": table.name, + "Key": { + "PK": f"USER#{share.get('owner_id', '')}", + "SK": owner_sk, + }, + } + }, + { + "Delete": { + "TableName": table.name, + "Key": _share_lookup_key(share_id), + } + }, + ] + ) + except ClientError as exc: + raise ArtifactQueryError("share revoke failed") from exc + logger.info("revoked artifact share share=%s", scrub_log(share_id)) + + @staticmethod + def _recipient_emails(share: Optional[dict]) -> set: + """Addresses that should hold a fan-out row for this share. + + Empty for a `public` share: "any authenticated tenant user" has + no recipient list to fan out to, so public shares stay + link-delivered and never appear in anyone's inbox. That is a + product decision, not a limitation — an inbox listing every + public share in the tenant is a different feature. + + The owner is filtered out even though `_resolve_allowed_emails` + deliberately keeps them on the allowlist: without this, sharing + your own artifact files it under "shared with you". + """ + if not share or share.get("access_level") != "specific": + return set() + owner = _normalize_email(str(share.get("owner_email", ""))) + return { + email + for email in ( + _normalize_email(str(raw)) + for raw in (share.get("allowed_emails") or []) + ) + if email and email != owner + } + + @staticmethod + def _recipient_key(share: dict, email: str) -> dict: + """Fan-out row key for one recipient of one share. + + Keyed on `created_at`, never `updated_at`: the sort key has to be + stable for the life of the share, or editing the allowlist would + strand a duplicate row under the old timestamp for every + recipient who was already on it. + """ + return { + "PK": _recipient_pk(email), + "SK": _recipient_sk( + str(share.get("created_at", "")), str(share["share_id"]) + ), + } + + def _sync_recipient_rows( + self, share: dict, *, previous: Optional[dict] = None + ) -> None: + """Reconcile fan-out rows to the share's current allowlist. + + A diff, not a rewrite: only added and removed addresses are + touched, so re-saving a share with an unchanged allowlist costs + nothing and an edit costs one write per changed address. + + Best-effort by design. These rows are discovery, not access (see + the block comment above `_RECIPIENT_PK_PREFIX`), so a failure + here must not fail the share write that already committed — the + link works, the recipient simply has to follow it rather than + find it. Raising would be worse: the caller has no way to undo + the committed share, so it would report failure for a share that + exists. + """ + desired = self._recipient_emails(share) + existing = self._recipient_emails(previous) + if desired == existing: + return + + table = _table() + # Removals first: an address taken off the allowlist has already + # lost access at `_check_share_access`, so clearing its listing + # is the more urgent of the two. + for email in existing - desired: + self.delete_quietly(table, self._recipient_key(share, email)) + + for email in desired - existing: + row = { + **self._recipient_key(share, email), + "share_id": str(share["share_id"]), + "artifact_id": str(share.get("artifact_id", "")), + "version": int(share.get("version", 0)), + "owner_id": str(share.get("owner_id", "")), + "owner_email": str(share.get("owner_email", "")), + "shared_at": str(share.get("created_at", "")), + } + try: + table.put_item(Item=row) + except ClientError: + logger.warning( + "could not fan out artifact share %s to a recipient", + scrub_log(str(share["share_id"])), + exc_info=True, + ) + + def _delete_recipient_rows(self, table, share: dict) -> None: + """Drop every fan-out row for one share. + + Called before the lookup row on every teardown path. A crash + between the two leaves a live share that nobody can discover, + which is inert; the reverse order would leave a dead share + sitting in someone's inbox. (The inbox resolves each row through + the lookup row and skips what has gone, so a stranded row is + already harmless — this ordering keeps it from mattering at + all.) + """ + for email in self._recipient_emails(share): + self.delete_quietly(table, self._recipient_key(share, email)) + + @staticmethod + def _write_share_rows(attrs: dict) -> None: + """Put the owner row and the lookup row in one transaction. + + Both carry the same attributes; only the keys differ. A partial + write would either strand an unreachable share (owner row with + no lookup) or an unlistable one, so this is atomic. Plain Puts + only — a ConditionCheck item would need `dynamodb:ConditionCheckItem` + added to the task role, which plain writes do not. + """ + table = _table() + owner_sk = _owner_share_sk( + str(attrs["artifact_id"]), + int(attrs["version"]), + str(attrs["share_id"]), + ) + owner_row = { + **attrs, + "PK": f"USER#{attrs['owner_id']}", + "SK": owner_sk, + } + lookup_row = {**attrs, **_share_lookup_key(str(attrs["share_id"]))} + try: + table.meta.client.transact_write_items( + TransactItems=[ + {"Put": {"TableName": table.name, "Item": owner_row}}, + {"Put": {"TableName": table.name, "Item": lookup_row}}, + ] + ) + except ClientError as exc: + raise ArtifactQueryError("share write failed") from exc + + @staticmethod + def _strip_keys(item: dict) -> dict: + """Drop the DynamoDB key attributes and normalize `version`. + + `version` comes back off DynamoDB as a Decimal; the SK builder + and the response models both want a real int.""" + stripped = {k: v for k, v in item.items() if k not in ("PK", "SK")} + if "version" in stripped: + stripped["version"] = int(stripped["version"]) + return stripped + + + def delete_for_session(self, session_id: str, owner_id: str) -> int: + """Revoke every artifact share produced by one chat session. + + Called as a background task when the session owner deletes a + conversation, mirroring `ShareService.delete_shares_for_session` + for conversation shares. Artifacts outlive the chat that made + them, so without this a deleted conversation would leave live + share links pointing at its artifacts. + + Best-effort and **never raises**: a delete that partially fails + leaves an orphan row, and the caller has already returned 204. + Returns the number of shares revoked (0 on any failure, and 0 + when the artifacts feature isn't configured for this deploy). + + Scoped by `owner_id` deliberately. `SessionIndex` is not + user-partitioned — the same reason `ArtifactListService` + re-checks every HEAD row — so filtering here is what stops a + borrowed or colliding session id reaching another user's shares. + + Deliberately NOT in scope: deleting the artifact content itself. + That is a retention decision about the artifacts feature as a + whole, not about sharing. + """ + try: + table = _table() + except RenderTokenConfigError: + # Artifacts aren't enabled for this environment. The session + # routes are always mounted, so this is a normal no-op, not + # a failure. + logger.debug("artifacts not configured — skipping share cascade") + return 0 + + try: + shares = self._shares_for_session(table, session_id, owner_id) + if not shares: + return 0 + + # Lookup rows first, owner rows second — and never the other + # way round. The lookup row (PK=SHARE#{id}) is what the + # recipient path resolves, so dropping it is what actually + # kills the link. If the second pass fails we are left with + # an unreachable owner row, which is inert; the reverse + # order would leave a *live* share whose owner can no longer + # see it to revoke. + # + # Plain DeleteItem per row, NOT `table.batch_writer()`. + # `BatchWriteItem` is its own IAM action and is *not* + # authorized by the underlying item actions the way + # `TransactWriteItems` is — the task role is granted + # GetItem/PutItem/UpdateItem/DeleteItem/Query and nothing + # else, so a batch write fails closed with AccessDenied at + # runtime. Per-item deletes also isolate failures: one bad + # row can't strand the rest of the cascade. + revoked = 0 + for share in shares: + self._delete_recipient_rows(table, share) + for share in shares: + if self.delete_quietly( + table, _share_lookup_key(str(share["share_id"])) + ): + revoked += 1 + for share in shares: + self.delete_quietly( + table, + { + "PK": f"USER#{owner_id}", + "SK": _owner_share_sk( + str(share["artifact_id"]), + int(share["version"]), + str(share["share_id"]), + ), + }, + ) + + logger.info( + "revoked %s of %s artifact share(s) for deleted session %s", + revoked, + len(shares), + scrub_log(session_id), + ) + return revoked + except Exception: + logger.error( + "failed to revoke artifact shares for session %s", + scrub_log(session_id), + exc_info=True, + ) + return 0 + + def revoke_for_artifact(self, *, owner_id: str, artifact_id: str) -> int: + """Revoke every share of one artifact. Returns the count revoked. + + The cascade behind `ArtifactLifecycleService.delete`. Shares are + per-version, so deleting an artifact has to sweep the whole + `SHARE#{artifact_id}#V#` prefix — otherwise a link handed out for + v2 outlives the artifact it points at. + + Ordering matches `delete_for_session` and must not be flipped: + the lookup row (`PK=SHARE#{id}`) is what the recipient path + resolves, so dropping it is what actually kills the link. If the + second pass fails we are left with an unreachable owner row, + which is inert; the reverse order would leave a *live* share + whose owner can no longer see it to revoke. + + Unlike `delete_for_session`, enumeration failures raise. That + call site is a fire-and-forget background task after a 204 has + already gone out, so it can only swallow; this one runs inside + the request that is about to start deleting rows, and failing + before anything has changed is strictly better than proceeding + blind. Individual row deletes stay best-effort for the same + reason they are there: one bad row must not strand the rest. + """ + table = _table() + shares = self._shares_for_artifact(table, owner_id, artifact_id) + if not shares: + return 0 + + revoked = 0 + for share in shares: + self._delete_recipient_rows(table, share) + for share in shares: + if self.delete_quietly( + table, _share_lookup_key(str(share["share_id"])) + ): + revoked += 1 + for share in shares: + self.delete_quietly( + table, + { + "PK": f"USER#{owner_id}", + "SK": _owner_share_sk( + str(share["artifact_id"]), + int(share["version"]), + str(share["share_id"]), + ), + }, + ) + + logger.info( + "revoked %s of %s artifact share(s) for deleted artifact %s", + revoked, + len(shares), + scrub_log(artifact_id), + ) + return revoked + + @staticmethod + def _shares_for_artifact( + table, owner_id: str, artifact_id: str + ) -> list[dict]: + """Every share row the owner holds for one artifact, across all + versions. Partition-scoped to the owner, so it can never reach + another user's shares.""" + shares: list[dict] = [] + kwargs: dict = { + "KeyConditionExpression": Key("PK").eq(f"USER#{owner_id}") + & Key("SK").begins_with(_owner_share_prefix(artifact_id)), + } + try: + while True: + resp = table.query(**kwargs) + shares.extend(resp.get("Items", [])) + last = resp.get("LastEvaluatedKey") + if not last: + break + kwargs["ExclusiveStartKey"] = last + except ClientError as exc: + raise ArtifactQueryError("share list query failed") from exc + return shares + + def list_for_recipient( + self, + *, + viewer: User, + limit: int = 25, + cursor: Optional[str] = None, + ) -> tuple[list[dict], Optional[str]]: + """Artifacts other people have shared with this viewer, newest first. + + One Query on the viewer's own `SHARED_WITH#{email}` partition — + no index, no scan, no filter — followed by one GetItem per row + to resolve the share it points at. + + ############################################################ + # The fan-out row is a POINTER and is never trusted for + # display or for access. Every row is resolved through the + # share lookup row, and a row whose share has gone is dropped. + # That is what makes best-effort fan-out safe: a stranded + # pointer (a revoke that failed halfway, a crash between the + # two teardown passes) lists nothing and grants nothing. + # + # `_check_share_access` is re-run per row even though the + # pointer's existence implies the viewer was on the allowlist + # when it was written. The allowlist can have changed since, + # and this endpoint must agree with the recipient page about + # who may see what — one predicate, both surfaces. + ############################################################ + + Per-item GetItem, never BatchGetItem: `dynamodb:BatchGetItem` is + its own IAM action and is NOT authorized by the item actions the + task role holds, so a batch read would fail closed at runtime + while passing every moto-backed test. That is not hypothetical — + it is exactly how `BatchWriteItem` shipped broken in the delete + cascade. Per-item reads also isolate failures, so one bad row + costs one listing rather than the page. + + Pagination is real, not decorative: this partition grows by one + row per share received, for the life of the account, and unlike + `list_for_user` it has no natural ceiling — it is bounded by how + many people share with you, which is not a number this service + controls. + + A page can come back shorter than `limit` (rows are dropped + after the Query, by the resolve step above) while still having a + next cursor. Callers must page until the cursor is None rather + than until a short page, which is standard DynamoDB semantics + and why the cursor, not the count, terminates the loop. + """ + table = _table() + viewer_email = _normalize_email(viewer.email) + if not viewer_email: + # A token with no email cannot be on any allowlist, so there + # is nothing to look up. Empty, not an error. + return [], None + + kwargs: dict = { + "KeyConditionExpression": Key("PK").eq(_recipient_pk(viewer_email)) + & Key("SK").begins_with(_RECIPIENT_SK_PREFIX), + # Sort key leads with the share's creation time, so this is + # newest-first with no sort at read time. + "ScanIndexForward": False, + "Limit": max(1, min(limit, _MAX_INBOX_PAGE)), + } + start_sk = _decode_inbox_cursor(cursor) + if start_sk: + ############################################################ + # The partition key is rebuilt from the authenticated + # viewer and only the SORT key is taken from the cursor. + # An opaque cursor is still attacker-supplied input, and a + # cursor carrying its own PK would be a paging primitive + # into another user's inbox. Do not "simplify" this by + # round-tripping the whole LastEvaluatedKey. + ############################################################ + kwargs["ExclusiveStartKey"] = { + "PK": _recipient_pk(viewer_email), + "SK": start_sk, + } + + try: + resp = table.query(**kwargs) + except ClientError as exc: + raise ArtifactQueryError("share inbox query failed") from exc + + items: list[dict] = [] + for pointer in resp.get("Items", []): + share = _get_share_lookup(str(pointer.get("share_id", ""))) + if not share: + continue # revoked, or the artifact was deleted + if share.get("owner_id") == viewer.user_id: + continue # your own artifact is not "shared with you" + try: + _check_share_access(share, viewer) + except ShareAccessDeniedError: + continue # taken off the allowlist since + items.append( + { + "share_id": str(share.get("share_id", "")), + "title": str(share.get("title", "")), + "content_type": str(share.get("content_type", "")), + "version": int(share.get("version", 0)), + "owner_email": str(share.get("owner_email", "")), + "shared_at": str( + pointer.get("shared_at") + or share.get("created_at", "") + ), + } + ) + + last = resp.get("LastEvaluatedKey") or {} + return items, _encode_inbox_cursor(last.get("SK")) + + def retitle_for_artifact( + self, *, owner_id: str, artifact_id: str, title: str + ) -> int: + """Push a renamed artifact's title onto its share rows. + + Share records denormalize `title` at creation time so the + recipient header needs no second read. Nothing kept them current + until this method: `ArtifactLifecycleService.rename` rewrote the + HEAD and version rows, so the owner saw the new name on every + surface they own while every recipient kept seeing the old one + indefinitely. The owner cannot see the discrepancy and the + recipient has nothing to compare against. + + Both rows per share, always together — the owner row and the + lookup row must never disagree, which is the same invariant + `_write_share_rows` holds atomically on the write path. + + Recipient fan-out rows are deliberately untouched: they carry no + title (see `_RECIPIENT_PK_PREFIX`), precisely so that a rename + stays bounded by the number of shares rather than by the number + of shares times their recipients. + + Best-effort per row, like the delete cascades and for the same + reason: one unwritable row must not strand the rest. Returns the + number of shares retitled. + + ############################################################ + # Nothing this method does may raise. It runs AFTER the rename + # has already committed to the HEAD and version rows, so an + # exception escaping here would report failure for a rename the + # caller can see succeeded everywhere they look — and would + # invite a retry of an operation that is already done. The + # blanket `except Exception` at the bottom is deliberate and + # mirrors `delete_for_session`; do not narrow it to the + # exceptions this code happens to raise today. + ############################################################ + """ + try: + return self._retitle_for_artifact( + owner_id=owner_id, artifact_id=artifact_id, title=title + ) + except RenderTokenConfigError: + # Artifacts aren't configured here at all — a normal no-op. + return 0 + except Exception: + logger.error( + "could not retitle shares for artifact %s", + scrub_log(artifact_id), + exc_info=True, + ) + return 0 + + def _retitle_for_artifact( + self, *, owner_id: str, artifact_id: str, title: str + ) -> int: + """Cascade body. See `retitle_for_artifact` for the contract — + in particular that this one is allowed to raise and its caller + is not.""" + table = _table() + shares = self._shares_for_artifact(table, owner_id, artifact_id) + + retitled = 0 + for share in shares: + share_id = str(share.get("share_id", "")) + keys = [ + { + "PK": f"USER#{owner_id}", + "SK": _owner_share_sk( + str(share.get("artifact_id", "")), + int(share.get("version", 0)), + share_id, + ), + }, + _share_lookup_key(share_id), + ] + ok = True + for key in keys: + try: + table.update_item( + Key=key, + UpdateExpression="SET title = :t", + ExpressionAttributeValues={":t": title}, + # Never resurrect a row a concurrent revoke removed. + ConditionExpression="attribute_exists(SK)", + ) + except ClientError: + ok = False + logger.warning( + "could not retitle artifact share %s", + scrub_log(share_id), + exc_info=True, + ) + if ok: + retitled += 1 + + if retitled: + logger.info( + "retitled %s artifact share(s) for artifact %s", + retitled, + scrub_log(artifact_id), + ) + return retitled + + @staticmethod + def delete_quietly(table, key: dict) -> bool: + """Delete one row, reporting success rather than raising. + + A single failed row must not strand the rest of the cascade — + every remaining share link would stay live.""" + try: + table.delete_item(Key=key) + return True + except ClientError: + logger.warning( + "artifact share cascade could not delete %s", + scrub_log(key.get("PK", "")), + exc_info=True, + ) + return False + + @staticmethod + def _shares_for_session( + table, session_id: str, owner_id: str + ) -> list[dict]: + """Every share row for the artifacts produced by one session. + + Two steps, because there is no index from session to share: + `SessionIndex` projects only artifact HEAD rows, so it yields the + artifact ids, and the shares are then read off the owner's own + partition by SK prefix. + """ + head_kwargs: dict = { + "IndexName": _SESSION_INDEX, + "KeyConditionExpression": Key("GSI1PK").eq( + f"SESSION#{session_id}" + ), + } + heads: list[dict] = [] + while True: + resp = table.query(**head_kwargs) + heads.extend(resp.get("Items", [])) + last = resp.get("LastEvaluatedKey") + if not last: + break + head_kwargs["ExclusiveStartKey"] = last + + artifact_ids = list( + dict.fromkeys( + item.get("artifact_id", "") + for item in heads + if item.get("user_id") == owner_id and item.get("artifact_id") + ) + ) + + shares: list[dict] = [] + for artifact_id in artifact_ids: + shares.extend( + ArtifactShareService._shares_for_artifact( + table, owner_id, artifact_id + ) + ) + return shares + + +def get_artifact_share_service() -> ArtifactShareService: + return ArtifactShareService() + + +# --------------------------------------------------------------------- +# Artifact lifecycle — rename + delete +# +# The library page lists every artifact a user has ever produced, which +# made "get rid of this one" the first thing missing from it. Both +# operations live here rather than in the agent-side writer +# (`agents/builtin_tools/artifacts/service.py`) because they are +# user-initiated CRUD on an existing record, not part of the agent's +# write path — and because the inference-api container cannot serve a +# custom route at all (see the inference-api boundary note in CLAUDE.md). +# +# DELETE SEMANTICS — read this before changing anything below. +# +# The DynamoDB rows are hard-deleted; the S3 objects are soft-deleted by +# tagging them `lifecycle-class=deleted`, which the artifacts bucket's +# existing `expire-soft-deleted` lifecycle rule reaps after +# `config.artifacts.retentionDays`. +# +# That split is deliberate, and the DynamoDB half is the part worth +# defending. The rows are the only authority on reachability: the render +# Lambda resolves a token to content by GetItem-ing the *version* row and +# following its `content_key`, and every listing, content and share path +# keys off these rows too. Deleting them makes an artifact unreachable +# everywhere at once, with no new condition to write and — more to the +# point — no new condition for a future reader to *forget*. A soft flag +# on the row would have to be honoured by the render Lambda (a separate +# deployable, and a frozen cross-PR contract), by both list paths, by the +# content endpoint, by share creation and by the share-scoped mint; one +# missed filter is a deleted artifact that still renders, and it fails +# silently. There is no undo built on top of the flag either, since the +# S3 bytes are what an undo would need and they are on a retention clock +# regardless. +# +# So: from the user's side delete is immediate and permanent. The +# retention window is an operational recovery path, not a user-facing +# trash — restoring an artifact means restoring its rows (the table has +# point-in-time recovery enabled in production) inside the S3 retention +# window, not flipping a flag. +# +# IAM: this needs nothing new. The app-api task role is already granted +# `s3:PutObjectTagging` and `dynamodb:DeleteItem` +# (infrastructure/lib/constructs/app-api/app-api-iam-grants.ts), which is +# the other reason tagging beats `DeleteObject` here — no bucket-policy +# widening and no infra deploy has to land before this code ships. Note +# there is still no `dynamodb:BatchWriteItem`, so deletes below are +# per-item, exactly as `delete_for_session` documents. +# --------------------------------------------------------------------- + +# The tag the artifacts bucket's `expire-soft-deleted` lifecycle rule +# filters on. Frozen contract with +# infrastructure/lib/constructs/artifacts/artifacts-data-construct.ts — +# a typo here is invisible (the object simply never expires). +_DELETED_TAG_KEY = "lifecycle-class" +_DELETED_TAG_VALUE = "deleted" + +# Generous ceiling on a user-supplied title. Long enough that no honest +# title hits it, short enough that the attribute can't be used as free +# storage on a row every list endpoint reads. +MAX_ARTIFACT_TITLE_LENGTH = 200 + + +class ArtifactLifecycleService: + """Rename and delete whole artifacts, owner-scoped. + + Every method builds `PK=USER#{user_id}` from the authenticated + session, so ownership is enforced by the key rather than checked + after the fact: another user's artifact id resolves to no HEAD row + and is an indistinguishable 404. + """ + + def __init__(self, shares: Optional["ArtifactShareService"] = None) -> None: + # Injected so the delete cascade can be asserted in isolation and + # so share-row key construction stays owned by the share service. + self._shares = shares or ArtifactShareService() + + # -- rename -------------------------------------------------------- + + def rename(self, *, user_id: str, artifact_id: str, title: str) -> dict: + """Retitle an artifact. Returns the updated HEAD row. + + Writes `title` to the HEAD row *and* to every version row. + Renaming HEAD alone would split the display name in two: the + library reads HEAD, but the session list reads version rows, so + the same artifact would show its new title on `/artifacts` and + its old one on the conversation that produced it. + + Share rows denormalize the title too, so they are cascaded last + (see `ArtifactShareService.retitle_for_artifact`). Before that + cascade existed, a rename left every recipient looking at the + title the artifact had on the day it was shared, with no way for + either party to notice — the owner sees the new name everywhere + they look. Best-effort: the rows that decide what the owner sees + are already written by then, so a share that fails to retitle is + exactly the old behaviour rather than a failed rename. + + ############################################################ + # This is a bare `SET title`. It must never touch `version`. + # `update_artifact_record` re-points HEAD under an optimistic + # lock (`ConditionExpression="version = :cur"`), so a rename that + # wrote `version` would race a concurrent agent update and one of + # them would lose. Same reasoning — and the same restraint — as + # `set_produced_by_message_index` in the writer. + ############################################################ + + `updated_at` is deliberately left alone too. It is not just a + display field: HEAD's `GSI1SK`/`GSI2SK` embed it, and only the + writer keeps those in sync. Bumping the attribute without + rewriting the keys would order the library (which sorts on the + attribute) differently from the session index (which sorts on the + key) for the same artifacts. "Updated" means the content changed; + a rename records `renamed_at`, which nothing sorts on. + """ + title = title.strip() + if not title: + raise ArtifactTitleError("title must not be empty") + if len(title) > MAX_ARTIFACT_TITLE_LENGTH: + raise ArtifactTitleError( + f"title must be {MAX_ARTIFACT_TITLE_LENGTH} characters or fewer" + ) + + table = _table() + head = self._require_head(table, user_id, artifact_id) + now = _now_iso() + + # HEAD first: it is what both list surfaces read, so if the + # per-version pass fails partway the user still sees the rename + # take effect and can retry into a consistent state. The reverse + # order would look like the rename silently did nothing. + sort_keys = [f"ARTIFACT#{artifact_id}#HEAD"] + [ + f"ARTIFACT#{artifact_id}#V#{int(item['version']):05d}" + for item in self._version_rows(table, user_id, artifact_id) + if item.get("version") is not None + ] + try: + for sk in sort_keys: + table.update_item( + Key={"PK": f"USER#{user_id}", "SK": sk}, + UpdateExpression="SET title = :t, renamed_at = :now", + ExpressionAttributeValues={":t": title, ":now": now}, + # Never resurrect a row the delete path just removed. + ConditionExpression="attribute_exists(SK)", + ) + except ClientError as exc: + code = exc.response.get("Error", {}).get("Code", "") + if code == "ConditionalCheckFailedException": + # The row went away under us — a concurrent delete. + raise ArtifactNotFoundError(artifact_id) from exc + raise ArtifactQueryError("artifact rename failed") from exc + + self._shares.retitle_for_artifact( + owner_id=user_id, artifact_id=artifact_id, title=title + ) + + logger.info( + "renamed artifact user=%s artifact=%s versions=%s", + scrub_log(user_id), + scrub_log(artifact_id), + len(sort_keys) - 1, + ) + return {**head, "title": title, "renamed_at": now} + + # -- delete -------------------------------------------------------- + + def delete(self, *, user_id: str, artifact_id: str) -> int: + """Delete an artifact and every version of it. Returns the + number of version rows removed. + + All versions, never just the HEAD pointer. Versions are + addressable independently — the panel's version picker mints a + render token per version, and shares are per-version — so + dropping only the pointer would leave every prior version live + for anyone holding a link, and unlisted, so the owner could + neither see nor clean them up. That is not a delete. + + Ordering is the load-bearing part, and it is the same principle + as `delete_for_session`: kill reachability first, kill visibility + last, so that every partial-failure state is fail-closed and a + retry finishes the job. + + 1. Revoke shares (lookup row before owner row, as ever). + 2. Tag the S3 objects `lifecycle-class=deleted` — this has to + happen while the version rows still exist, because those + rows hold the only `content_key` pointers to the objects. + 3. Delete the version rows. This is the moment the artifact + stops rendering: the render Lambda's GetItem finds nothing. + 4. Delete the HEAD row last. It is what the library and the + session index list, so an interrupted delete leaves an + artifact that is already unreachable but still listed — + visibly wrong and self-healing on retry, rather than + invisibly still live. + """ + table = _table() + self._require_head(table, user_id, artifact_id) + versions = self._version_rows(table, user_id, artifact_id) + + # 1 — shares. Enumeration failures raise (nothing has changed + # yet, so there is a clean state to fail into); individual row + # deletes are best-effort so one bad row can't strand the rest. + self._shares.revoke_for_artifact( + owner_id=user_id, artifact_id=artifact_id + ) + + # 2 — soft-delete the bytes. + self._tag_objects_deleted(versions) + + # 3 — version rows. + deleted = 0 + for item in versions: + version = item.get("version") + if version is None: + continue + if self._shares.delete_quietly( + table, + { + "PK": f"USER#{user_id}", + "SK": f"ARTIFACT#{artifact_id}#V#{int(version):05d}", + }, + ): + deleted += 1 + + # 4 — HEAD. + try: + table.delete_item( + Key={ + "PK": f"USER#{user_id}", + "SK": f"ARTIFACT#{artifact_id}#HEAD", + } + ) + except ClientError as exc: + raise ArtifactQueryError("artifact delete failed") from exc + + logger.info( + "deleted artifact user=%s artifact=%s versions=%s/%s", + scrub_log(user_id), + scrub_log(artifact_id), + deleted, + len(versions), + ) + return deleted + + # -- internals ----------------------------------------------------- + + @staticmethod + def _require_head(table, user_id: str, artifact_id: str) -> dict: + try: + result = table.get_item( + Key={ + "PK": f"USER#{user_id}", + "SK": f"ARTIFACT#{artifact_id}#HEAD", + } + ) + except ClientError as exc: + raise ArtifactQueryError("artifact lookup failed") from exc + head = result.get("Item") + if not head: + raise ArtifactNotFoundError(artifact_id) + return head + + @staticmethod + def _version_rows(table, user_id: str, artifact_id: str) -> list[dict]: + """Every immutable version row for one artifact. `#HEAD` shares + the SK prefix but not the `#V#` infix, so it is excluded.""" + items: list[dict] = [] + kwargs: dict = { + "KeyConditionExpression": Key("PK").eq(f"USER#{user_id}") + & Key("SK").begins_with(f"ARTIFACT#{artifact_id}#V#"), + } + try: + while True: + resp = table.query(**kwargs) + items.extend(resp.get("Items", [])) + last = resp.get("LastEvaluatedKey") + if not last: + break + kwargs["ExclusiveStartKey"] = last + except ClientError as exc: + raise ArtifactQueryError("artifact version query failed") from exc + return items + + @staticmethod + def _tag_objects_deleted(versions: list[dict]) -> None: + """Tag each version's S3 object for lifecycle expiry. + + Best-effort per object, and never fatal. A failed tag leaves an + orphaned object that the lifecycle rule will not reap — wasted + bytes, logged loudly — whereas aborting the delete over it would + leave the artifact *visible*, which is the failure the user + actually cares about. The object is already unreachable the + moment its row goes, tag or no tag. + + `put_object_tagging` replaces the whole tag set; artifact objects + are written untagged, so there is nothing to preserve. + """ + bucket = _bucket_name() + client = _s3() + for item in versions: + key = item.get("content_key") + if not isinstance(key, str) or not key: + continue + try: + client.put_object_tagging( + Bucket=bucket, + Key=key, + Tagging={ + "TagSet": [ + { + "Key": _DELETED_TAG_KEY, + "Value": _DELETED_TAG_VALUE, + } + ] + }, + ) + except ClientError: + logger.warning( + "artifact delete could not tag object for expiry " + "artifact=%s version=%s", + scrub_log(str(item.get("artifact_id", ""))), + item.get("version"), + exc_info=True, + ) + + +def get_artifact_lifecycle_service() -> ArtifactLifecycleService: + return ArtifactLifecycleService() diff --git a/backend/src/apis/app_api/artifacts/shares.py b/backend/src/apis/app_api/artifacts/shares.py new file mode 100644 index 000000000..1414f95fb --- /dev/null +++ b/backend/src/apis/app_api/artifacts/shares.py @@ -0,0 +1,508 @@ +"""Artifact sharing API routes. + +Two routers, mounted together under the same enablement signal that +gates `artifacts/routes.py` (presence of +`ARTIFACTS_RENDER_TOKEN_SECRET_ARN`): + + - ``artifact_shares_router`` — owner CRUD, under ``/artifacts``. + - ``shared_artifacts_router`` — the recipient surface, under + ``/shared-artifacts``. + +Every route depends on ``get_current_user_from_session``. "Public" here +means *any authenticated tenant user*, exactly as it does for +conversation shares — never anonymous. Governance is Entra JWT identity, +so there is no unauthenticated path to an artifact at all. + +The recipient render-token route is the security-critical one: it hands +a viewer a short-lived credential addressed to the *owner's* DynamoDB +partition. See the block comment on ``RenderTokenService.mint_for_share`` +before touching it. +""" + +import logging +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException, Query, Response, status + +from apis.shared.auth import User, get_current_user_from_session +from apis.shared.feature_flags import artifact_share_inbox_enabled +from apis.shared.security.log_sanitize import scrub_log + +from .models import ( + ArtifactContentResponse, + ArtifactShareListResponse, + ArtifactShareResponse, + CreateArtifactShareRequest, + RenderTokenResponse, + SharedArtifactResponse, + SharedWithMeArtifact, + SharedWithMeResponse, + UpdateArtifactShareRequest, +) +from .service import ( + ArtifactContentService, + ArtifactNotFoundError, + ArtifactQueryError, + ArtifactShareService, + ArtifactTooLargeError, + NotShareOwnerError, + RenderTokenConfigError, + RenderTokenService, + ShareAccessDeniedError, + ShareNotFoundError, + get_artifact_content_service, + get_artifact_share_service, + get_render_token_service, +) + +logger = logging.getLogger(__name__) + +artifact_shares_router = APIRouter(prefix="/artifacts", tags=["artifact-shares"]) +shared_artifacts_router = APIRouter( + prefix="/shared-artifacts", tags=["artifact-shares"] +) + + +def _share_response(share: dict) -> ArtifactShareResponse: + return ArtifactShareResponse( + share_id=share["share_id"], + artifact_id=share.get("artifact_id", ""), + version=int(share.get("version", 0)), + owner_id=share.get("owner_id", ""), + access_level=share.get("access_level", "specific"), + allowed_emails=share.get("allowed_emails"), + title=share.get("title", ""), + content_type=share.get("content_type", ""), + created_at=share.get("created_at", ""), + updated_at=share.get("updated_at"), + share_url=f"/shared-artifact/{share['share_id']}", + ) + + +# ------------------------------------------------------------------ +# Owner endpoints +# ------------------------------------------------------------------ + + +@artifact_shares_router.post( + "/{artifact_id}/shares", + response_model=ArtifactShareResponse, + response_model_by_alias=True, + status_code=status.HTTP_201_CREATED, +) +async def create_artifact_share( + artifact_id: str, + request: CreateArtifactShareRequest, + user: User = Depends(get_current_user_from_session), + service: ArtifactShareService = Depends(get_artifact_share_service), +) -> ArtifactShareResponse: + """Share one immutable artifact version. + + The version row is looked up with a partition key built from the + authenticated session, so a caller can only share their own + artifact — someone else's version is an indistinguishable 404. + """ + try: + share = service.create( + owner=user, + artifact_id=artifact_id, + version=request.version, + access_level=request.access_level, + allowed_emails=request.allowed_emails, + ) + except ArtifactNotFoundError: + raise HTTPException( + status.HTTP_404_NOT_FOUND, "Artifact version not found" + ) + except RenderTokenConfigError: + logger.exception("artifact share service misconfigured") + raise HTTPException( + status.HTTP_500_INTERNAL_SERVER_ERROR, + "Artifact sharing is unavailable", + ) + except ArtifactQueryError: + logger.exception("artifact share write failed") + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Artifact sharing is temporarily unavailable", + ) + + return _share_response(share) + + +@artifact_shares_router.get( + "/{artifact_id}/shares", + response_model=ArtifactShareListResponse, + response_model_by_alias=True, +) +async def list_artifact_shares( + artifact_id: str, + user: User = Depends(get_current_user_from_session), + service: ArtifactShareService = Depends(get_artifact_share_service), +) -> ArtifactShareListResponse: + """List the caller's shares for one artifact. + + Partition-scoped to the authenticated user, so an unknown or + unowned artifact id is a normal empty list rather than a 404 — it + reveals nothing about whether that artifact exists. + """ + try: + shares = service.list_for_artifact( + owner_id=user.user_id, artifact_id=artifact_id + ) + except RenderTokenConfigError: + logger.exception("artifact share service misconfigured") + raise HTTPException( + status.HTTP_500_INTERNAL_SERVER_ERROR, + "Artifact sharing is unavailable", + ) + except ArtifactQueryError: + logger.exception("artifact share list query failed") + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Artifact sharing is temporarily unavailable", + ) + + return ArtifactShareListResponse( + shares=[_share_response(share) for share in shares] + ) + + +@artifact_shares_router.patch( + "/shares/{share_id}", + response_model=ArtifactShareResponse, + response_model_by_alias=True, +) +async def update_artifact_share( + share_id: str, + request: UpdateArtifactShareRequest, + user: User = Depends(get_current_user_from_session), + service: ArtifactShareService = Depends(get_artifact_share_service), +) -> ArtifactShareResponse: + """Change who may view an existing share. Owner only.""" + try: + share = service.update( + share_id=share_id, + owner=user, + access_level=request.access_level, + allowed_emails=request.allowed_emails, + ) + except ShareNotFoundError: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Share not found") + except NotShareOwnerError: + raise HTTPException( + status.HTTP_403_FORBIDDEN, + "You do not have permission to update this share", + ) + except RenderTokenConfigError: + logger.exception("artifact share service misconfigured") + raise HTTPException( + status.HTTP_500_INTERNAL_SERVER_ERROR, + "Artifact sharing is unavailable", + ) + except ArtifactQueryError: + logger.exception("artifact share update failed") + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Artifact sharing is temporarily unavailable", + ) + + return _share_response(share) + + +@artifact_shares_router.delete( + "/shares/{share_id}", status_code=status.HTTP_204_NO_CONTENT +) +async def revoke_artifact_share( + share_id: str, + user: User = Depends(get_current_user_from_session), + service: ArtifactShareService = Depends(get_artifact_share_service), +) -> Response: + """Revoke a share. Owner only. + + Deletes both rows, so the next recipient open finds nothing to mint + against. Already-issued tokens stay valid until they expire, which + bounds the revocation window at the ~120s token TTL rather than at + the length of the recipient's session. + """ + try: + service.revoke(share_id=share_id, owner=user) + except ShareNotFoundError: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Share not found") + except NotShareOwnerError: + raise HTTPException( + status.HTTP_403_FORBIDDEN, + "You do not have permission to revoke this share", + ) + except RenderTokenConfigError: + logger.exception("artifact share service misconfigured") + raise HTTPException( + status.HTTP_500_INTERNAL_SERVER_ERROR, + "Artifact sharing is unavailable", + ) + except ArtifactQueryError: + logger.exception("artifact share revoke failed") + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Artifact sharing is temporarily unavailable", + ) + + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +# ------------------------------------------------------------------ +# Recipient endpoints +# ------------------------------------------------------------------ + + +@shared_artifacts_router.get( + "", + response_model=SharedWithMeResponse, + response_model_by_alias=True, +) +async def list_shared_with_me( + limit: int = Query( + 25, ge=1, le=100, description="Maximum shares to return" + ), + cursor: str | None = Query( + None, description="Opaque continuation token from a previous page" + ), + user: User = Depends(get_current_user_from_session), + service: ArtifactShareService = Depends(get_artifact_share_service), +) -> SharedWithMeResponse: + """Artifacts other people have shared with the caller, newest first. + + Scoped by construction, like every other listing in this domain: the + partition is built from the authenticated session's email and there + is no parameter that could widen it. There is no way to ask this + endpoint about somebody else's inbox. + + Only `specific` shares appear. `public` means "any authenticated + tenant user", which has no recipient list to fan out to — those stay + link-delivered. Listing every public share in the tenant would be a + different feature with a different consent story. + + 404s while `ARTIFACT_SHARE_INBOX_ENABLED` is off, matching the + mid-turn-steering endpoint's behaviour under its own flag: the + surface does not exist in this environment, which is exactly what a + 404 says. The SPA reads that as "no tabs" and renders the library it + always did. Note the flag gates only this read — the rows behind it + are written regardless, so turning it on shows a complete inbox + rather than one that starts from the flip. + """ + if not artifact_share_inbox_enabled(): + raise HTTPException(status.HTTP_404_NOT_FOUND, "Not found") + + try: + items, next_cursor = service.list_for_recipient( + viewer=user, limit=limit, cursor=cursor + ) + except RenderTokenConfigError: + logger.exception("artifact share inbox misconfigured") + raise HTTPException( + status.HTTP_500_INTERNAL_SERVER_ERROR, + "Artifact sharing is unavailable", + ) + except ArtifactQueryError: + logger.exception("artifact share inbox query failed") + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Artifact sharing is temporarily unavailable", + ) + + return SharedWithMeResponse( + artifacts=[ + SharedWithMeArtifact( + share_id=item["share_id"], + title=item["title"], + content_type=item["content_type"], + version=item["version"], + owner_email=item["owner_email"], + shared_at=item["shared_at"], + share_url=f"/shared-artifact/{item['share_id']}", + ) + for item in items + ], + next_cursor=next_cursor, + ) + + +@shared_artifacts_router.get( + "/{share_id}", + response_model=SharedArtifactResponse, + response_model_by_alias=True, +) +async def get_shared_artifact( + share_id: str, + user: User = Depends(get_current_user_from_session), + service: ArtifactShareService = Depends(get_artifact_share_service), +) -> SharedArtifactResponse: + """Metadata for a shared artifact. Never returns content. + + Access-controlled: a revoked share is a 404 and a viewer outside the + allowlist is a 403, so a recipient learns nothing about a share they + cannot open beyond whether the link is dead or simply not theirs. + """ + try: + share = service.get_for_viewer(share_id=share_id, viewer=user) + except ShareNotFoundError: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Share not found") + except ShareAccessDeniedError: + logger.info( + "artifact share access denied share=%s viewer=%s", + scrub_log(share_id), + scrub_log(user.user_id), + ) + raise HTTPException(status.HTTP_403_FORBIDDEN, "Access denied") + except RenderTokenConfigError: + logger.exception("artifact share service misconfigured") + raise HTTPException( + status.HTTP_500_INTERNAL_SERVER_ERROR, + "Artifact sharing is unavailable", + ) + except ArtifactQueryError: + logger.exception("shared artifact lookup failed") + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Artifact sharing is temporarily unavailable", + ) + + return SharedArtifactResponse( + share_id=share["share_id"], + title=share.get("title", ""), + content_type=share.get("content_type", ""), + version=int(share.get("version", 0)), + created_at=share.get("created_at", ""), + owner_email=share.get("owner_email", ""), + can_download=True, + ) + + +@shared_artifacts_router.post( + "/{share_id}/render-token", response_model=RenderTokenResponse +) +async def mint_shared_render_token( + share_id: str, + user: User = Depends(get_current_user_from_session), + service: RenderTokenService = Depends(get_render_token_service), +) -> RenderTokenResponse: + """Mint a render token for a shared artifact version. + + Returns the same ``{url, expires_at}`` shape as the owner endpoint, + so the SPA's iframe and ``?download=1`` paths work unchanged. + + The minted token's ``sub`` is the OWNER, because it is the DynamoDB + partition key the render Lambda builds — the ACL check inside + ``mint_for_share`` is what makes that safe. Read the block comment + there before changing anything on this path. + """ + try: + url, exp = service.mint_for_share(share_id=share_id, viewer=user) + except ShareNotFoundError: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Share not found") + except ShareAccessDeniedError: + logger.info( + "artifact share mint denied share=%s viewer=%s", + scrub_log(share_id), + scrub_log(user.user_id), + ) + raise HTTPException(status.HTTP_403_FORBIDDEN, "Access denied") + except ArtifactNotFoundError: + # The share row outlived the artifact version it points at. A + # 404 beats minting a token that renders the Lambda's error page + # inside the recipient's iframe. + raise HTTPException( + status.HTTP_404_NOT_FOUND, "Artifact version not found" + ) + except RenderTokenConfigError: + logger.exception("render token service misconfigured") + raise HTTPException( + status.HTTP_500_INTERNAL_SERVER_ERROR, + "Artifact rendering is unavailable", + ) + except ArtifactQueryError: + logger.exception("shared render token lookup failed") + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Artifact rendering is temporarily unavailable", + ) + + return RenderTokenResponse( + url=url, + expires_at=datetime.fromtimestamp(exp, tz=timezone.utc).isoformat(), + ) + + +@shared_artifacts_router.get( + "/{share_id}/content", response_model=ArtifactContentResponse +) +async def get_shared_artifact_content( + share_id: str, + user: User = Depends(get_current_user_from_session), + shares: ArtifactShareService = Depends(get_artifact_share_service), + content: ArtifactContentService = Depends(get_artifact_content_service), +) -> ArtifactContentResponse: + """Raw source of a shared artifact version, for the recipient's code view. + + The parallel of ``GET /artifacts/{id}/content``, which builds its + lookup key from the authenticated session and must stay that way — + a recipient is not the owner, so that route can never serve them. + + ############################################################ + # SECURITY: the ACL check is the first thing that happens, and + # `get_for_viewer` is what performs it. Only after it admits the + # viewer may the owner's id be handed to ArtifactContentService, + # which does no access control of its own and will read whatever + # partition it is given. Resolving the owner before (or without) + # the ACL check turns this route into read-any-artifact-by-id. + ############################################################ + + The bytes are inert text the SPA highlights client-side — never + executed. Markdown is unwrapped back to the authored source, and an + oversized artifact 413s so the recipient is steered to download. + """ + try: + share = shares.get_for_viewer(share_id=share_id, viewer=user) + body, content_type = content.get( + owner_id=str(share["owner_id"]), + artifact_id=str(share["artifact_id"]), + version=int(share["version"]), + ) + except ShareNotFoundError: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Share not found") + except ShareAccessDeniedError: + logger.info( + "shared artifact content denied share=%s viewer=%s", + scrub_log(share_id), + scrub_log(user.user_id), + ) + raise HTTPException(status.HTTP_403_FORBIDDEN, "Access denied") + except ArtifactNotFoundError: + # The share outlived the version it points at, or its content + # object is gone. + raise HTTPException( + status.HTTP_404_NOT_FOUND, "Artifact version not found" + ) + except ArtifactTooLargeError: + raise HTTPException( + status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + "Artifact is too large to preview — download it instead", + ) + except RenderTokenConfigError: + logger.exception("artifact content service misconfigured") + raise HTTPException( + status.HTTP_500_INTERNAL_SERVER_ERROR, + "Artifact content is unavailable", + ) + except ArtifactQueryError: + logger.exception("shared artifact content fetch failed") + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Artifact content is temporarily unavailable", + ) + + return ArtifactContentResponse( + content=body, + content_type=content_type, + version=int(share["version"]), + ) diff --git a/backend/src/apis/app_api/chat/converse_routes.py b/backend/src/apis/app_api/chat/converse_routes.py index b2fe7b162..eff9198fd 100644 --- a/backend/src/apis/app_api/chat/converse_routes.py +++ b/backend/src/apis/app_api/chat/converse_routes.py @@ -51,6 +51,7 @@ Attribution, ) +from apis.shared.models.bedrock_responses import build_bedrock_responses_model from apis.shared.models.mantle import ( MantleApiMode, build_mantle_model, @@ -355,17 +356,29 @@ async def _stream_converse(request: ConverseRequest, user_id: str, key_id: str) # --------------------------------------------------------------------------- -# Bedrock Mantle path (OpenAI-compatible surface; provider="mantle") +# OpenAI-compatible Bedrock surfaces (provider="mantle" / "bedrock-responses") # -# Mantle models don't speak Bedrock Converse — they ride the OpenAI wire -# protocol. We reuse the SHARED Strands builder (apis.shared.models.mantle, -# same one the agent factory uses) and invoke the bare model's `.stream()`, -# which yields the same Converse-shaped events the Bedrock path emits — so the -# SSE translation and usage/cost accounting are identical. +# Neither speaks Bedrock Converse — both ride the OpenAI wire protocol. We +# reuse the SHARED Strands builders (apis.shared.models.mantle and +# apis.shared.models.bedrock_responses, the same ones the agent factory uses) +# and invoke the bare model's `.stream()`, which yields the same +# Converse-shaped events the Bedrock path emits — so one set of SSE +# translation and usage/cost accounting serves both. +# +# The two surfaces differ only in construction: host, auth scope and model-id +# shape. Everything downstream of `_build_request_openai_model` is shared. # --------------------------------------------------------------------------- -def _build_mantle_params(request: ConverseRequest, api_mode: MantleApiMode) -> dict: - """Translate the request's canonical inference params to Mantle-native names.""" +# Providers that ride the OpenAI wire protocol rather than Bedrock Converse. +OPENAI_SURFACE_PROVIDERS = ("mantle", "bedrock-responses") + + +def _build_openai_params(request: ConverseRequest, api_mode: MantleApiMode) -> dict: + """Translate the request's canonical inference params to OpenAI-native names. + + Keyed off the API surface, not the transport: Mantle-Responses and + bedrock-runtime Responses share one native param vocabulary. + """ pmap = param_map_for(api_mode) canonical = { "temperature": request.temperature, @@ -381,35 +394,56 @@ def _build_mantle_params(request: ConverseRequest, api_mode: MantleApiMode) -> d return params -def _build_request_mantle_model(request: ConverseRequest, api_mode: MantleApiMode, region: Optional[str]): - """Construct the shared Strands Mantle model for this request.""" +def _build_request_openai_model( + request: ConverseRequest, + provider: str, + api_mode: MantleApiMode, + region: Optional[str], +): + """Construct the shared Strands model for this request's OpenAI surface. + + Args: + request: The converse request. + provider: ``"mantle"`` or ``"bedrock-responses"``. + api_mode: Chat Completions vs Responses. Always Responses on the + bedrock-runtime transport, which serves no other surface here. + region: Optional region override for the endpoint and token signature. + """ + params = _build_openai_params(request, api_mode) or None + if provider == "bedrock-responses": + return build_bedrock_responses_model( + model_id=request.model_id, + region=region or None, + params=params, + ) return build_mantle_model( model_id=request.model_id, api_mode=api_mode, region=region or None, - params=_build_mantle_params(request, api_mode) or None, + params=params, ) -def _mantle_messages(request: ConverseRequest) -> list[dict]: +def _openai_surface_messages(request: ConverseRequest) -> list[dict]: """Convert request messages to the Converse content-block format.""" return [{"role": m.role, "content": [{"text": m.content}]} for m in request.messages] -async def _stream_mantle( +async def _stream_openai_surface( request: ConverseRequest, user_id: str, key_id: str, + provider: str, api_mode: MantleApiMode, region: Optional[str], ) -> AsyncGenerator[str, None]: - """Invoke a Mantle model via Strands and yield the same SSE shape as Bedrock.""" + """Invoke an OpenAI-surface model via Strands, in the Bedrock SSE shape.""" try: - model = _build_request_mantle_model(request, api_mode, region) - messages = _mantle_messages(request) + model = _build_request_openai_model(request, provider, api_mode, region) + messages = _openai_surface_messages(request) system_prompt = request.system_prompt or None except Exception: - logger.error("Failed to build Mantle model", exc_info=True) + logger.error("Failed to build %s model", provider, exc_info=True) yield _sse("error", {"error": "Model invocation failed due to an internal error."}) yield _sse("done", {}) return @@ -420,7 +454,7 @@ async def _stream_mantle( for frame in _converse_event_to_sse(event, state): yield frame except Exception: - logger.error("Bedrock Mantle stream error", exc_info=True) + logger.error("%s stream error", provider, exc_info=True) yield _sse("error", {"error": "Model invocation failed due to a service error."}) yield _sse("done", {}) return @@ -430,24 +464,25 @@ async def _stream_mantle( if state["usage"]: await _record_cost( user_id=user_id, model_id=request.model_id, usage=state["usage"], - key_id=key_id, provider="mantle", + key_id=key_id, provider=provider, ) -async def _mantle_converse( +async def _openai_surface_converse( request: ConverseRequest, user_id: str, key_id: str, + provider: str, api_mode: MantleApiMode, region: Optional[str], ) -> ConverseResponse: - """Non-streaming Mantle converse: consume the model stream and aggregate.""" + """Non-streaming OpenAI-surface converse: consume the stream and aggregate.""" try: - model = _build_request_mantle_model(request, api_mode, region) - messages = _mantle_messages(request) + model = _build_request_openai_model(request, provider, api_mode, region) + messages = _openai_surface_messages(request) system_prompt = request.system_prompt or None except Exception: - logger.error("Failed to build Mantle model", exc_info=True) + logger.error("Failed to build %s model", provider, exc_info=True) raise HTTPException(status_code=502, detail="Model invocation failed due to an internal error.") text_parts: list[str] = [] @@ -469,13 +504,13 @@ async def _mantle_converse( elif "metadata" in event: usage = event["metadata"].get("usage", {}) except Exception: - logger.error("Bedrock Mantle converse error", exc_info=True) + logger.error("%s converse error", provider, exc_info=True) raise HTTPException(status_code=502, detail="Model invocation failed due to a service error.") if usage: await _record_cost( user_id=user_id, model_id=request.model_id, usage=usage, - key_id=key_id, provider="mantle", + key_id=key_id, provider=provider, ) return ConverseResponse( @@ -600,22 +635,31 @@ async def api_converse( detail=f"Access denied to model: {request.model_id}", ) - # 2.8 Provider routing — Bedrock Converse vs Bedrock Mantle (OpenAI wire). + # 2.8 Provider routing — Bedrock Converse vs an OpenAI-compatible Bedrock + # surface (Mantle, or the Responses API on bedrock-runtime). provider, mantle_api_mode, mantle_region = await _resolve_model_routing(request.model_id) - is_mantle = (provider or "").lower() == "mantle" + normalized_provider = (provider or "").lower() + is_openai_surface = normalized_provider in OPENAI_SURFACE_PROVIDERS try: api_mode = ( MantleApiMode(mantle_api_mode) if mantle_api_mode else MantleApiMode.CHAT_COMPLETIONS ) except ValueError: api_mode = MantleApiMode.CHAT_COMPLETIONS + if normalized_provider == "bedrock-responses": + # Not admin-selectable: that transport exists because GPT-5.6 caches + # only over the Responses API. Mirrors the same normalization applied + # when the model record is written (apis/shared/models/managed_models.py), + # so a legacy row that predates it can't silently downgrade the model + # to an uncached Chat Completions call. + api_mode = MantleApiMode.RESPONSES # 3. Streaming path if request.stream: - if is_mantle: - generator = _stream_mantle( + if is_openai_surface: + generator = _stream_openai_surface( request, user_id=validated_key.user_id, key_id=validated_key.key_id, - api_mode=api_mode, region=mantle_region, + provider=normalized_provider, api_mode=api_mode, region=mantle_region, ) else: generator = _stream_converse( @@ -630,11 +674,11 @@ async def api_converse( }, ) - # 4. Non-streaming path — Mantle (Strands) vs Bedrock Converse (boto3). - if is_mantle: - return await _mantle_converse( + # 4. Non-streaming path — OpenAI surface (Strands) vs Bedrock Converse (boto3). + if is_openai_surface: + return await _openai_surface_converse( request, user_id=validated_key.user_id, key_id=validated_key.key_id, - api_mode=api_mode, region=mantle_region, + provider=normalized_provider, api_mode=api_mode, region=mantle_region, ) client = _get_bedrock_client() diff --git a/backend/src/apis/app_api/fine_tuning/dependencies.py b/backend/src/apis/app_api/fine_tuning/dependencies.py index e08e0b0f2..8e33d937f 100644 --- a/backend/src/apis/app_api/fine_tuning/dependencies.py +++ b/backend/src/apis/app_api/fine_tuning/dependencies.py @@ -5,14 +5,25 @@ from fastapi import Depends, HTTPException, status from apis.shared.auth import User from apis.shared.auth.dependencies import get_current_user_from_session -from .repository import FineTuningAccessRepository, get_fine_tuning_access_repository +from .repository import ( + LEGACY_HOURS_TO_USD, + FineTuningAccessRepository, + get_fine_tuning_access_repository, +) logger = logging.getLogger(__name__) -# Default monthly GPU-hour quota for users without an explicit grant. +# Default monthly dollar quota for users without an explicit grant. # Set to 0 to revert to whitelist-only mode (original behaviour). -DEFAULT_MONTHLY_QUOTA_HOURS = float( - os.environ.get("FINE_TUNING_DEFAULT_QUOTA_HOURS", "0") +# +# FINE_TUNING_DEFAULT_QUOTA_HOURS is still read as a fallback so an +# environment configured before the quota moved to dollars keeps working; its +# value is converted at the ml.g5.xlarge rate those hours were spent at. +DEFAULT_MONTHLY_QUOTA_USD = float( + os.environ.get("FINE_TUNING_DEFAULT_QUOTA_USD", "0") +) or ( + float(os.environ.get("FINE_TUNING_DEFAULT_QUOTA_HOURS", "0")) + * LEGACY_HOURS_TO_USD ) @@ -22,7 +33,7 @@ async def require_fine_tuning_access( ) -> dict: """FastAPI dependency that enforces fine-tuning access. - Behaviour depends on ``FINE_TUNING_DEFAULT_QUOTA_HOURS``: + Behaviour depends on ``FINE_TUNING_DEFAULT_QUOTA_USD``: * **0 (default / whitelist mode):** Only users with an explicit grant in the ``fine-tuning-access`` table are allowed. Anyone else @@ -41,17 +52,17 @@ async def require_fine_tuning_access( return grant # No explicit grant exists for this user. - if DEFAULT_MONTHLY_QUOTA_HOURS > 0: + if DEFAULT_MONTHLY_QUOTA_USD > 0: # Open-access mode: auto-provision a grant with the default quota. logger.info( f"Auto-provisioning fine-tuning access for {user.email} " - f"with {DEFAULT_MONTHLY_QUOTA_HOURS}h default quota" + f"with ${DEFAULT_MONTHLY_QUOTA_USD:.2f} default quota" ) try: new_grant = repo.grant_access( email=user.email, granted_by="system-default", - monthly_quota_hours=DEFAULT_MONTHLY_QUOTA_HOURS, + monthly_quota_usd=DEFAULT_MONTHLY_QUOTA_USD, ) return new_grant except ValueError: diff --git a/backend/src/apis/app_api/fine_tuning/inference_models.py b/backend/src/apis/app_api/fine_tuning/inference_models.py index 2e1adb423..9f36217da 100644 --- a/backend/src/apis/app_api/fine_tuning/inference_models.py +++ b/backend/src/apis/app_api/fine_tuning/inference_models.py @@ -1,6 +1,8 @@ """Pydantic models for SageMaker Batch Transform inference jobs.""" from pydantic import BaseModel, Field + +from . import task_types from typing import List, Optional @@ -52,6 +54,9 @@ class TrainedModelResponse(BaseModel): training_job_id: str model_id: str model_name: str + #: The task this model was fine-tuned for. Drives which input formats the + #: inference form will accept — an image classifier cannot read a .txt. + task_type: str = task_types.DEFAULT_TASK_TYPE model_s3_path: str instance_type: str completed_at: Optional[str] = None diff --git a/backend/src/apis/app_api/fine_tuning/job_models.py b/backend/src/apis/app_api/fine_tuning/job_models.py index cc1a55238..6620bc2ab 100644 --- a/backend/src/apis/app_api/fine_tuning/job_models.py +++ b/backend/src/apis/app_api/fine_tuning/job_models.py @@ -1,8 +1,11 @@ -"""Pydantic models, model catalog, and cost map for fine-tuning training jobs.""" +"""Pydantic models and the base-model catalog for fine-tuning training jobs.""" -from pydantic import BaseModel, Field from typing import Dict, List, Optional +from pydantic import BaseModel, Field + +from . import task_types + # ========================================================================= # Model Catalog @@ -14,226 +17,238 @@ class AvailableModel(BaseModel): model_name: str huggingface_model_id: str description: str + #: Which task this checkpoint can be fine-tuned for. A model is only + #: offered for its own task: a ViT cannot classify text and a BERT cannot + #: classify images, and letting the two mix produces a job that fails + #: several billed minutes into a GPU run. + task_type: str default_instance_type: str default_hyperparameters: Dict[str, str] +def _hyperparameters(task_type: str, **overrides: str) -> Dict[str, str]: + """Task defaults with per-model overrides applied. + + Keeps the catalog readable: a model entry states only what makes it + different, instead of repeating seven identical keys. + """ + spec = task_types.get_task_spec(task_type) + return {**spec.default_hyperparameters, **overrides} + + +_TEXT = task_types.TEXT_CLASSIFICATION +_IMAGE = task_types.IMAGE_CLASSIFICATION +_IMAGE_TEXT = task_types.IMAGE_TEXT_CLASSIFICATION + + AVAILABLE_MODELS: List[AvailableModel] = [ + # --------------------------------------------------------------- + # Text classification + # --------------------------------------------------------------- AvailableModel( model_id="bert-base-uncased", model_name="BERT Base Uncased", huggingface_model_id="bert-base-uncased", description="110M parameter masked language model from Google, widely used baseline for NLP tasks", + task_type=_TEXT, default_instance_type="ml.g5.xlarge", - default_hyperparameters={ - "epochs": "3", - "per_device_train_batch_size": "16", - "learning_rate": "5e-5", - "weight_decay": "0.01", - "split_ratio": "0.8", - "seed": "42", - "context_length": "512", - }, + default_hyperparameters=_hyperparameters(_TEXT), ), AvailableModel( model_id="roberta-base", model_name="RoBERTa Base", huggingface_model_id="roberta-base", description="125M parameter robustly optimized BERT from Meta, strong on classification and NLU", + task_type=_TEXT, default_instance_type="ml.g5.xlarge", - default_hyperparameters={ - "epochs": "3", - "per_device_train_batch_size": "16", - "learning_rate": "5e-5", - "weight_decay": "0.01", - "split_ratio": "0.8", - "seed": "42", - "context_length": "512", - }, + default_hyperparameters=_hyperparameters(_TEXT), ), AvailableModel( model_id="electra-base", model_name="ELECTRA", huggingface_model_id="google/electra-base-discriminator", description="110M parameter discriminative model from Google, efficient pre-training with replaced token detection", + task_type=_TEXT, default_instance_type="ml.g5.xlarge", - default_hyperparameters={ - "epochs": "3", - "per_device_train_batch_size": "16", - "learning_rate": "5e-5", - "weight_decay": "0.01", - "split_ratio": "0.8", - "seed": "42", - "context_length": "512", - }, + default_hyperparameters=_hyperparameters(_TEXT), ), AvailableModel( model_id="electra-tiny", model_name="ELECTRA Tiny", huggingface_model_id="bsu-slim/electra-tiny", description="Tiny ELECTRA variant, very fast training for prototyping and experimentation", + task_type=_TEXT, default_instance_type="ml.g5.xlarge", - default_hyperparameters={ - "epochs": "3", - "per_device_train_batch_size": "32", - "learning_rate": "5e-5", - "weight_decay": "0.01", - "split_ratio": "0.8", - "seed": "42", - "context_length": "512", - }, + default_hyperparameters=_hyperparameters(_TEXT, per_device_train_batch_size="32"), ), AvailableModel( model_id="electra-tiny-mm", model_name="ELECTRA Tiny Multimodal", huggingface_model_id="bsu-slim/electra-tiny-mm", description="Multimodal tiny ELECTRA variant for cross-modal experimentation", + task_type=_TEXT, default_instance_type="ml.g5.xlarge", - default_hyperparameters={ - "epochs": "3", - "per_device_train_batch_size": "32", - "learning_rate": "5e-5", - "weight_decay": "0.01", - "split_ratio": "0.8", - "seed": "42", - "context_length": "512", - }, + default_hyperparameters=_hyperparameters(_TEXT, per_device_train_batch_size="32"), ), AvailableModel( model_id="childes-bert", model_name="BERT ChildES", huggingface_model_id="smeylan/childes-bert", description="BERT model pre-trained on child-directed speech, suited for developmental language research", + task_type=_TEXT, default_instance_type="ml.g5.xlarge", - default_hyperparameters={ - "epochs": "3", - "per_device_train_batch_size": "16", - "learning_rate": "5e-5", - "weight_decay": "0.01", - "split_ratio": "0.8", - "seed": "42", - "context_length": "512", - }, + default_hyperparameters=_hyperparameters(_TEXT), ), AvailableModel( model_id="distilgpt2", model_name="Distilled GPT2", huggingface_model_id="distilbert/distilgpt2", description="82M parameter distilled GPT-2, lightweight causal language model for fast iteration", + task_type=_TEXT, default_instance_type="ml.g5.xlarge", - default_hyperparameters={ - "epochs": "3", - "per_device_train_batch_size": "16", - "learning_rate": "5e-5", - "weight_decay": "0.01", - "split_ratio": "0.8", - "seed": "42", - "context_length": "512", - }, + default_hyperparameters=_hyperparameters(_TEXT), ), AvailableModel( model_id="childgpt", model_name="ChildGPT", huggingface_model_id="Aunsiels/ChildGPT", description="GPT model trained on child language data for developmental linguistics research", + task_type=_TEXT, default_instance_type="ml.g5.xlarge", - default_hyperparameters={ - "epochs": "3", - "per_device_train_batch_size": "16", - "learning_rate": "5e-5", - "weight_decay": "0.01", - "split_ratio": "0.8", - "seed": "42", - "context_length": "512", - }, + default_hyperparameters=_hyperparameters(_TEXT), ), AvailableModel( model_id="gpt2-medium", model_name="GPT2 Medium", huggingface_model_id="openai-community/gpt2-medium", description="355M parameter GPT-2 medium from OpenAI, good balance of capability and efficiency", + task_type=_TEXT, default_instance_type="ml.g5.xlarge", - default_hyperparameters={ - "epochs": "3", - "per_device_train_batch_size": "8", - "learning_rate": "2e-5", - "weight_decay": "0.01", - "split_ratio": "0.8", - "seed": "42", - "context_length": "512", - }, + default_hyperparameters=_hyperparameters( + _TEXT, per_device_train_batch_size="8", learning_rate="2e-5" + ), ), AvailableModel( model_id="eurollm-1.7b-instruct", model_name="EuroLLM 1.7B Instruct", huggingface_model_id="utter-project/EuroLLM-1.7B-Instruct", description="1.7B parameter multilingual European LLM with instruction tuning", + task_type=_TEXT, default_instance_type="ml.g5.xlarge", - default_hyperparameters={ - "epochs": "3", - "per_device_train_batch_size": "4", - "learning_rate": "2e-5", - "weight_decay": "0.01", - "split_ratio": "0.8", - "seed": "42", - "context_length": "512", - }, + default_hyperparameters=_hyperparameters( + _TEXT, per_device_train_batch_size="4", learning_rate="2e-5" + ), ), AvailableModel( model_id="smollm2-135m-instruct", model_name="SmolLM2 135M Instruct", huggingface_model_id="HuggingFaceTB/SmolLM2-135M-Instruct", description="135M parameter instruction-tuned model from HuggingFace, ultra-lightweight for fast experiments", + task_type=_TEXT, default_instance_type="ml.g5.xlarge", - default_hyperparameters={ - "epochs": "3", - "per_device_train_batch_size": "32", - "learning_rate": "5e-5", - "weight_decay": "0.01", - "split_ratio": "0.8", - "seed": "42", - "context_length": "512", - }, + default_hyperparameters=_hyperparameters(_TEXT, per_device_train_batch_size="32"), + ), + # --------------------------------------------------------------- + # Image classification + # --------------------------------------------------------------- + AvailableModel( + model_id="vit-base", + model_name="ViT Base", + huggingface_model_id="google/vit-base-patch16-224", + description="86M parameter Vision Transformer from Google, the standard baseline for image classification", + task_type=_IMAGE, + default_instance_type="ml.g6.xlarge", + default_hyperparameters=_hyperparameters(_IMAGE), + ), + AvailableModel( + model_id="resnet-50", + model_name="ResNet-50", + huggingface_model_id="microsoft/resnet-50", + description="25M parameter convolutional network, fast to train and a strong classical baseline", + task_type=_IMAGE, + default_instance_type="ml.g6.xlarge", + default_hyperparameters=_hyperparameters(_IMAGE, per_device_train_batch_size="32"), + ), + AvailableModel( + model_id="convnext-tiny", + model_name="ConvNeXt Tiny", + huggingface_model_id="facebook/convnext-tiny-224", + description="29M parameter modernised convolutional network from Meta, competitive with transformers at low cost", + task_type=_IMAGE, + default_instance_type="ml.g6.xlarge", + default_hyperparameters=_hyperparameters(_IMAGE, per_device_train_batch_size="32"), + ), + AvailableModel( + model_id="swin-tiny", + model_name="Swin Tiny", + huggingface_model_id="microsoft/swin-tiny-patch4-window7-224", + description="28M parameter hierarchical vision transformer from Microsoft, strong on fine-grained detail", + task_type=_IMAGE, + default_instance_type="ml.g6.xlarge", + default_hyperparameters=_hyperparameters(_IMAGE), + ), + # --------------------------------------------------------------- + # Image + text classification + # + # These must be dual encoders exposing get_image_features and + # get_text_features — the fusion head reads both towers. + # --------------------------------------------------------------- + AvailableModel( + model_id="clip-vit-base", + model_name="CLIP ViT-B/32", + huggingface_model_id="openai/clip-vit-base-patch32", + description="151M parameter image/text dual encoder from OpenAI, the standard baseline for cross-modal tasks", + task_type=_IMAGE_TEXT, + default_instance_type="ml.g6.xlarge", + default_hyperparameters=_hyperparameters(_IMAGE_TEXT), + ), + AvailableModel( + model_id="clip-vit-large", + model_name="CLIP ViT-L/14", + huggingface_model_id="openai/clip-vit-large-patch14", + description="428M parameter CLIP from OpenAI, higher accuracy at meaningfully higher training cost", + task_type=_IMAGE_TEXT, + default_instance_type="ml.g6.xlarge", + default_hyperparameters=_hyperparameters(_IMAGE_TEXT, per_device_train_batch_size="8"), + ), + AvailableModel( + model_id="siglip-base", + model_name="SigLIP Base", + huggingface_model_id="google/siglip-base-patch16-224", + description="203M parameter dual encoder from Google using a sigmoid loss, stronger than CLIP at equal size", + task_type=_IMAGE_TEXT, + default_instance_type="ml.g6.xlarge", + default_hyperparameters=_hyperparameters(_IMAGE_TEXT, context_length="64"), + ), + AvailableModel( + model_id="clip-laion-b32", + model_name="CLIP ViT-B/32 (LAION-2B)", + huggingface_model_id="laion/CLIP-ViT-B-32-laion2B-s34B-b79K", + description="Open-data CLIP trained on LAION-2B, a reproducible alternative to the OpenAI weights", + task_type=_IMAGE_TEXT, + default_instance_type="ml.g6.xlarge", + default_hyperparameters=_hyperparameters(_IMAGE_TEXT), ), ] MODEL_CATALOG: Dict[str, AvailableModel] = {m.model_id: m for m in AVAILABLE_MODELS} -# ========================================================================= -# Instance Cost Map (on-demand USD/hour, us-west-2 pricing) -# ========================================================================= - -INSTANCE_COST_PER_HOUR: Dict[str, float] = { - "ml.g5.xlarge": 1.41, - "ml.g5.2xlarge": 1.515, - "ml.g5.4xlarge": 2.03, - "ml.g5.8xlarge": 3.06, - "ml.g5.12xlarge": 7.09, - "ml.g5.16xlarge": 6.10, - "ml.g5.24xlarge": 10.18, - "ml.g5.48xlarge": 20.36, - "ml.p3.2xlarge": 3.825, - "ml.p3.8xlarge": 14.688, - "ml.p3.16xlarge": 28.152, -} +def models_for_task(task_type: Optional[str]) -> List[AvailableModel]: + """Catalog entries trainable for ``task_type``, in catalog order.""" + resolved = task_types.get_task_spec(task_type).task_type + return [m for m in AVAILABLE_MODELS if m.task_type == resolved] # ========================================================================= # Request / Response Models # ========================================================================= -# Dataset formats the SageMaker training script can read — keep in sync with -# SUPPORTED_DATASET_EXTENSIONS in fine_tuning/sagemaker_scripts/train.py. -# Enforced here so an unreadable dataset is rejected before a GPU instance is -# ever provisioned; otherwise the job fails several billed minutes in. -SUPPORTED_DATASET_EXTENSIONS = (".csv", ".jsonl", ".json") - - class PresignRequest(BaseModel): """Request for a presigned upload URL for a training dataset.""" filename: str content_type: str + task_type: str = task_types.DEFAULT_TASK_TYPE class PresignResponse(BaseModel): @@ -247,6 +262,7 @@ class CreateJobRequest(BaseModel): """Request to create a new fine-tuning training job.""" model_id: str dataset_s3_key: str + task_type: str = task_types.DEFAULT_TASK_TYPE instance_type: Optional[str] = None hyperparameters: Optional[Dict[str, str]] = None max_runtime_seconds: int = Field(default=86400, le=432000, gt=0) @@ -260,6 +276,7 @@ class JobResponse(BaseModel): email: str model_id: str model_name: str + task_type: str = task_types.DEFAULT_TASK_TYPE status: str dataset_s3_key: str output_s3_prefix: Optional[str] = None @@ -282,3 +299,15 @@ class JobListResponse(BaseModel): """Response for listing training jobs.""" jobs: List[JobResponse] total_count: int + + +class TaskTypeResponse(BaseModel): + """A task type offered by the platform, for the create-job UI.""" + task_type: str + display_name: str + description: str + required_columns: List[str] + upload_extensions: List[str] + requires_archive: bool + inference_upload_extensions: List[str] + default_instance_type: str diff --git a/backend/src/apis/app_api/fine_tuning/job_repository.py b/backend/src/apis/app_api/fine_tuning/job_repository.py index f26c2e871..adf6011f6 100644 --- a/backend/src/apis/app_api/fine_tuning/job_repository.py +++ b/backend/src/apis/app_api/fine_tuning/job_repository.py @@ -9,6 +9,8 @@ import boto3 from botocore.exceptions import ClientError +from . import task_types + logger = logging.getLogger(__name__) @@ -47,6 +49,9 @@ def _item_to_dict(self, item: dict) -> dict: "email": item["email"], "model_id": item["model_id"], "model_name": item["model_name"], + # Jobs written before task types existed carry no attribute; they + # are all text classifiers. + "task_type": item.get("task_type", task_types.DEFAULT_TASK_TYPE), "status": item["status"], "dataset_s3_key": item["dataset_s3_key"], "output_s3_prefix": item.get("output_s3_prefix"), @@ -79,6 +84,7 @@ def create_job( sagemaker_job_name: str, output_s3_prefix: str, max_runtime_seconds: int = 86400, + task_type: str = task_types.DEFAULT_TASK_TYPE, ) -> dict: """Create a new training job record.""" now = datetime.now(timezone.utc).isoformat() @@ -91,6 +97,7 @@ def create_job( "email": email, "model_id": model_id, "model_name": model_name, + "task_type": task_type, "status": "PENDING", "dataset_s3_key": dataset_s3_key, "output_s3_prefix": output_s3_prefix, diff --git a/backend/src/apis/app_api/fine_tuning/models.py b/backend/src/apis/app_api/fine_tuning/models.py index 9cfe1b690..c33db15eb 100644 --- a/backend/src/apis/app_api/fine_tuning/models.py +++ b/backend/src/apis/app_api/fine_tuning/models.py @@ -1,29 +1,37 @@ -"""Pydantic models for fine-tuning access control and quota.""" +"""Pydantic models for fine-tuning access control and quota. + +The quota is denominated in **US dollars**, not GPU-hours. Hours were a +proxy that stopped tracking the thing being budgeted the moment more than one +instance type was offered: ten hours buys about $14 on an ml.g5.xlarge and +roughly $450 on an ml.g6e.24xlarge. Grants written against the old field are +migrated lazily on read — see ``repository.FineTuningAccessRepository``. +""" -from pydantic import BaseModel, Field from typing import Optional +from pydantic import BaseModel, Field + class FineTuningAccessGrant(BaseModel): """DynamoDB item shape for a fine-tuning access grant.""" email: str granted_by: str granted_at: str - monthly_quota_hours: float = Field(default=10.0) - current_month_usage_hours: float = Field(default=0.0) + monthly_quota_usd: float = Field(default=15.0) + current_month_usage_usd: float = Field(default=0.0) quota_period: str = Field(description="YYYY-MM format for lazy reset detection") class FineTuningAccessResponse(BaseModel): """User-facing response for access check.""" has_access: bool - monthly_quota_hours: Optional[float] = None - current_month_usage_hours: Optional[float] = None + monthly_quota_usd: Optional[float] = None + current_month_usage_usd: Optional[float] = None quota_period: Optional[str] = None class QuotaCheckResult(BaseModel): """Internal result of a quota check before job creation.""" allowed: bool - remaining_hours: float + remaining_usd: float message: str diff --git a/backend/src/apis/app_api/fine_tuning/pricing.py b/backend/src/apis/app_api/fine_tuning/pricing.py new file mode 100644 index 000000000..efece7068 --- /dev/null +++ b/backend/src/apis/app_api/fine_tuning/pricing.py @@ -0,0 +1,164 @@ +"""SageMaker instance pricing and capability map for fine-tuning. + +Rates are **us-west-2 on-demand USD/hour**, taken from the AWS Price List API +(``aws pricing get-products --service-code AmazonSageMaker``), filtered to the +``Training`` and ``BatchTransform`` components. They are not from the pricing +web page, which rounds and lags. + +Two things this module gets right that a single flat map could not: + +1. **Training and Batch Transform are priced separately.** They agree across + the g5 family but diverge on g6e (e.g. ml.g6e.24xlarge is $18.83/hr to + train and $18.8319875/hr to transform), and some instances are offered for + one and not the other at all. +2. **Not every training instance can run Batch Transform.** ml.p4d.24xlarge + and ml.p5.48xlarge publish a Training rate and no BatchTransform rate — an + inference job on one is rejected by AWS. Absence from + :data:`TRANSFORM_COST_PER_HOUR` is how we refuse it before billing. + +``ml.p3.*`` is deliberately absent: the Price List API returns no on-demand +SageMaker rate for that family in us-west-2 at all, so the three entries this +map used to carry priced instances a job could never actually provision. +``ml.p4d.24xlarge`` and ``ml.p5.48xlarge`` are also left out — they train but +cannot Batch Transform, so offering them would let a researcher fine-tune a +model they then have no way to run inference on. Add them only alongside a +non-Batch-Transform inference path. + +Pricing accuracy is now load-bearing rather than cosmetic: the monthly quota +is denominated in dollars, so a wrong rate does not just misreport spend, it +mis-enforces the budget. Re-run ``backend/scripts/refresh_instance_pricing.py`` to +resync after an AWS price change. +""" + +from typing import Dict, Optional, Tuple + + +# ========================================================================= +# Training rates (USD/hour, us-west-2 on-demand) +# ========================================================================= + +TRAINING_COST_PER_HOUR: Dict[str, float] = { + # --- G5 (NVIDIA A10G, 24GB) --- + "ml.g5.xlarge": 1.408, + "ml.g5.2xlarge": 1.515, + "ml.g5.4xlarge": 2.03, + "ml.g5.8xlarge": 3.06, + "ml.g5.12xlarge": 7.09, + "ml.g5.16xlarge": 5.12, + "ml.g5.24xlarge": 10.18, + "ml.g5.48xlarge": 20.36, + # --- G6 (NVIDIA L4, 24GB) — newer and cheaper than G5 at every size --- + "ml.g6.xlarge": 1.127, + "ml.g6.2xlarge": 1.222, + "ml.g6.4xlarge": 1.654, + "ml.g6.8xlarge": 2.518, + "ml.g6.12xlarge": 5.752, + "ml.g6.24xlarge": 8.344, + "ml.g6.48xlarge": 16.688, + # --- G6e (NVIDIA L40S, 48GB) — the memory headroom vision models want --- + "ml.g6e.xlarge": 2.61, + "ml.g6e.2xlarge": 2.8, + "ml.g6e.4xlarge": 3.76, + "ml.g6e.8xlarge": 5.66, + "ml.g6e.12xlarge": 13.12, + "ml.g6e.24xlarge": 18.83, + "ml.g6e.48xlarge": 37.66, +} + + +# ========================================================================= +# Batch Transform rates (USD/hour, us-west-2 on-demand) +# ========================================================================= + +TRANSFORM_COST_PER_HOUR: Dict[str, float] = { + "ml.g5.xlarge": 1.408, + "ml.g5.2xlarge": 1.515, + "ml.g5.4xlarge": 2.03, + "ml.g5.8xlarge": 3.06, + "ml.g5.12xlarge": 7.09, + "ml.g5.16xlarge": 5.12, + "ml.g5.24xlarge": 10.18, + "ml.g5.48xlarge": 20.36, + "ml.g6.xlarge": 1.1267, + "ml.g6.2xlarge": 1.222, + "ml.g6.4xlarge": 1.654, + "ml.g6.8xlarge": 2.518, + "ml.g6.12xlarge": 5.752, + "ml.g6.24xlarge": 8.344, + "ml.g6.48xlarge": 16.688, + "ml.g6e.xlarge": 2.6054, + "ml.g6e.2xlarge": 2.8026, + "ml.g6e.4xlarge": 3.7553, + "ml.g6e.8xlarge": 5.6607, + "ml.g6e.12xlarge": 13.1158, + "ml.g6e.24xlarge": 18.8319875, + "ml.g6e.48xlarge": 37.663975, +} + + +# ========================================================================= +# Accelerator memory (GB of GPU VRAM per instance, summed across GPUs) +# ========================================================================= + +# Used to warn a user before they submit a model that cannot fit, rather than +# letting them discover it as a CUDA OOM several billed minutes in. +ACCELERATOR_MEMORY_GB: Dict[str, int] = { + "ml.g5.xlarge": 24, "ml.g5.2xlarge": 24, "ml.g5.4xlarge": 24, + "ml.g5.8xlarge": 24, "ml.g5.16xlarge": 24, + "ml.g5.12xlarge": 96, "ml.g5.24xlarge": 96, "ml.g5.48xlarge": 192, + "ml.g6.xlarge": 24, "ml.g6.2xlarge": 24, "ml.g6.4xlarge": 24, + "ml.g6.8xlarge": 24, "ml.g6.16xlarge": 24, + "ml.g6.12xlarge": 96, "ml.g6.24xlarge": 96, "ml.g6.48xlarge": 192, + "ml.g6e.xlarge": 48, "ml.g6e.2xlarge": 48, "ml.g6e.4xlarge": 48, + "ml.g6e.8xlarge": 48, + "ml.g6e.12xlarge": 192, "ml.g6e.24xlarge": 192, "ml.g6e.48xlarge": 384, +} + + +# ========================================================================= +# Lookups +# ========================================================================= + +def training_rate(instance_type: str) -> Optional[float]: + """USD/hour to train on ``instance_type``, or None if we have no rate.""" + return TRAINING_COST_PER_HOUR.get(instance_type) + + +def transform_rate(instance_type: str) -> Optional[float]: + """USD/hour to Batch Transform on ``instance_type``, or None if unsupported.""" + return TRANSFORM_COST_PER_HOUR.get(instance_type) + + +def calculate_cost( + instance_type: str, billable_seconds: int, *, transform: bool = False +) -> float: + """Cost in USD for ``billable_seconds`` on ``instance_type``. + + Returns 0.0 for an instance we have no rate for. Callers must not rely on + that to mean "free" — validate the instance up front instead; a silent + 0.0 is exactly the blind spot that lets unpriced GPU time go unbilled. + """ + rate = transform_rate(instance_type) if transform else training_rate(instance_type) + return round((rate or 0.0) * (billable_seconds / 3600), 4) + + +def estimate_max_cost( + instance_type: str, max_runtime_seconds: int, *, transform: bool = False +) -> float: + """Worst-case cost if a job runs to its full ``max_runtime_seconds``. + + This is what the dollar quota reserves against at submission time: the + actual bill is only known when the job stops, so admitting a job on its + *current* spend would let a single long run overshoot the budget. + """ + return calculate_cost(instance_type, max_runtime_seconds, transform=transform) + + +def supported_training_instances() -> Tuple[str, ...]: + """Instance types we can price for training, cheapest first.""" + return tuple(sorted(TRAINING_COST_PER_HOUR, key=lambda i: TRAINING_COST_PER_HOUR[i])) + + +def supported_transform_instances() -> Tuple[str, ...]: + """Instance types we can price for Batch Transform, cheapest first.""" + return tuple(sorted(TRANSFORM_COST_PER_HOUR, key=lambda i: TRANSFORM_COST_PER_HOUR[i])) diff --git a/backend/src/apis/app_api/fine_tuning/repository.py b/backend/src/apis/app_api/fine_tuning/repository.py index 7c573c854..b97174bac 100644 --- a/backend/src/apis/app_api/fine_tuning/repository.py +++ b/backend/src/apis/app_api/fine_tuning/repository.py @@ -1,4 +1,4 @@ -"""DynamoDB repository for fine-tuning access control table.""" +"""DynamoDB repository for the fine-tuning access control table.""" import os import logging @@ -9,8 +9,19 @@ import boto3 from botocore.exceptions import ClientError +from . import pricing + logger = logging.getLogger(__name__) +#: Default dollar quota for a new grant. Approximately what the previous +#: 10 GPU-hour default bought on the ml.g5.xlarge every job actually ran on. +DEFAULT_QUOTA_USD = 15.0 + +#: Rate used to convert a legacy hours-denominated grant to dollars. Every +#: grant written before the dollar quota existed was spent on ml.g5.xlarge — +#: it was the only default — so its training rate is the faithful conversion. +LEGACY_HOURS_TO_USD = pricing.training_rate("ml.g5.xlarge") or 1.408 + class FineTuningAccessRepository: """Repository for the fine-tuning-access DynamoDB table. @@ -20,8 +31,14 @@ class FineTuningAccessRepository: SK: ACCESS (fixed literal) Attributes: - email, granted_by, granted_at, monthly_quota_hours, - current_month_usage_hours, quota_period (YYYY-MM) + email, granted_by, granted_at, monthly_quota_usd, + current_month_usage_usd, quota_period (YYYY-MM) + + **Legacy grants.** Records written before the quota moved to dollars + carry ``monthly_quota_hours``/``current_month_usage_hours`` instead. They + are converted on read and written back on the next + :meth:`check_and_reset_quota`, so the migration is lazy and self-healing + rather than a backfill script that has to be run in every environment. """ def __init__(self, table_name: Optional[str] = None): @@ -39,14 +56,30 @@ def _make_pk(email: str) -> str: def _current_period() -> str: return datetime.now(timezone.utc).strftime("%Y-%m") + @staticmethod + def _needs_migration(item: dict) -> bool: + """True when a record predates the dollar quota.""" + return "monthly_quota_usd" not in item and "monthly_quota_hours" in item + def _item_to_dict(self, item: dict) -> dict: - """Convert DynamoDB item to a plain dict, converting Decimals to float.""" + """Convert a DynamoDB item to a plain dict, Decimals to float. + + Converts a legacy hours-denominated grant to dollars on the way out so + callers only ever see one shape. + """ + if self._needs_migration(item): + quota = float(item.get("monthly_quota_hours", 10)) * LEGACY_HOURS_TO_USD + usage = float(item.get("current_month_usage_hours", 0)) * LEGACY_HOURS_TO_USD + else: + quota = float(item.get("monthly_quota_usd", DEFAULT_QUOTA_USD)) + usage = float(item.get("current_month_usage_usd", 0)) + return { "email": item["email"], "granted_by": item.get("granted_by", ""), "granted_at": item.get("granted_at", ""), - "monthly_quota_hours": float(item.get("monthly_quota_hours", 10)), - "current_month_usage_hours": float(item.get("current_month_usage_hours", 0)), + "monthly_quota_usd": round(quota, 4), + "current_month_usage_usd": round(usage, 4), "quota_period": item.get("quota_period", ""), } @@ -90,7 +123,7 @@ def grant_access( self, email: str, granted_by: str, - monthly_quota_hours: float = 10.0, + monthly_quota_usd: float = DEFAULT_QUOTA_USD, ) -> dict: """Grant fine-tuning access to an email. @@ -106,8 +139,8 @@ def grant_access( "email": email.lower(), "granted_by": granted_by, "granted_at": now, - "monthly_quota_hours": Decimal(str(monthly_quota_hours)), - "current_month_usage_hours": Decimal("0"), + "monthly_quota_usd": Decimal(str(monthly_quota_usd)), + "current_month_usage_usd": Decimal("0"), "quota_period": period, } @@ -123,14 +156,16 @@ def grant_access( raise ValueError(f"Access already granted to {email.lower()}") raise - def update_quota(self, email: str, monthly_quota_hours: float) -> Optional[dict]: - """Update the monthly quota for a user. Returns None if not found.""" + def update_quota(self, email: str, monthly_quota_usd: float) -> Optional[dict]: + """Update the monthly dollar quota for a user. Returns None if not found.""" try: response = self._table.update_item( Key={"PK": self._make_pk(email), "SK": "ACCESS"}, - UpdateExpression="SET monthly_quota_hours = :mq", + UpdateExpression=( + "SET monthly_quota_usd = :mq REMOVE monthly_quota_hours" + ), ExpressionAttributeValues={ - ":mq": Decimal(str(monthly_quota_hours)), + ":mq": Decimal(str(monthly_quota_usd)), }, ConditionExpression="attribute_exists(PK)", ReturnValues="ALL_NEW", @@ -156,42 +191,78 @@ def revoke_access(self, email: str) -> bool: raise def check_and_reset_quota(self, email: str) -> Optional[dict]: - """Check quota and lazily reset if a new month has started. + """Check quota, lazily resetting it when a new month has started. + + Also completes the hours-to-dollars migration for a legacy record, so + a grant is rewritten in the new shape the first time its owner is seen + rather than by a backfill script run per environment. Returns the (possibly updated) access grant, or None if not found. """ - item = self.get_access(email) - if item is None: + try: + response = self._table.get_item( + Key={"PK": self._make_pk(email), "SK": "ACCESS"} + ) + raw = response.get("Item") + except ClientError as e: + logger.error(f"Error getting access for {email}: {e}") + raise + + if raw is None: return None + item = self._item_to_dict(raw) current_period = self._current_period() - if item["quota_period"] != current_period: - try: - response = self._table.update_item( - Key={"PK": self._make_pk(email), "SK": "ACCESS"}, - UpdateExpression="SET current_month_usage_hours = :zero, quota_period = :period", - ExpressionAttributeValues={ - ":zero": Decimal("0"), - ":period": current_period, - }, - ReturnValues="ALL_NEW", - ) - logger.info(f"Reset quota for {email.lower()} to period {current_period}") - return self._item_to_dict(response["Attributes"]) - except ClientError as e: - logger.error(f"Error resetting quota for {email}: {e}") - raise + needs_migration = self._needs_migration(raw) + new_period = item["quota_period"] != current_period + + if not needs_migration and not new_period: + return item + + # A new month zeroes usage; a migration carries the converted usage + # across so a user cannot reset their own spend by being migrated. + usage = Decimal("0") if new_period else Decimal(str(item["current_month_usage_usd"])) + + update = ( + "SET monthly_quota_usd = :quota, " + "current_month_usage_usd = :usage, " + "quota_period = :period " + "REMOVE monthly_quota_hours, current_month_usage_hours" + ) + + try: + response = self._table.update_item( + Key={"PK": self._make_pk(email), "SK": "ACCESS"}, + UpdateExpression=update, + ExpressionAttributeValues={ + ":quota": Decimal(str(item["monthly_quota_usd"])), + ":usage": usage, + ":period": current_period, + }, + ReturnValues="ALL_NEW", + ) + except ClientError as e: + logger.error(f"Error resetting quota for {email}: {e}") + raise + + if needs_migration: + logger.info( + f"Migrated {email.lower()} to a dollar quota: " + f"${item['monthly_quota_usd']:.2f}/month" + ) + if new_period: + logger.info(f"Reset quota for {email.lower()} to period {current_period}") - return item + return self._item_to_dict(response["Attributes"]) - def increment_usage(self, email: str, hours: float) -> Optional[dict]: - """Atomically increment current_month_usage_hours.""" + def increment_usage(self, email: str, usd: float) -> Optional[dict]: + """Atomically add spend to current_month_usage_usd.""" try: response = self._table.update_item( Key={"PK": self._make_pk(email), "SK": "ACCESS"}, - UpdateExpression="ADD current_month_usage_hours :hours", + UpdateExpression="ADD current_month_usage_usd :usd", ExpressionAttributeValues={ - ":hours": Decimal(str(hours)), + ":usd": Decimal(str(usd)), }, ConditionExpression="attribute_exists(PK)", ReturnValues="ALL_NEW", diff --git a/backend/src/apis/app_api/fine_tuning/routes.py b/backend/src/apis/app_api/fine_tuning/routes.py index 5e42ad097..f634fb866 100644 --- a/backend/src/apis/app_api/fine_tuning/routes.py +++ b/backend/src/apis/app_api/fine_tuning/routes.py @@ -2,10 +2,11 @@ import math import os +import re import uuid import logging from datetime import datetime, timezone, timedelta -from typing import Optional +from typing import List, Optional import httpx from fastapi import APIRouter, Depends, HTTPException, Query, status @@ -17,16 +18,17 @@ FineTuningAccessRepository, get_fine_tuning_access_repository, ) +from . import pricing, task_types from .job_models import ( AVAILABLE_MODELS, - INSTANCE_COST_PER_HOUR, MODEL_CATALOG, - SUPPORTED_DATASET_EXTENSIONS, PresignRequest, PresignResponse, CreateJobRequest, JobResponse, JobListResponse, + TaskTypeResponse, + models_for_task, ) from .job_repository import FineTuningJobsRepository, get_fine_tuning_jobs_repository from .s3_service import FineTuningS3Service, get_fine_tuning_s3_service @@ -60,24 +62,24 @@ async def check_access( This endpoint does NOT require fine-tuning access — it is used by the frontend to decide whether to show the fine-tuning UI. """ - from .dependencies import DEFAULT_MONTHLY_QUOTA_HOURS + from .dependencies import DEFAULT_MONTHLY_QUOTA_USD grant = repo.check_and_reset_quota(user.email) if grant is not None: return FineTuningAccessResponse( has_access=True, - monthly_quota_hours=grant["monthly_quota_hours"], - current_month_usage_hours=grant["current_month_usage_hours"], + monthly_quota_usd=grant["monthly_quota_usd"], + current_month_usage_usd=grant["current_month_usage_usd"], quota_period=grant["quota_period"], ) # No explicit grant — check if open-access mode is enabled. - if DEFAULT_MONTHLY_QUOTA_HOURS > 0: + if DEFAULT_MONTHLY_QUOTA_USD > 0: return FineTuningAccessResponse( has_access=True, - monthly_quota_hours=DEFAULT_MONTHLY_QUOTA_HOURS, - current_month_usage_hours=0.0, + monthly_quota_usd=DEFAULT_MONTHLY_QUOTA_USD, + current_month_usage_usd=0.0, quota_period=None, ) @@ -90,37 +92,77 @@ async def check_access( @router.get("/models") async def list_models( + task_type: Optional[str] = Query(None), grant: dict = Depends(require_fine_tuning_access), ): - """List available base models for fine-tuning.""" - return [m.model_dump() for m in AVAILABLE_MODELS] + """List available base models, optionally narrowed to one task type.""" + if task_type is None: + return [m.model_dump() for m in AVAILABLE_MODELS] + + try: + return [m.model_dump() for m in models_for_task(task_type)] + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.get("/task-types", response_model=List[TaskTypeResponse]) +async def list_task_types( + grant: dict = Depends(require_fine_tuning_access), +): + """List the fine-tuning task types the platform supports. + + Drives the create-job UI: the dataset contract, the accepted upload + formats and the model list all follow from the chosen task. + """ + return [ + TaskTypeResponse( + task_type=spec.task_type, + display_name=spec.display_name, + description=spec.description, + required_columns=list(spec.required_columns), + upload_extensions=list(spec.upload_extensions), + requires_archive=spec.requires_archive, + inference_upload_extensions=list(spec.inference_upload_extensions), + default_instance_type=spec.default_instance_type, + ) + for spec in ( + task_types.get_task_spec(t) for t in task_types.TASK_TYPES + ) + ] # ========================================================================= # HuggingFace Model Search (proxy) # ========================================================================= -# Pipeline tags compatible with AutoModelForSequenceClassification -COMPATIBLE_PIPELINE_TAGS = [ - "fill-mask", - "text-classification", - "feature-extraction", - "token-classification", - "text-generation", -] +#: Weight files a task can actually be fine-tuned from. A repo carrying only +#: GGUF (llama.cpp) or other quantised artifacts cannot be loaded by +#: ``from_pretrained`` for training — it will provision a GPU and fail minutes +#: in, which is exactly the failure this check exists to prevent. +TRAINABLE_WEIGHT_SUFFIXES = (".safetensors", ".bin", ".pt", ".pth", ".msgpack", ".h5") @router.get("/huggingface-models") async def search_huggingface_models( search: str = Query(..., min_length=2, max_length=200), compatible_only: bool = Query(True), + task_type: str = Query(task_types.DEFAULT_TASK_TYPE), grant: dict = Depends(require_fine_tuning_access), ): """Search HuggingFace Hub models. Proxied to avoid CORS issues. When compatible_only=True (default), makes parallel requests for each - compatible pipeline_tag and merges results sorted by downloads. + pipeline_tag the *task* can use and merges results sorted by downloads. + Searching image models with the text task's tags returns a list of models + that all fail on submission, so the tags follow the task. """ + try: + spec = task_types.get_task_spec(task_type) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + pipeline_tags = list(spec.hf_pipeline_tags) + try: async with httpx.AsyncClient(timeout=10.0) as client: if compatible_only: @@ -143,7 +185,7 @@ async def _fetch_tag(tag: str): return resp.json() results = await asyncio.gather( - *[_fetch_tag(tag) for tag in COMPATIBLE_PIPELINE_TAGS], + *[_fetch_tag(tag) for tag in pipeline_tags], return_exceptions=True, ) @@ -191,48 +233,233 @@ async def _fetch_tag(tag: str): raise HTTPException(status_code=502, detail="Failed to search HuggingFace models") +# A HuggingFace repo id is ``name`` or ``org/name``, each segment limited to +# word characters, dots and hyphens. Anchored, single optional slash, no +# percent-encoding and no dot-segments — which is what keeps an id out of the +# URL structure when it is interpolated into a Hub request path below, and out +# of ``model_name_or_path`` in the training job's hyperparameters. +# ``\Z`` and not ``$`` — ``$`` also matches immediately before a trailing +# newline, so "org/model\n" would pass an otherwise-anchored pattern. +_HF_MODEL_ID = re.compile( + r"\A[A-Za-z0-9][A-Za-z0-9._-]*(?:/[A-Za-z0-9][A-Za-z0-9._-]*)?\Z" +) + + +def validate_huggingface_model_id(raw: str) -> str: + """Return ``raw`` stripped, or raise 400 if it is not a repo id. + + The length ceiling alone was never enough: the value is interpolated into + a Hub URL path and forwarded to SageMaker as ``model_name_or_path``, so a + value carrying ``/``-heavy or dot-segment structure changes the meaning of + both sinks rather than merely failing to resolve. + """ + hf_id = raw.strip() + if not hf_id or len(hf_id) > 200 or not _HF_MODEL_ID.match(hf_id): + raise HTTPException( + status_code=400, + detail=( + "Invalid HuggingFace model ID. Use 'model' or 'org/model' — " + "letters, digits, dots, hyphens and underscores only." + ), + ) + return hf_id + + +async def preflight_huggingface_model(hf_id: str, spec) -> None: + """Reject a custom HuggingFace model that cannot serve ``spec``. + + Three failure modes, all of which otherwise provision a GPU and die + minutes into a billed run with an opaque traceback: + + 1. the repo does not exist (typo, or a gated/private model); + 2. it carries no loadable weights — a GGUF-only repo is the common case, + since llama.cpp quantisations cannot be fine-tuned by transformers; + 3. its pipeline tag belongs to a different modality than the chosen task. + + Network problems are *not* treated as failures: the Hub being unreachable + should not block a submission, so an unavailable check falls through and + lets the training job be the judge. + """ + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get(f"https://huggingface.co/api/models/{hf_id}") + except httpx.HTTPError as e: + logger.warning(f"HuggingFace pre-flight unavailable for {hf_id}: {e}") + return + + if response.status_code == 404: + raise HTTPException( + status_code=400, + detail=( + f"HuggingFace model '{hf_id}' was not found. Check the id, and " + f"note that gated or private models cannot be used." + ), + ) + if response.status_code >= 400: + logger.warning( + f"HuggingFace pre-flight returned {response.status_code} for {hf_id}" + ) + return + + payload = response.json() + + filenames = [s.get("rfilename", "") for s in payload.get("siblings", [])] + if filenames and not any( + name.endswith(TRAINABLE_WEIGHT_SUFFIXES) for name in filenames + ): + gguf = any(name.lower().endswith(".gguf") for name in filenames) + reason = ( + "it only publishes GGUF (llama.cpp) quantisations, which cannot be " + "fine-tuned" + if gguf + else "it publishes no loadable model weights" + ) + raise HTTPException( + status_code=400, + detail=( + f"HuggingFace model '{hf_id}' cannot be fine-tuned because " + f"{reason}. Look for the original (unquantised) repository." + ), + ) + + pipeline_tag = payload.get("pipeline_tag") + if pipeline_tag and pipeline_tag not in spec.hf_pipeline_tags: + supported = ", ".join(spec.hf_pipeline_tags) + raise HTTPException( + status_code=400, + detail=( + f"HuggingFace model '{hf_id}' is tagged '{pipeline_tag}', which " + f"is not compatible with {spec.display_name.lower()}. " + f"Compatible tags: {supported}." + ), + ) + + # ========================================================================= # Presigned URL # ========================================================================= -def _validate_dataset_format(name: str) -> None: +def _validate_dataset_format(name: str, spec) -> None: """Reject a dataset filename the training script could not read. Checked before upload and again before the job is submitted, so an unreadable dataset never reaches a billed GPU instance. """ - if not name.lower().endswith(SUPPORTED_DATASET_EXTENSIONS): - supported = ", ".join(SUPPORTED_DATASET_EXTENSIONS) + if not spec.supports_extension(name): + supported = ", ".join(spec.upload_extensions) + required = ", ".join(f'"{c}"' for c in spec.required_columns) + detail = ( + f"Unsupported dataset format for {spec.display_name.lower()}. " + f"Supported formats: {supported}. " + ) + if spec.requires_archive: + detail += ( + f"Upload a .zip containing a manifest (CSV/JSONL/JSON) with " + f"{required} fields, plus the image files it references." + ) + else: + detail += f"Each record needs {required} fields." + raise HTTPException(status_code=400, detail=detail) + + +def _resolve_task_spec(task_type: Optional[str]): + """Resolve a task type from a request, as a 400 rather than a 500.""" + try: + return task_types.get_task_spec(task_type) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +#: Shortest run worth starting. Below this a GPU job cannot even pull its +#: container image and download the base model, so admitting one would spend +#: the user's remaining budget on producing nothing. +MIN_BUDGETED_RUNTIME_SECONDS = 1800 + + +def _budgeted_runtime( + requested_seconds: int, + instance_type: str, + remaining_usd: float, + *, + transform: bool = False, +) -> int: + """Clamp a job's stopping condition to what the remaining budget affords. + + Admitting on worst-case cost alone would reject almost everything: the + default stopping condition is 24 hours, which is ~$34 on the cheapest + instance, so no ordinary monthly quota could ever admit a job — even + though the same job typically finishes in minutes and costs cents. + + Instead the budget *becomes* the stopping condition. SageMaker kills the + job when MaxRuntimeInSeconds elapses, so clamping that value to the hours + the user can actually afford bounds the spend exactly, with no reservation + bookkeeping and no false rejections. + + Raises HTTPException when the affordable runtime is too short to be worth + starting. + """ + rate = ( + pricing.transform_rate(instance_type) + if transform + else pricing.training_rate(instance_type) + ) + if not rate: # pragma: no cover - _validate_instance_type runs first + raise HTTPException(status_code=400, detail=f"Unpriced instance type '{instance_type}'.") + + affordable_seconds = int((remaining_usd / rate) * 3600) + + if affordable_seconds < MIN_BUDGETED_RUNTIME_SECONDS: raise HTTPException( status_code=400, detail=( - f"Unsupported dataset format. Supported formats: {supported}. " - 'Each record needs a "text" and a "label" field.' + f"Insufficient quota. ${remaining_usd:.2f} remaining buys " + f"{affordable_seconds // 60} minutes on {instance_type}, below " + f"the {MIN_BUDGETED_RUNTIME_SECONDS // 60}-minute minimum. " + f"Choose a cheaper instance type, or ask an administrator to " + f"raise your quota." ), ) + effective = min(requested_seconds, affordable_seconds) + if effective < requested_seconds: + logger.info( + f"Clamped max runtime from {requested_seconds}s to {effective}s " + f"to fit ${remaining_usd:.2f} remaining on {instance_type}" + ) + return effective + -def _validate_instance_type(instance_type: str) -> None: +def _validate_instance_type(instance_type: str, *, transform: bool = False) -> None: """Reject an instance type we have no price for. - ``calculate_cost`` falls back to $0.00/hour for anything absent from - INSTANCE_COST_PER_HOUR, so an unlisted type runs real GPUs and records no - spend — invisible to the admin cost dashboard, the same blind spot the - StatusIndex casing bug produced by a different route. - - The quota does not bound the damage either: it meters GPU-*hours*, not - dollars, so the same ten hours buys ~$14 on an ml.g5.xlarge or several - hundred on a larger instance. `instance_type` arrives straight off the + ``calculate_cost`` falls back to $0.00/hour for anything unpriced, so an + unlisted type would run real GPUs and record no spend — invisible to the + admin cost dashboard, and now also invisible to the dollar quota, which + would let it run unbounded. ``instance_type`` arrives straight off the request body, so this is the only thing standing between a caller and an unpriced instance. + + Training and Batch Transform have separate rate tables because some + instances are offered for one and not the other; ``transform`` says which + table to check. """ - if instance_type not in INSTANCE_COST_PER_HOUR: - supported = ", ".join(sorted(INSTANCE_COST_PER_HOUR)) + rate = ( + pricing.transform_rate(instance_type) + if transform + else pricing.training_rate(instance_type) + ) + if rate is None: + supported = ", ".join( + pricing.supported_transform_instances() + if transform + else pricing.supported_training_instances() + ) + operation = "Batch Transform" if transform else "training" raise HTTPException( status_code=400, detail=( - f"Unsupported instance type '{instance_type}'. " - f"Supported types: {supported}" + f"Instance type '{instance_type}' is not available for " + f"{operation}. Supported types: {supported}" ), ) @@ -245,7 +472,8 @@ async def presign_upload( s3_service: FineTuningS3Service = Depends(get_fine_tuning_s3_service), ): """Generate a presigned PUT URL for dataset upload.""" - _validate_dataset_format(request.filename) + spec = _resolve_task_spec(request.task_type) + _validate_dataset_format(request.filename, spec) try: presigned_url, s3_key = s3_service.generate_upload_url( @@ -283,31 +511,36 @@ async def create_job( script_service: ScriptPackagingService = Depends(get_script_packaging_service), ): """Create a new fine-tuning training job.""" + spec = _resolve_task_spec(request.task_type) + # Validate model — either from catalog or custom HuggingFace model model = MODEL_CATALOG.get(request.model_id) if not model and not request.custom_huggingface_model_id: raise HTTPException(status_code=400, detail=f"Unknown model_id: {request.model_id}") + if model and model.task_type != spec.task_type: + raise HTTPException( + status_code=400, + detail=( + f"Model '{model.model_name}' is a " + f"{task_types.get_task_spec(model.task_type).display_name.lower()} " + f"model and cannot be fine-tuned for " + f"{spec.display_name.lower()}." + ), + ) + if request.custom_huggingface_model_id: - # Validate the custom HuggingFace model ID format (org/model or just model) - hf_id = request.custom_huggingface_model_id.strip() - if not hf_id or len(hf_id) > 200: - raise HTTPException(status_code=400, detail="Invalid HuggingFace model ID.") + hf_id = validate_huggingface_model_id(request.custom_huggingface_model_id) + # Ask the Hub whether this model can actually serve the task before a + # GPU is provisioned for it. + await preflight_huggingface_model(hf_id, spec) # Verify the dataset is readable by the training script and exists in S3 - _validate_dataset_format(request.dataset_s3_key) + _validate_dataset_format(request.dataset_s3_key, spec) if not s3_service.check_object_exists(request.dataset_s3_key): raise HTTPException(status_code=400, detail="Dataset not found in S3. Upload your dataset first.") - # Check quota (need at least 1 hour remaining) - remaining = grant["monthly_quota_hours"] - grant["current_month_usage_hours"] - if remaining < 1.0: - raise HTTPException( - status_code=400, - detail=f"Insufficient quota. You have {remaining:.1f} hours remaining, minimum 1.0 required.", - ) - # Resolve instance type and hyperparameters if model: instance_type = request.instance_type or model.default_instance_type @@ -315,25 +548,32 @@ async def create_job( model_name = model.model_name huggingface_id = model.huggingface_model_id else: - # Custom HuggingFace model — use sensible defaults - instance_type = request.instance_type or "ml.g5.xlarge" - hyperparameters = { - "epochs": "3", - "per_device_train_batch_size": "8", - "learning_rate": "2e-5", - "weight_decay": "0.01", - "split_ratio": "0.8", - "seed": "42", - "context_length": "512", - } - huggingface_id = request.custom_huggingface_model_id.strip() + # Custom HuggingFace model — fall back to the task's own defaults. + instance_type = request.instance_type or spec.default_instance_type + hyperparameters = {**spec.default_hyperparameters} + # Re-validate rather than reuse the value from the pre-flight block: + # this is the sink that reaches the training container as + # `model_name_or_path`, and it should be safe on its own terms rather + # than because of where an earlier branch happened to run. + huggingface_id = validate_huggingface_model_id( + request.custom_huggingface_model_id + ) model_name = huggingface_id _validate_instance_type(instance_type) + # Bound the job's spend by clamping its stopping condition to what the + # remaining budget affords. The real bill is unknown until the job stops, + # so this is what keeps a single long run from overshooting the quota. + remaining = grant["monthly_quota_usd"] - grant["current_month_usage_usd"] + max_runtime_seconds = _budgeted_runtime( + request.max_runtime_seconds, instance_type, remaining + ) + if request.hyperparameters: hyperparameters.update(request.hyperparameters) hyperparameters["model_name_or_path"] = huggingface_id + hyperparameters["task_type"] = spec.task_type # Generate identifiers job_id = uuid.uuid4().hex @@ -363,12 +603,13 @@ async def create_job( job_id=job_id, model_id=request.model_id, model_name=model_name, + task_type=spec.task_type, dataset_s3_key=request.dataset_s3_key, instance_type=instance_type, hyperparameters=hyperparameters, sagemaker_job_name=sagemaker_job_name, output_s3_prefix=output_s3_prefix, - max_runtime_seconds=request.max_runtime_seconds, + max_runtime_seconds=max_runtime_seconds, ) # Start SageMaker training job @@ -379,8 +620,9 @@ async def create_job( input_s3_uri=input_s3_uri, output_s3_uri=output_s3_uri, instance_type=instance_type, - max_runtime=request.max_runtime_seconds, + max_runtime=max_runtime_seconds, source_dir_s3_uri=scripts_s3_uri, + task_type=spec.task_type, ) job = jobs_repo.update_job_status(user.user_id, job_id, "TRAINING") except Exception as e: @@ -607,11 +849,14 @@ def _sync_job_status( job["user_id"], job["job_id"], new_status, **update_kwargs ) - # Increment usage quota on completion/failure/stop (if billable time exists) + # Charge the quota on completion/failure/stop (if billable time exists). + # Failed and stopped runs are billed by AWS too, so they are charged here. if new_status in ("COMPLETED", "FAILED", "STOPPED") and sm_status.get("billable_seconds"): - billable_hours = sm_status["billable_seconds"] / 3600 - access_repo.increment_usage(job["email"], billable_hours) - logger.info(f"Incremented usage for {job['email']} by {billable_hours:.2f} hours") + spend = sagemaker.calculate_cost( + job["instance_type"], sm_status["billable_seconds"] + ) + access_repo.increment_usage(job["email"], spend) + logger.info(f"Charged {job['email']} ${spend:.4f} for training {job['job_id']}") return updated @@ -647,7 +892,9 @@ def _sync_inference_status( update_kwargs["transform_end_time"] = sm_status["transform_end_time"] if sm_status.get("billable_seconds"): update_kwargs["billable_seconds"] = sm_status["billable_seconds"] - cost = sagemaker.calculate_cost(job["instance_type"], sm_status["billable_seconds"]) + cost = sagemaker.calculate_cost( + job["instance_type"], sm_status["billable_seconds"], transform=True + ) update_kwargs["estimated_cost_usd"] = cost if sm_status.get("failure_reason"): update_kwargs["error_message"] = sm_status["failure_reason"] @@ -656,11 +903,13 @@ def _sync_inference_status( job["user_id"], job["job_id"], new_status, **update_kwargs ) - # Increment usage quota on terminal status (if billable time exists) + # Charge the quota on terminal status (if billable time exists). if new_status in ("COMPLETED", "FAILED", "STOPPED") and sm_status.get("billable_seconds"): - billable_hours = sm_status["billable_seconds"] / 3600 - access_repo.increment_usage(job["email"], billable_hours) - logger.info(f"Incremented inference usage for {job['email']} by {billable_hours:.2f} hours") + spend = sagemaker.calculate_cost( + job["instance_type"], sm_status["billable_seconds"], transform=True + ) + access_repo.increment_usage(job["email"], spend) + logger.info(f"Charged {job['email']} ${spend:.4f} for inference {job['job_id']}") return updated @@ -690,6 +939,7 @@ async def list_trained_models( model_id=job["model_id"], model_name=job["model_name"], model_s3_path=model_s3_path, + task_type=job.get("task_type", task_types.DEFAULT_TASK_TYPE), instance_type=job["instance_type"], completed_at=job.get("training_end_time"), estimated_cost_usd=job.get("estimated_cost_usd"), @@ -711,6 +961,17 @@ async def inference_presign_upload( s3_service: FineTuningS3Service = Depends(get_fine_tuning_s3_service), ): """Generate a presigned PUT URL for inference input file upload.""" + spec = _resolve_task_spec(request.task_type) + if not spec.supports_inference_extension(request.filename): + supported = ", ".join(spec.inference_upload_extensions) + raise HTTPException( + status_code=400, + detail=( + f"Unsupported input format for {spec.display_name.lower()} " + f"inference. Supported formats: {supported}." + ), + ) + try: presigned_url, s3_key = s3_service.generate_inference_upload_url( user_id=user.user_id, @@ -750,24 +1011,38 @@ async def create_inference_job( if training_job["status"] != "COMPLETED": raise HTTPException(status_code=400, detail="Training job has not completed successfully") + # The inference task is whatever the model was trained for — never what + # the caller claims, since the artifact can only serve its own task. + spec = _resolve_task_spec(training_job.get("task_type")) + # Verify input file exists in S3 if not s3_service.check_object_exists(request.input_s3_key): raise HTTPException(status_code=400, detail="Input file not found in S3. Upload your input file first.") - # Check quota (need at least 0.5 hours remaining for inference) - remaining = grant["monthly_quota_hours"] - grant["current_month_usage_hours"] - if remaining < 0.5: + if not spec.supports_inference_extension(request.input_s3_key): + supported = ", ".join(spec.inference_upload_extensions) raise HTTPException( status_code=400, - detail=f"Insufficient quota. You have {remaining:.1f} hours remaining, minimum 0.5 required.", + detail=( + f"This model was fine-tuned for {spec.display_name.lower()}, " + f"so its inference input must be one of: {supported}." + ), ) # Build model artifact S3 path from training job's output model_s3_path = f"s3://{s3_service.bucket_name}/{training_job['output_s3_prefix']}/{training_job['sagemaker_job_name']}/output/model.tar.gz" - # Resolve instance type (default to training job's instance type) + # Resolve instance type (default to training job's instance type). Not + # every training instance can run Batch Transform, so this is checked + # against the transform rate table. instance_type = request.instance_type or training_job["instance_type"] - _validate_instance_type(instance_type) + _validate_instance_type(instance_type, transform=True) + + # Bound spend the same way training does. + remaining = grant["monthly_quota_usd"] - grant["current_month_usage_usd"] + max_runtime_seconds = _budgeted_runtime( + request.max_runtime_seconds, instance_type, remaining, transform=True + ) # Generate identifiers job_id = uuid.uuid4().hex @@ -792,7 +1067,7 @@ async def create_inference_job( instance_type=instance_type, transform_job_name=transform_job_name, output_s3_prefix=output_s3_prefix, - max_runtime_seconds=request.max_runtime_seconds, + max_runtime_seconds=max_runtime_seconds, ) # Start SageMaker Batch Transform job @@ -803,7 +1078,8 @@ async def create_inference_job( input_s3_uri=input_s3_uri, output_s3_uri=output_s3_uri, instance_type=instance_type, - max_runtime=request.max_runtime_seconds, + max_runtime=max_runtime_seconds, + task_type=spec.task_type, ) job = inf_repo.update_inference_status(user.user_id, job_id, "TRANSFORMING") except Exception as e: diff --git a/backend/src/apis/app_api/fine_tuning/sagemaker_scripts/inference.py b/backend/src/apis/app_api/fine_tuning/sagemaker_scripts/inference.py index fda1ed3eb..c647424d9 100644 --- a/backend/src/apis/app_api/fine_tuning/sagemaker_scripts/inference.py +++ b/backend/src/apis/app_api/fine_tuning/sagemaker_scripts/inference.py @@ -1,170 +1,185 @@ """SageMaker Inference Toolkit handler for Batch Transform. -Implements the four handler functions required by the HuggingFace inference DLC: - - model_fn(model_dir): Load model and tokenizer - - input_fn(body, type): Parse input text - - predict_fn(data, model): Run batched inference with softmax - - output_fn(prediction, accept): Format as CSV with probability columns - -SageMaker Batch Transform: - - Extracts model.tar.gz to a directory, finds code/inference.py - - Calls model_fn once to load the model - - For each input record: input_fn -> predict_fn -> output_fn +A dispatcher, mirroring ``train.py``. It implements the four handler +functions the HuggingFace inference DLC calls, resolves the task type recorded +in the model artifact, and delegates the modality-specific work to the +matching ``task_*`` module. + + - model_fn(model_dir): load model + processor for the task + - input_fn(body, content_type): parse the payload into records + - predict_fn(records, model): batched inference with softmax + - output_fn(prediction, accept): format as CSV with probability columns + +The output shape is deliberately identical across every task — an identifier +column followed by one probability column per class — so a new modality never +breaks the result viewer. """ import json import logging - -# Heavy ML dependencies are imported lazily inside functions since they are only -# available in the SageMaker DLC container. input_fn, output_fn, and -# _sanitize_label must remain importable without torch/transformers so they -# can be unit-tested locally. +import os + +try: # package context: unit tests and the app-api container + from .. import task_types + from . import task_image_classification + from . import task_image_text_classification + from . import task_text_classification +except ImportError: # pragma: no cover - flat sourcedir inside the SageMaker DLC + import task_types # type: ignore + import task_image_classification # type: ignore + import task_image_text_classification # type: ignore + import task_text_classification # type: ignore logger = logging.getLogger(__name__) -BATCH_SIZE = 64 +#: Written into the model artifact at training time so the handler can tell +#: which task it is serving without being told by the caller. +TASK_MARKER_FILENAME = "task_type.json" + +TASK_MODULES = { + task_types.TEXT_CLASSIFICATION: task_text_classification, + task_types.IMAGE_CLASSIFICATION: task_image_classification, + task_types.IMAGE_TEXT_CLASSIFICATION: task_image_text_classification, +} + +#: Task type of the artifact currently loaded, remembered at module scope. +#: +#: The SageMaker inference toolkit calls the user's ``input_fn`` as +#: ``input_fn(input_data, content_type)`` — the loaded model is NOT passed to +#: it, only to ``predict_fn``. The single-entry-point ``transform_fn`` does +#: receive the model, but the toolkit forbids defining it alongside +#: input_fn/predict_fn/output_fn. So the one place ``input_fn`` can learn +#: which task it is parsing for is here, written by ``model_fn``, which the +#: toolkit always calls first and exactly once. +#: +#: Without this an image artifact would parse its .zip payload as newline +#: delimited text and fail on every record. +_LOADED_TASK_TYPE = None + + +def read_task_type(model_dir): + """Read the task type recorded in a model artifact. + + Falls back to the default task for an artifact trained before task types + existed — those directories have no marker and are all text classifiers. + """ + marker = os.path.join(model_dir, TASK_MARKER_FILENAME) + if not os.path.exists(marker): + logger.info( + f"No {TASK_MARKER_FILENAME} in artifact; assuming " + f"'{task_types.DEFAULT_TASK_TYPE}'" + ) + return task_types.DEFAULT_TASK_TYPE + with open(marker) as handle: + return json.load(handle).get("task_type", task_types.DEFAULT_TASK_TYPE) -def model_fn(model_dir): - """Load model and tokenizer from the model directory. - Returns a tuple of (model, tokenizer, device). - """ - import torch - from transformers import AutoTokenizer, AutoModelForSequenceClassification +def resolve_task_module(task_type): + """Return the (module, spec) pair serving ``task_type``.""" + spec = task_types.get_task_spec(task_type) + module = TASK_MODULES.get(spec.task_type) + if module is None: # pragma: no cover - registry/module drift + raise ValueError( + f"Task type '{spec.task_type}' is registered but has no inference module." + ) + return module, spec - device = torch.device("cuda" if torch.cuda.is_available() else "cpu") - tokenizer = AutoTokenizer.from_pretrained(model_dir) - model = AutoModelForSequenceClassification.from_pretrained( - model_dir, - torch_dtype="auto", - ) - model.resize_token_embeddings(len(tokenizer)) - model.to(device) - model.eval() +# ========================================================================= +# SageMaker handler functions +# ========================================================================= - logger.info(f"Loaded model from {model_dir} on {device}") - return (model, tokenizer, device) +def model_fn(model_dir): + """Load the model for whichever task this artifact was trained for.""" + global _LOADED_TASK_TYPE + + task_type = read_task_type(model_dir) + module, spec = resolve_task_module(task_type) + # Remember the task before loading, so input_fn can dispatch on it. + _LOADED_TASK_TYPE = spec.task_type + + loaded = module.model_fn(model_dir) + # Carry the task through to input_fn/predict_fn, which SageMaker calls + # with only the payload and this object. + loaded["task_type"] = spec.task_type + loaded["spec"] = spec + return loaded + + +def input_fn(request_body, content_type="text/plain", model=None): + """Parse the Batch Transform payload into task-appropriate records. + + The toolkit calls this with only ``(input_data, content_type)``, so the + task normally comes from :data:`_LOADED_TASK_TYPE`, set by ``model_fn``. + ``model`` is accepted as an optional override for direct callers and + tests. Falls back to the default task when nothing has been loaded, which + preserves the pre-task-types behaviour. + """ + task_type = None + if isinstance(model, dict): + task_type = model.get("task_type") + task_type = task_type or _LOADED_TASK_TYPE or task_types.DEFAULT_TASK_TYPE + module, spec = resolve_task_module(task_type) + return module.input_fn(request_body, content_type, spec) -def input_fn(request_body, content_type="text/plain"): - """Parse input data. - Supports: - - text/plain: one text string per line - - application/json: list of strings or {"texts": [...]} +def predict_fn(input_data, model): + """Run inference for the loaded task. - Returns a list of non-empty text strings. + Names the identifier column here rather than in each task module, so every + task is guaranteed to produce a labelled first column. """ - # SageMaker HuggingFace DLC passes request_body as bytes, not str. - if isinstance(request_body, (bytes, bytearray)): - request_body = request_body.decode("utf-8") - - if content_type == "text/plain": - lines = request_body.strip().split("\n") - texts = [line.strip() for line in lines if line.strip()] - return texts - elif content_type == "application/json": - data = json.loads(request_body) - if isinstance(data, list): - return [str(item) for item in data if str(item).strip()] - elif isinstance(data, dict) and "texts" in data: - return [str(t) for t in data["texts"] if str(t).strip()] - raise ValueError('JSON input must be a list or {"texts": [...]}') - else: - raise ValueError(f"Unsupported content type: {content_type}") - - -def predict_fn(input_data, model_tuple): - """Run batched inference with softmax probabilities. - - Args: - input_data: List of text strings from input_fn - model_tuple: (model, tokenizer, device) from model_fn - - Returns a dict with 'texts', 'probabilities' (numpy array), and 'labels'. - """ - import torch - import numpy as np - - model, tokenizer, device = model_tuple - texts = input_data - - if not texts: - return {"texts": [], "probabilities": np.zeros((0, 0)), "labels": []} - - # Batched inference - all_probs = [] - with torch.no_grad(): - for start in range(0, len(texts), BATCH_SIZE): - batch_texts = texts[start : start + BATCH_SIZE] - enc = tokenizer( - batch_texts, - padding=True, - truncation=True, - return_tensors="pt", - ) - enc = {k: v.to(device) for k, v in enc.items()} - outputs = model(**enc) - probs = torch.softmax(outputs.logits, dim=-1).cpu().numpy() - all_probs.append(probs) - - probabilities = np.vstack(all_probs) if all_probs else np.zeros((0, 0)) - - # Build label names from model config - num_labels = ( - probabilities.shape[1] if len(probabilities.shape) > 1 else 0 + module, spec = resolve_task_module(model["task_type"]) + prediction = module.predict_fn(input_data, model, spec) + prediction.setdefault( + "identifier_column", spec.image_column or spec.text_column or "id" ) - id2label = getattr(model.config, "id2label", None) - if isinstance(id2label, dict): - labels = [ - id2label.get(i) or id2label.get(str(i)) or f"class_{i}" - for i in range(num_labels) - ] - elif isinstance(id2label, (list, tuple)): - labels = list(id2label)[:num_labels] - else: - labels = [f"class_{i}" for i in range(num_labels)] - - return {"texts": texts, "probabilities": probabilities, "labels": labels} + return prediction def _sanitize_label(label): """Sanitize a label string for use as a CSV column name.""" if label is None: return "class" - return "".join( - c if (c.isalnum() or c == "_") else "_" for c in str(label) - ) + return "".join(c if (c.isalnum() or c == "_") else "_" for c in str(label)) + + +def _escape_csv(value): + """Quote and escape a value for CSV output.""" + return '"' + str(value).replace('"', '""') + '"' def output_fn(prediction, accept="text/csv"): - """Format prediction output as CSV with probability columns. + """Format predictions as CSV with one probability column per class. Output format: - text,prob_label1,prob_label2,... - "example text",0.850000,0.150000 + id,prob_label1,prob_label2,... + "example.jpg",0.850000,0.150000 + + The first column is whatever identifies a record for the task: the input + text for text classification, the archive-relative image path for the + image tasks. """ - texts = prediction["texts"] - probs = prediction["probabilities"] + identifiers = prediction["identifiers"] + probabilities = prediction["probabilities"] labels = prediction["labels"] + identifier_column = prediction.get("identifier_column", "id") - # Build CSV header - prob_columns = [f"prob_{_sanitize_label(l)}" for l in labels] - header = "text," + ",".join(prob_columns) + header = identifier_column + "," + ",".join( + f"prob_{_sanitize_label(label)}" for label in labels + ) - # Build rows rows = [header] - for i, text in enumerate(texts): - # Escape text for CSV (handle commas and quotes) - escaped_text = '"' + text.replace('"', '""') + '"' - if probs.shape[0] > i and probs.shape[1] > 0: - prob_values = ",".join( - f"{probs[i, j]:.6f}" for j in range(probs.shape[1]) + for index, identifier in enumerate(identifiers): + if probabilities.shape[0] > index and probabilities.shape[1] > 0: + values = ",".join( + f"{probabilities[index, column]:.6f}" + for column in range(probabilities.shape[1]) ) else: - prob_values = ",".join("0.000000" for _ in labels) - rows.append(f"{escaped_text},{prob_values}") + values = ",".join("0.000000" for _ in labels) + rows.append(f"{_escape_csv(identifier)},{values}") return "\n".join(rows) diff --git a/backend/src/apis/app_api/fine_tuning/sagemaker_scripts/requirements.txt b/backend/src/apis/app_api/fine_tuning/sagemaker_scripts/requirements.txt index fda4dd66e..f067b56d7 100644 --- a/backend/src/apis/app_api/fine_tuning/sagemaker_scripts/requirements.txt +++ b/backend/src/apis/app_api/fine_tuning/sagemaker_scripts/requirements.txt @@ -1,2 +1,13 @@ +# Installed by the SageMaker DLC into both the training container and, via +# code/ inside model.tar.gz, the Batch Transform container. +# +# One file serves two containers on different Python versions — the text tasks +# run py3.10 (PyTorch 2.1 DLC), the vision tasks py3.12 (PyTorch 2.8 DLC) — so +# anything added here has to install on both. pillow 12.x requires >=3.10 and +# therefore does. +# +# torch, transformers, datasets and evaluate are supplied by the DLC itself +# and are deliberately not listed: pinning them here would fight the image. pandas scikit-learn +pillow==12.3.0 diff --git a/backend/src/apis/app_api/fine_tuning/sagemaker_scripts/task_common.py b/backend/src/apis/app_api/fine_tuning/sagemaker_scripts/task_common.py new file mode 100644 index 000000000..ccc8106fe --- /dev/null +++ b/backend/src/apis/app_api/fine_tuning/sagemaker_scripts/task_common.py @@ -0,0 +1,535 @@ +"""Shared helpers for every fine-tuning task type. + +Everything here is task-agnostic: dataset discovery, archive unpacking, label +normalisation, the train/test split, progress reporting and the bundling of +the inference handler into the model artifact. Task-specific behaviour — +which auto-class loads the model, how a batch is collated — lives in the +``task_*.py`` sibling modules. + +**Import shim.** These modules are consumed two ways: as a package +(``apis.app_api.fine_tuning.sagemaker_scripts.task_common``) by the unit tests +and the app-api container, and as flat top-level modules inside the SageMaker +DLC, where ``script_packaging_service`` tars them at the archive root with no +package around them. The try/except below is what lets one file serve both; +it is not defensive coding, it is the two real layouts. + +**Heavy ML dependencies are imported lazily inside functions.** torch, +transformers, pandas and PIL exist only in the DLC, and the module has to stay +importable in the backend venv so the dataset contract can be unit-tested +without them. +""" + +import logging +import os +import shutil +import zipfile + +try: # package context: unit tests and the app-api container + from .. import task_types +except ImportError: # pragma: no cover - flat sourcedir inside the SageMaker DLC + import task_types # type: ignore + +try: + from transformers import TrainerCallback +except ImportError: # pragma: no cover - local dev/test without transformers + TrainerCallback = object + +logger = logging.getLogger(__name__) + + +# ========================================================================= +# Dataset formats +# ========================================================================= + +# Formats the trainer can read, mapped to the pandas reader that loads them. +# Plain .txt is absent because it has no way to express a label; it stays +# valid for *inference* input, which is unlabelled. +# +# Kept as data rather than an if/elif chain so the supported-format contract +# can be asserted without importing pandas, which only exists inside the +# SageMaker container. +DATASET_READERS = { + ".csv": ("read_csv", {}), + ".jsonl": ("read_json", {"lines": True}), + ".json": ("read_json", {}), +} + +#: Image files an archive-based dataset may reference. +SUPPORTED_IMAGE_EXTENSIONS = (".jpg", ".jpeg", ".png", ".bmp", ".gif", ".webp", ".tiff") + +#: Directory the training script unpacks an uploaded archive into. Sits +#: outside the input channel so the extracted tree is never mistaken for +#: another dataset file on a re-scan. +EXTRACT_DIR = "/opt/ml/input/extracted" + + +# ========================================================================= +# Dataset discovery +# ========================================================================= + +def find_file_in_dir(directory, extensions, description="dataset"): + """Return the first file under ``directory`` matching ``extensions``. + + Searches recursively, deepest-last, so a manifest at the archive root wins + over one nested inside an image folder. Raises FileNotFoundError when the + directory is missing or holds no match. + """ + if not os.path.isdir(directory): + raise FileNotFoundError(f"Directory does not exist: {directory}") + + matches = [] + for root, _dirs, files in os.walk(directory): + relative = os.path.relpath(root, directory) + # relpath returns "." for the top level and "images" one level down — + # both hold zero separators, so counting them alone ranks a nested + # manifest equal to a root one. + depth = 0 if relative == os.curdir else relative.count(os.sep) + 1 + for name in sorted(files): + if name.startswith("._") or name.startswith("."): + # Skip macOS resource forks, which zip up alongside the real + # files and would otherwise be picked as the manifest. + continue + if name.lower().endswith(tuple(extensions)): + matches.append((depth, os.path.join(root, name))) + + if not matches: + supported = ", ".join(extensions) + raise FileNotFoundError( + f"No {description} file found in {directory}. " + f"Supported formats: {supported}" + ) + + matches.sort(key=lambda pair: (pair[0], pair[1])) + return matches[0][1] + + +def resolve_dataset_reader(dataset_path): + """Return the (pandas reader name, kwargs) pair for a manifest file. + + Raises ValueError for an extension the trainer cannot read. + """ + extension = os.path.splitext(dataset_path)[1].lower() + + if extension not in DATASET_READERS: + supported = ", ".join(DATASET_READERS) + raise ValueError( + f"Unsupported dataset format '{extension}'. Supported formats: {supported}" + ) + + return DATASET_READERS[extension] + + +def validate_dataset_columns(columns, dataset_path, spec): + """Raise ValueError if a column the task requires is absent.""" + missing = [c for c in spec.required_columns if c not in columns] + if missing: + required = ", ".join(f'"{c}"' for c in spec.required_columns) + raise ValueError( + f"Dataset {os.path.basename(dataset_path)} is missing required " + f"column(s): {', '.join(missing)}. A {spec.display_name.lower()} " + f"record needs {required}." + ) + + +# ========================================================================= +# Archive handling +# ========================================================================= + +def _is_within(directory, target): + """True when ``target`` resolves to a path inside ``directory``.""" + directory = os.path.realpath(directory) + target = os.path.realpath(target) + return os.path.commonpath([directory, target]) == directory + + +def extract_archive(archive_path, dest_dir): + """Safely unpack a user-uploaded .zip into ``dest_dir``. + + Rejects absolute paths, parent-directory traversal and symlinks — the + archive is untrusted input uploaded by a user, and a naive ``extractall`` + would let a crafted entry write anywhere the training container can reach + (``Zip-Slip``). Returns ``dest_dir``. + """ + os.makedirs(dest_dir, exist_ok=True) + + with zipfile.ZipFile(archive_path) as archive: + for member in archive.infolist(): + name = member.filename + if name.endswith("/"): + continue + if os.path.isabs(name) or ".." in name.replace("\\", "/").split("/"): + raise ValueError( + f"Refusing to extract unsafe archive entry: {name!r}" + ) + target = os.path.join(dest_dir, name) + if not _is_within(dest_dir, os.path.dirname(target) or dest_dir): + raise ValueError( + f"Refusing to extract archive entry outside the " + f"destination: {name!r}" + ) + os.makedirs(os.path.dirname(target), exist_ok=True) + with archive.open(member) as source, open(target, "wb") as sink: + shutil.copyfileobj(source, sink) + + logger.info(f"Extracted {archive_path} to {dest_dir}") + return dest_dir + + +def resolve_image_path(image_root, relative_path): + """Resolve a manifest image reference against the archive root. + + Raises ValueError if the reference escapes the archive, FileNotFoundError + if the file is not there. + """ + candidate = os.path.join(image_root, str(relative_path).strip()) + + if not _is_within(image_root, candidate): + raise ValueError( + f"Image path escapes the dataset archive: {relative_path!r}" + ) + if not os.path.isfile(candidate): + raise FileNotFoundError( + f"Image referenced by the manifest is missing from the archive: " + f"{relative_path!r}" + ) + return candidate + + +# ========================================================================= +# Dataset loading +# ========================================================================= + +def load_manifest_frame(manifest_path, spec): + """Load a manifest file into a DataFrame and validate its columns.""" + import pandas as pd + + reader_name, reader_kwargs = resolve_dataset_reader(manifest_path) + frame = getattr(pd, reader_name)(manifest_path, **reader_kwargs) + + validate_dataset_columns(frame.columns, manifest_path, spec) + + return frame + + +def prepare_dataset(channel_dir, spec): + """Locate, unpack and load the dataset for ``spec``. + + Returns ``(frame, image_root)``. ``image_root`` is None for text-only + tasks; for archive tasks it is the extracted archive root, and the frame's + image column has been rewritten to absolute, existence-checked paths. + """ + if spec.requires_archive: + archive_path = find_file_in_dir(channel_dir, spec.upload_extensions, "dataset archive") + logger.info(f"Unpacking dataset archive {archive_path}") + image_root = extract_archive(archive_path, EXTRACT_DIR) + manifest_path = find_file_in_dir(image_root, spec.manifest_extensions, "manifest") + else: + image_root = None + manifest_path = find_file_in_dir(channel_dir, spec.manifest_extensions, "dataset") + + logger.info(f"Loading dataset manifest from {manifest_path}") + frame = load_manifest_frame(manifest_path, spec) + + if spec.image_column: + frame = frame.copy() + frame[spec.image_column] = [ + resolve_image_path(image_root, value) + for value in frame[spec.image_column] + ] + logger.info(f"Resolved {len(frame)} image paths against {image_root}") + + if len(frame) == 0: + raise ValueError( + f"Dataset {os.path.basename(manifest_path)} contains no records." + ) + + return frame, image_root + + +def build_label_mapping(frame, spec): + """Normalise the label column to contiguous ids. + + Returns ``(frame, label2id, id2label)`` with the label column replaced by + integer ids. Non-numeric class names are supported and preserved in the + mapping so the model config can carry them through to inference. + """ + import pandas as pd + + label_column = spec.label_column + label_names = sorted(pd.Series(frame[label_column]).astype(str).unique()) + + if len(label_names) < 2: + raise ValueError( + f"Dataset needs at least 2 distinct values in the " + f'"{label_column}" column, found {len(label_names)}: {label_names}.' + ) + + label2id = {name: index for index, name in enumerate(label_names)} + id2label = {index: name for name, index in label2id.items()} + + frame = frame.copy() + frame[label_column] = frame[label_column].astype(str).map(label2id) + + logger.info(f"Label mapping ({len(label_names)} classes): {label2id}") + return frame, label2id, id2label + + +def split_frame(frame, split_ratio, seed): + """Split a DataFrame into shuffled train/eval HuggingFace Datasets.""" + from datasets import Dataset + + dataset = Dataset.from_pandas(frame.reset_index(drop=True)) + dataset = dataset.train_test_split(test_size=1 - split_ratio, seed=seed) + + logger.info( + f"Data split: {len(dataset['train'])} train / {len(dataset['test'])} eval" + ) + return dataset["train"].shuffle(seed=seed), dataset["test"].shuffle(seed=seed) + + +# ========================================================================= +# Model helpers +# ========================================================================= + +def resolve_max_context_length(config, tokenizer): + """Resolve the effective maximum text context length from a model config. + + Checks the usual config attributes and returns the smallest valid value, + or None if none can be determined. Multimodal configs keep these on a + nested ``text_config``, so that is consulted too — reading only the top + level silently yields None on every vision-language model. + """ + sources = [config, getattr(config, "text_config", None)] + + candidates = [] + for source in sources: + if source is None: + continue + candidates.extend( + [ + getattr(source, "max_position_embeddings", None), + getattr(source, "n_positions", None), + getattr(source, "seq_length", None), + ] + ) + candidates.append(getattr(tokenizer, "model_max_length", None)) + + def _valid(value): + try: + return value is not None and 0 < float(value) < 1_000_000 + except Exception: + return False + + valid = [int(v) for v in candidates if _valid(v)] + return min(valid) if valid else None + + +def build_training_arguments(**kwargs): + """Construct ``TrainingArguments``, tolerating the evaluation-strategy rename. + + Text tasks run on a transformers 4.36 container, where the argument is + ``evaluation_strategy``. Vision tasks run on 4.56, where it is + ``eval_strategy`` and the old spelling is deprecated. Pinning either name + breaks the other container, so inspect the signature and pass whichever + this transformers actually accepts. + """ + import inspect + + from transformers import TrainingArguments + + strategy = kwargs.pop("eval_strategy", None) + if strategy is not None: + parameters = inspect.signature(TrainingArguments.__init__).parameters + key = "eval_strategy" if "eval_strategy" in parameters else "evaluation_strategy" + kwargs[key] = strategy + + return TrainingArguments(**kwargs) + + +def compute_accuracy(eval_pred): + """Accuracy metric shared by every classification task.""" + import numpy as np + import evaluate + + metric = evaluate.load("accuracy") + logits, labels = eval_pred + if isinstance(logits, tuple): + logits = logits[0] + predictions = np.argmax(logits, axis=-1) + return metric.compute(predictions=predictions, references=labels) + + +def label_names(config, probabilities): + """Recover ordered class names from a model config. + + Every classification task writes ``id2label`` into the config at training + time, so inference can name the probability columns without the dataset. + """ + num_labels = probabilities.shape[1] if len(probabilities.shape) > 1 else 0 + id2label = getattr(config, "id2label", None) + + if isinstance(id2label, dict): + return [ + id2label.get(i) or id2label.get(str(i)) or f"class_{i}" + for i in range(num_labels) + ] + if isinstance(id2label, (list, tuple)): + return list(id2label)[:num_labels] + return [f"class_{i}" for i in range(num_labels)] + + +# ========================================================================= +# Callbacks +# ========================================================================= + +class DynamoDBProgressCallback(TrainerCallback): + """Reports training progress (0.0-1.0) to DynamoDB. + + Throttles writes to every 10 steps to reduce API calls. + Fails silently so that DynamoDB issues don't abort training. + """ + + def __init__(self, table_name, region, pk, sk): + super().__init__() + self._table_name = table_name + self._pk = pk + self._sk = sk + self._client = None + if table_name and pk and sk: + try: + import boto3 + + self._client = boto3.client("dynamodb", region_name=region) + logger.info( + f"DynamoDB progress callback initialized: " + f"table={table_name}, region={region}" + ) + except Exception as e: + logger.warning(f"Could not create DynamoDB client: {e}") + else: + logger.warning( + f"DynamoDB progress callback disabled — missing config: " + f"table_name={'set' if table_name else 'EMPTY'}, " + f"pk={'set' if pk else 'EMPTY'}, " + f"sk={'set' if sk else 'EMPTY'}" + ) + + def _update_progress(self, progress): + if not self._client: + return + try: + self._client.update_item( + TableName=self._table_name, + Key={ + "PK": {"S": self._pk}, + "SK": {"S": self._sk}, + }, + UpdateExpression="SET training_progress = :p", + ExpressionAttributeValues={ + ":p": {"N": str(round(progress, 4))}, + }, + ) + except Exception as e: + logger.warning(f"Failed to update progress in DynamoDB: {e}") + + def _from_state(self, state): + if getattr(state, "max_steps", 0) and state.max_steps > 0: + return min(1.0, max(0.0, state.global_step / state.max_steps)) + if getattr(state, "num_train_epochs", 0) and getattr( + state, "epoch", None + ) is not None: + total = float(state.num_train_epochs) + if total > 0: + return min(1.0, max(0.0, float(state.epoch) / total)) + return None + + def on_train_begin(self, args, state, control, **kwargs): + self._update_progress(0.0) + + def on_log(self, args, state, control, logs=None, **kwargs): + progress = self._from_state(state) + if progress is not None: + self._update_progress(progress) + + def on_step_end(self, args, state, control, **kwargs): + if state.global_step % 10 == 0: + progress = self._from_state(state) + if progress is not None: + self._update_progress(progress) + + def on_train_end(self, args, state, control, **kwargs): + self._update_progress(1.0) + + +class SageMakerLoggingCallback(TrainerCallback): + """Logs epoch accuracy to stdout (captured by CloudWatch).""" + + def on_evaluate(self, args, state, control, metrics=None, **kwargs): + if state.epoch is not None and state.epoch < args.num_train_epochs: + if metrics is not None: + accuracy = metrics.get("eval_accuracy") + if accuracy is not None: + logger.info( + f"Epoch {int(state.epoch)} finished with " + f"eval_accuracy={accuracy:.4f}" + ) + logger.info("Starting next epoch...") + + +def build_callbacks(args): + """Assemble the callback list every task uses.""" + return [ + SageMakerLoggingCallback(), + DynamoDBProgressCallback( + table_name=args.dynamodb_table_name, + region=args.dynamodb_region, + pk=args.job_pk, + sk=args.job_sk, + ), + ] + + +# ========================================================================= +# Inference bundling +# ========================================================================= + +# Files copied into model.tar.gz so Batch Transform can serve the model. +# SageMaker discovers code/inference.py inside the artifact and uses it as the +# handler; the task modules and the task registry travel with it because the +# handler dispatches on the task type the same way the trainer does. +INFERENCE_BUNDLE_FILES = ( + "inference.py", + "requirements.txt", + "task_types.py", + "task_common.py", + "task_text_classification.py", + "task_image_classification.py", + "task_image_text_classification.py", +) + + +def copy_inference_bundle(model_output_dir, script_dir=None): + """Copy the inference handler and its task modules into ``model_dir/code/``. + + ``script_dir`` defaults to the directory this module lives in and exists so + tests can point the copy at a fixture tree instead of monkeypatching + ``os.path``. + """ + code_dir = os.path.join(model_output_dir, "code") + os.makedirs(code_dir, exist_ok=True) + + script_dir = script_dir or os.path.dirname(os.path.abspath(__file__)) + copied = [] + for filename in INFERENCE_BUNDLE_FILES: + source = os.path.join(script_dir, filename) + if not os.path.exists(source): + # task_types.py lives one level up in the package layout; inside + # the flat SageMaker sourcedir it sits alongside this file. + source = os.path.join(os.path.dirname(script_dir), filename) + if os.path.exists(source): + shutil.copy2(source, os.path.join(code_dir, filename)) + copied.append(filename) + logger.info(f"Copied {filename} to {code_dir}") + else: + logger.warning(f"Script file not found, skipping: {filename}") + return copied diff --git a/backend/src/apis/app_api/fine_tuning/sagemaker_scripts/task_image_classification.py b/backend/src/apis/app_api/fine_tuning/sagemaker_scripts/task_image_classification.py new file mode 100644 index 000000000..3fc026218 --- /dev/null +++ b/backend/src/apis/app_api/fine_tuning/sagemaker_scripts/task_image_classification.py @@ -0,0 +1,241 @@ +"""Image classification: image -> label. + +Uses ``AutoModelForImageClassification``, so the trained artifact is an +ordinary HuggingFace model directory that ``from_pretrained`` reloads with no +custom code — the same round-trip the text task gets. + +Images are opened lazily in the collator rather than materialised by a +``Dataset.map``. Mapping pixel tensors over the whole dataset up front is +what turns a modest image corpus into an out-of-memory kill several billed +minutes into training; the dataset carries file paths and the collator reads +each batch's images as it needs them. +""" + +import logging +import os + +try: # package context: unit tests and the app-api container + from . import task_common +except ImportError: # pragma: no cover - flat sourcedir inside the SageMaker DLC + import task_common # type: ignore + +logger = logging.getLogger(__name__) + +BATCH_SIZE = 32 + + +def load_image(path): + """Open an image and normalise it to RGB. + + Greyscale and palette images (common in scanned research corpora) have to + be converted, or the processor emits a tensor with the wrong channel count + and the batch fails to stack. + """ + from PIL import Image + + with Image.open(path) as image: + return image.convert("RGB") + + +def build_collator(processor, label_column, image_column): + """Return a collate_fn that opens each batch's images and stacks them.""" + import torch + + def collate(features): + images = [load_image(feature[image_column]) for feature in features] + batch = processor(images=images, return_tensors="pt") + if label_column is not None and label_column in features[0]: + batch["labels"] = torch.tensor( + [feature[label_column] for feature in features], dtype=torch.long + ) + return batch + + return collate + + +# ========================================================================= +# Training +# ========================================================================= + +def train(args, spec): + """Fine-tune an image classification model.""" + from transformers import ( + AutoConfig, + AutoImageProcessor, + AutoModelForImageClassification, + Trainer, + ) + + train_channel = os.environ.get("SM_CHANNEL_TRAIN", "/opt/ml/input/data/train") + model_dir = os.environ.get("SM_MODEL_DIR", "/opt/ml/model") + + frame, _ = task_common.prepare_dataset(train_channel, spec) + frame, label2id, id2label = task_common.build_label_mapping(frame, spec) + num_labels = len(label2id) + + processor = AutoImageProcessor.from_pretrained(args.model_name_or_path) + if args.image_size: + # Honour the requested resolution where the processor exposes one. + # Some processors key this "shortest_edge" instead of height/width. + if isinstance(getattr(processor, "size", None), dict): + if "height" in processor.size: + processor.size = {"height": args.image_size, "width": args.image_size} + elif "shortest_edge" in processor.size: + processor.size = {"shortest_edge": args.image_size} + + config = AutoConfig.from_pretrained( + args.model_name_or_path, + num_labels=num_labels, + label2id=label2id, + id2label=id2label, + ) + + model = AutoModelForImageClassification.from_pretrained( + args.model_name_or_path, + config=config, + torch_dtype="auto", + # The base checkpoint's head has the wrong class count (or none at + # all). Without this, loading raises instead of re-initialising it. + ignore_mismatched_sizes=True, + ) + + train_dataset, eval_dataset = task_common.split_frame( + frame, args.split_ratio, args.seed + ) + + training_args = task_common.build_training_arguments( + output_dir="/opt/ml/checkpoints", + learning_rate=args.learning_rate, + num_train_epochs=args.epochs, + per_device_train_batch_size=args.per_device_train_batch_size, + weight_decay=args.weight_decay, + eval_strategy="epoch", + save_strategy="no", + logging_dir="/opt/ml/output/tensorboard", + # The collator returns pixel tensors, not model-signature columns; + # Trainer's default column pruning would strip the image paths it + # needs before the collator ever sees them. + remove_unused_columns=False, + ) + + trainer = Trainer( + model=model, + args=training_args, + train_dataset=train_dataset, + eval_dataset=eval_dataset, + compute_metrics=task_common.compute_accuracy, + callbacks=task_common.build_callbacks(args), + data_collator=build_collator(processor, spec.label_column, spec.image_column), + ) + + logger.info( + f"Starting fine-tuning: task={spec.task_type}, " + f"model={args.model_name_or_path}, epochs={args.epochs}, " + f"batch_size={args.per_device_train_batch_size}, " + f"image_size={args.image_size}" + ) + trainer.train() + + metrics = trainer.evaluate() + logger.info(f"Final evaluation: accuracy={metrics.get('eval_accuracy', 'N/A')}") + + trainer.save_model(model_dir) + processor.save_pretrained(model_dir) + logger.info(f"Saved model to {model_dir}") + + return metrics + + +# ========================================================================= +# Inference +# ========================================================================= + +def model_fn(model_dir): + """Load the model and image processor for Batch Transform.""" + import torch + from transformers import AutoImageProcessor, AutoModelForImageClassification + + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + + processor = AutoImageProcessor.from_pretrained(model_dir) + model = AutoModelForImageClassification.from_pretrained( + model_dir, torch_dtype="auto" + ) + model.to(device) + model.eval() + + logger.info(f"Loaded image classification model from {model_dir} on {device}") + return {"model": model, "processor": processor, "device": device} + + +def input_fn(request_body, content_type, spec): + """Unpack a .zip of images into records. + + Batch Transform hands the archive over as a single payload; unpacking it + here is what keeps the one-file-in, one-CSV-out contract identical to the + text tasks, and therefore keeps the whole result viewer unchanged. + """ + import io + import tempfile + + if not isinstance(request_body, (bytes, bytearray)): + raise ValueError( + f"Expected archive bytes for {spec.task_type}, got {type(request_body).__name__}" + ) + + work_dir = tempfile.mkdtemp(prefix="inference-images-") + archive_path = os.path.join(work_dir, "input.zip") + with open(archive_path, "wb") as handle: + handle.write(bytes(request_body)) + + root = task_common.extract_archive(archive_path, os.path.join(work_dir, "extracted")) + + records = [] + for directory, _dirs, files in os.walk(root): + for name in sorted(files): + if name.startswith("."): + continue + if not name.lower().endswith(task_common.SUPPORTED_IMAGE_EXTENSIONS): + continue + path = os.path.join(directory, name) + records.append( + { + spec.image_column: path, + "identifier": os.path.relpath(path, root), + } + ) + + if not records: + raise ValueError("Archive contains no readable image files.") + + logger.info(f"Unpacked {len(records)} images for inference") + return records + + +def predict_fn(records, loaded, spec): + """Run batched image inference with softmax probabilities.""" + import numpy as np + import torch + + model, processor, device = loaded["model"], loaded["processor"], loaded["device"] + + if not records: + return {"identifiers": [], "probabilities": np.zeros((0, 0)), "labels": []} + + collate = build_collator(processor, None, spec.image_column) + + batches = [] + with torch.no_grad(): + for start in range(0, len(records), BATCH_SIZE): + batch = collate(records[start : start + BATCH_SIZE]) + batch = {k: v.to(device) for k, v in batch.items()} + logits = model(**batch).logits + batches.append(torch.softmax(logits, dim=-1).cpu().numpy()) + + probabilities = np.vstack(batches) if batches else np.zeros((0, 0)) + + return { + "identifiers": [record["identifier"] for record in records], + "probabilities": probabilities, + "labels": task_common.label_names(model.config, probabilities), + } diff --git a/backend/src/apis/app_api/fine_tuning/sagemaker_scripts/task_image_text_classification.py b/backend/src/apis/app_api/fine_tuning/sagemaker_scripts/task_image_text_classification.py new file mode 100644 index 000000000..c53b76e0f --- /dev/null +++ b/backend/src/apis/app_api/fine_tuning/sagemaker_scripts/task_image_text_classification.py @@ -0,0 +1,404 @@ +"""Image + text classification: (image, text) -> label. + +Transformers has no general auto-class for this shape. ``AutoModelFor +SequenceClassification`` is text-only, and ``AutoModelForImageTextToText`` is +generative — it emits tokens, not a class distribution. So this task builds +an explicit small model instead of pretending an auto-class fits: + + frozen-or-tuned dual encoder (CLIP / SigLIP / ALIGN) + -> pooled image embedding ++ pooled text embedding + -> dropout -> linear classifier -> logits + +That composition works across the whole CLIP-family of checkpoints rather than +a single architecture, and it keeps the output contract — a softmax over the +dataset's own classes — byte-identical to the other two tasks, so the Batch +Transform result CSV and the entire result viewer stay unchanged. + +Because the result is not a ``PreTrainedModel``, ``Trainer.save_model`` would +write a bare state dict and lose the backbone config. The artifact is +therefore saved explicitly: the backbone via ``save_pretrained``, the head via +``torch.save``, and the wiring via ``fusion_head.json``. :func:`load` is the +exact inverse and is what ``model_fn`` calls at inference. +""" + +import json +import logging +import os + +try: # package context: unit tests and the app-api container + from . import task_common +except ImportError: # pragma: no cover - flat sourcedir inside the SageMaker DLC + import task_common # type: ignore + +logger = logging.getLogger(__name__) + +BATCH_SIZE = 32 + +#: Written next to the backbone so :func:`load` can rebuild the head without +#: the training arguments. +HEAD_CONFIG_FILENAME = "fusion_head.json" +HEAD_WEIGHTS_FILENAME = "fusion_head.pt" + + +# ========================================================================= +# Model +# ========================================================================= + +def build_fusion_model(backbone, image_dim, text_dim, num_labels, dropout=0.1): + """Wrap a dual encoder with a concat-and-classify head. + + Defined as a factory rather than a module-level class so this file stays + importable without torch — the backend venv and the unit tests have no ML + stack, and the dataset/artifact contract has to be testable there. + """ + import torch + import torch.nn as nn + + class ImageTextClassifier(nn.Module): + def __init__(self): + super().__init__() + self.backbone = backbone + self.image_dim = image_dim + self.text_dim = text_dim + self.num_labels = num_labels + self.dropout = nn.Dropout(dropout) + self.classifier = nn.Linear(image_dim + text_dim, num_labels) + + def encode(self, pixel_values, input_ids, attention_mask=None): + image_features = self.backbone.get_image_features(pixel_values=pixel_values) + text_features = self.backbone.get_text_features( + input_ids=input_ids, attention_mask=attention_mask + ) + return image_features, text_features + + def forward(self, pixel_values=None, input_ids=None, attention_mask=None, labels=None, **_ignored): + image_features, text_features = self.encode( + pixel_values, input_ids, attention_mask + ) + fused = torch.cat([image_features, text_features], dim=-1) + logits = self.classifier(self.dropout(fused)) + + loss = None + if labels is not None: + loss = nn.functional.cross_entropy(logits, labels) + + # Trainer accepts a dict output as long as "loss" is present when + # labels were supplied. + return {"loss": loss, "logits": logits} if loss is not None else {"logits": logits} + + def save(self, output_dir): + os.makedirs(output_dir, exist_ok=True) + self.backbone.save_pretrained(output_dir) + torch.save( + self.classifier.state_dict(), + os.path.join(output_dir, HEAD_WEIGHTS_FILENAME), + ) + with open(os.path.join(output_dir, HEAD_CONFIG_FILENAME), "w") as handle: + json.dump( + { + "image_dim": self.image_dim, + "text_dim": self.text_dim, + "num_labels": self.num_labels, + "dropout": dropout, + }, + handle, + indent=2, + ) + + return ImageTextClassifier() + + +def load(model_dir, id2label): + """Rebuild a saved fusion model. The exact inverse of ``model.save``.""" + import torch + from transformers import AutoModel + + with open(os.path.join(model_dir, HEAD_CONFIG_FILENAME)) as handle: + head_config = json.load(handle) + + backbone = AutoModel.from_pretrained(model_dir) + model = build_fusion_model( + backbone, + image_dim=head_config["image_dim"], + text_dim=head_config["text_dim"], + num_labels=head_config["num_labels"], + dropout=head_config.get("dropout", 0.1), + ) + model.classifier.load_state_dict( + torch.load( + os.path.join(model_dir, HEAD_WEIGHTS_FILENAME), map_location="cpu" + ) + ) + model.id2label = id2label + return model + + +def resolve_projection_dims(config): + """Determine the pooled image and text embedding widths for a dual encoder. + + CLIP-family configs expose a shared ``projection_dim``; others fall back to + the per-tower hidden sizes. Getting this wrong only surfaces as a shape + error deep inside the first forward pass, so it is resolved up front. + """ + projection_dim = getattr(config, "projection_dim", None) + if projection_dim: + return int(projection_dim), int(projection_dim) + + vision_config = getattr(config, "vision_config", None) + text_config = getattr(config, "text_config", None) + image_dim = getattr(vision_config, "hidden_size", None) + text_dim = getattr(text_config, "hidden_size", None) + + if not image_dim or not text_dim: + raise ValueError( + "Could not determine image/text embedding widths from the model " + "config. This task needs a dual-encoder checkpoint that exposes " + "get_image_features and get_text_features (CLIP, SigLIP, ALIGN)." + ) + return int(image_dim), int(text_dim) + + +# ========================================================================= +# Collation +# ========================================================================= + +def build_collator(processor, spec, context_length, include_labels=True): + """Return a collate_fn producing pixel_values, input_ids and labels.""" + import torch + + try: + from . import task_image_classification + except ImportError: # pragma: no cover - flat sourcedir + import task_image_classification # type: ignore + + def collate(features): + images = [ + task_image_classification.load_image(feature[spec.image_column]) + for feature in features + ] + texts = [str(feature[spec.text_column]) for feature in features] + + batch = processor( + images=images, + text=texts, + return_tensors="pt", + padding="max_length", + truncation=True, + max_length=context_length, + ) + batch = { + key: value + for key, value in batch.items() + if key in ("pixel_values", "input_ids", "attention_mask") + } + + if include_labels and spec.label_column in features[0]: + batch["labels"] = torch.tensor( + [feature[spec.label_column] for feature in features], dtype=torch.long + ) + return batch + + return collate + + +# ========================================================================= +# Training +# ========================================================================= + +def train(args, spec): + """Fine-tune a dual encoder plus fusion head on image/text pairs.""" + from transformers import AutoConfig, AutoModel, AutoProcessor, Trainer + + train_channel = os.environ.get("SM_CHANNEL_TRAIN", "/opt/ml/input/data/train") + model_dir = os.environ.get("SM_MODEL_DIR", "/opt/ml/model") + + frame, _ = task_common.prepare_dataset(train_channel, spec) + frame, label2id, id2label = task_common.build_label_mapping(frame, spec) + num_labels = len(label2id) + + processor = AutoProcessor.from_pretrained(args.model_name_or_path) + config = AutoConfig.from_pretrained(args.model_name_or_path) + backbone = AutoModel.from_pretrained(args.model_name_or_path, torch_dtype="auto") + + if not (hasattr(backbone, "get_image_features") and hasattr(backbone, "get_text_features")): + raise ValueError( + f"{args.model_name_or_path} is not a dual-encoder model: it does " + f"not expose get_image_features/get_text_features. Choose a " + f"CLIP, SigLIP or ALIGN style checkpoint for " + f"{spec.display_name.lower()}." + ) + + image_dim, text_dim = resolve_projection_dims(config) + logger.info(f"Fusion head: image_dim={image_dim}, text_dim={text_dim}, classes={num_labels}") + + tokenizer = getattr(processor, "tokenizer", None) + max_ctx = task_common.resolve_max_context_length(config, tokenizer) + effective_context = ( + min(args.context_length, max_ctx) if max_ctx else args.context_length + ) + logger.info( + f"Context length: requested={args.context_length}, " + f"effective={effective_context}" + f"{' (capped)' if max_ctx and args.context_length > max_ctx else ''}" + ) + + model = build_fusion_model(backbone, image_dim, text_dim, num_labels) + + train_dataset, eval_dataset = task_common.split_frame( + frame, args.split_ratio, args.seed + ) + + training_args = task_common.build_training_arguments( + output_dir="/opt/ml/checkpoints", + learning_rate=args.learning_rate, + num_train_epochs=args.epochs, + per_device_train_batch_size=args.per_device_train_batch_size, + weight_decay=args.weight_decay, + eval_strategy="epoch", + save_strategy="no", + logging_dir="/opt/ml/output/tensorboard", + remove_unused_columns=False, + label_names=["labels"], + ) + + trainer = Trainer( + model=model, + args=training_args, + train_dataset=train_dataset, + eval_dataset=eval_dataset, + compute_metrics=task_common.compute_accuracy, + callbacks=task_common.build_callbacks(args), + data_collator=build_collator(processor, spec, effective_context), + ) + + logger.info( + f"Starting fine-tuning: task={spec.task_type}, " + f"model={args.model_name_or_path}, epochs={args.epochs}, " + f"batch_size={args.per_device_train_batch_size}" + ) + trainer.train() + + metrics = trainer.evaluate() + logger.info(f"Final evaluation: accuracy={metrics.get('eval_accuracy', 'N/A')}") + + # Trainer.save_model cannot round-trip a plain nn.Module, so save the + # backbone, head and wiring explicitly. + model.save(model_dir) + processor.save_pretrained(model_dir) + with open(os.path.join(model_dir, "label_mapping.json"), "w") as handle: + json.dump({"label2id": label2id, "id2label": id2label}, handle, indent=2) + logger.info(f"Saved model to {model_dir}") + + return metrics + + +# ========================================================================= +# Inference +# ========================================================================= + +def model_fn(model_dir): + """Load the fusion model, processor and label mapping.""" + import torch + from transformers import AutoProcessor + + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + + with open(os.path.join(model_dir, "label_mapping.json")) as handle: + mapping = json.load(handle) + id2label = {int(k): v for k, v in mapping["id2label"].items()} + + processor = AutoProcessor.from_pretrained(model_dir) + model = load(model_dir, id2label) + model.to(device) + model.eval() + + logger.info(f"Loaded image+text classification model from {model_dir} on {device}") + return { + "model": model, + "processor": processor, + "device": device, + "id2label": id2label, + } + + +def input_fn(request_body, content_type, spec): + """Unpack a .zip holding a manifest plus images into records. + + The manifest needs the task's ``image`` and ``text`` columns; ``label`` is + not required, since inference input is unlabelled. + """ + import tempfile + + if not isinstance(request_body, (bytes, bytearray)): + raise ValueError( + f"Expected archive bytes for {spec.task_type}, got {type(request_body).__name__}" + ) + + work_dir = tempfile.mkdtemp(prefix="inference-image-text-") + archive_path = os.path.join(work_dir, "input.zip") + with open(archive_path, "wb") as handle: + handle.write(bytes(request_body)) + + root = task_common.extract_archive(archive_path, os.path.join(work_dir, "extracted")) + manifest_path = task_common.find_file_in_dir(root, spec.manifest_extensions, "manifest") + + import pandas as pd + + reader_name, reader_kwargs = task_common.resolve_dataset_reader(manifest_path) + frame = getattr(pd, reader_name)(manifest_path, **reader_kwargs) + + missing = [c for c in (spec.image_column, spec.text_column) if c not in frame.columns] + if missing: + raise ValueError( + f"Inference manifest is missing required column(s): {', '.join(missing)}." + ) + + records = [] + for _index, row in frame.iterrows(): + relative = str(row[spec.image_column]).strip() + records.append( + { + spec.image_column: task_common.resolve_image_path(root, relative), + spec.text_column: str(row[spec.text_column]), + "identifier": relative, + } + ) + + if not records: + raise ValueError("Inference manifest contains no records.") + + logger.info(f"Unpacked {len(records)} image/text pairs for inference") + return records + + +def predict_fn(records, loaded, spec): + """Run batched image+text inference with softmax probabilities.""" + import numpy as np + import torch + + model, processor, device = loaded["model"], loaded["processor"], loaded["device"] + id2label = loaded["id2label"] + + if not records: + return {"identifiers": [], "probabilities": np.zeros((0, 0)), "labels": []} + + tokenizer = getattr(processor, "tokenizer", None) + context_length = getattr(tokenizer, "model_max_length", 77) or 77 + collate = build_collator(processor, spec, context_length, include_labels=False) + + batches = [] + with torch.no_grad(): + for start in range(0, len(records), BATCH_SIZE): + batch = collate(records[start : start + BATCH_SIZE]) + batch = {k: v.to(device) for k, v in batch.items()} + logits = model(**batch)["logits"] + batches.append(torch.softmax(logits, dim=-1).cpu().numpy()) + + probabilities = np.vstack(batches) if batches else np.zeros((0, 0)) + num_labels = probabilities.shape[1] if len(probabilities.shape) > 1 else 0 + + return { + "identifiers": [record["identifier"] for record in records], + "probabilities": probabilities, + "labels": [id2label.get(i, f"class_{i}") for i in range(num_labels)], + } diff --git a/backend/src/apis/app_api/fine_tuning/sagemaker_scripts/task_text_classification.py b/backend/src/apis/app_api/fine_tuning/sagemaker_scripts/task_text_classification.py new file mode 100644 index 000000000..39be5f504 --- /dev/null +++ b/backend/src/apis/app_api/fine_tuning/sagemaker_scripts/task_text_classification.py @@ -0,0 +1,213 @@ +"""Text classification: text -> label. + +The original fine-tuning path, moved here unchanged in behaviour. Every model +trained before task types existed was trained by this code, so its handling of +the pad token and of ``resize_token_embeddings`` is preserved verbatim: the +decoder-only models in the catalog (GPT-2, SmolLM2, EuroLLM) ship without a +pad token and genuinely need one added before they can be batched. + +That same manoeuvre is actively harmful on a vision-language model, which is +one of the reasons the tasks are separate modules rather than branches. +""" + +import logging +import os + +try: # package context: unit tests and the app-api container + from . import task_common +except ImportError: # pragma: no cover - flat sourcedir inside the SageMaker DLC + import task_common # type: ignore + +logger = logging.getLogger(__name__) + +BATCH_SIZE = 64 + + +# ========================================================================= +# Training +# ========================================================================= + +def train(args, spec): + """Fine-tune a sequence classification model on text records.""" + from transformers import ( + AutoTokenizer, + AutoConfig, + AutoModelForSequenceClassification, + Trainer, + ) + + train_channel = os.environ.get("SM_CHANNEL_TRAIN", "/opt/ml/input/data/train") + model_dir = os.environ.get("SM_MODEL_DIR", "/opt/ml/model") + + frame, _ = task_common.prepare_dataset(train_channel, spec) + frame, label2id, id2label = task_common.build_label_mapping(frame, spec) + num_labels = len(label2id) + + # Load tokenizer and add a PAD token. Decoder-only checkpoints have none, + # and padding is required to batch. + tokenizer = AutoTokenizer.from_pretrained(args.model_name_or_path) + tokenizer.add_special_tokens({"pad_token": "[PAD]"}) + pad_token_id = tokenizer( + "[PAD]", truncation=True, padding=False, return_tensors="pt" + )["input_ids"][0][0].item() + + config = AutoConfig.from_pretrained( + args.model_name_or_path, + num_labels=num_labels, + label2id=label2id, + id2label=id2label, + pad_token_id=pad_token_id, + ) + + max_ctx = task_common.resolve_max_context_length(config, tokenizer) + effective_context = ( + min(args.context_length, max_ctx) if max_ctx else args.context_length + ) + logger.info( + f"Context length: requested={args.context_length}, " + f"effective={effective_context}" + f"{' (capped)' if max_ctx and args.context_length > max_ctx else ''}" + ) + + model = AutoModelForSequenceClassification.from_pretrained( + args.model_name_or_path, + config=config, + torch_dtype="auto", + ) + model.resize_token_embeddings(len(tokenizer)) + + text_column = spec.text_column + + def tokenize_function(examples): + return tokenizer( + examples[text_column], + max_length=effective_context, + padding="max_length", + truncation=True, + ) + + train_dataset, eval_dataset = task_common.split_frame( + frame, args.split_ratio, args.seed + ) + train_dataset = train_dataset.map(tokenize_function, batched=True) + eval_dataset = eval_dataset.map(tokenize_function, batched=True) + + training_args = task_common.build_training_arguments( + output_dir="/opt/ml/checkpoints", + learning_rate=args.learning_rate, + num_train_epochs=args.epochs, + per_device_train_batch_size=args.per_device_train_batch_size, + weight_decay=args.weight_decay, + eval_strategy="epoch", + save_strategy="no", + logging_dir="/opt/ml/output/tensorboard", + ) + + trainer = Trainer( + model=model, + args=training_args, + train_dataset=train_dataset, + eval_dataset=eval_dataset, + compute_metrics=task_common.compute_accuracy, + callbacks=task_common.build_callbacks(args), + ) + + logger.info( + f"Starting fine-tuning: task={spec.task_type}, " + f"model={args.model_name_or_path}, epochs={args.epochs}, " + f"batch_size={args.per_device_train_batch_size}" + ) + trainer.train() + + metrics = trainer.evaluate() + logger.info(f"Final evaluation: accuracy={metrics.get('eval_accuracy', 'N/A')}") + + trainer.save_model(model_dir) + tokenizer.save_pretrained(model_dir) + logger.info(f"Saved model to {model_dir}") + + return metrics + + +# ========================================================================= +# Inference +# ========================================================================= + +def model_fn(model_dir): + """Load the model and tokenizer for Batch Transform.""" + import torch + from transformers import AutoTokenizer, AutoModelForSequenceClassification + + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + + tokenizer = AutoTokenizer.from_pretrained(model_dir) + model = AutoModelForSequenceClassification.from_pretrained( + model_dir, + torch_dtype="auto", + ) + model.resize_token_embeddings(len(tokenizer)) + model.to(device) + model.eval() + + logger.info(f"Loaded text classification model from {model_dir} on {device}") + return {"model": model, "tokenizer": tokenizer, "device": device} + + +def input_fn(request_body, content_type, spec): + """Parse Batch Transform input into records. + + Supports text/plain (one text per line) and application/json (a list of + strings or ``{"texts": [...]}``). + """ + import json + + if isinstance(request_body, (bytes, bytearray)): + request_body = request_body.decode("utf-8") + + if content_type in ("text/plain", "text/csv"): + texts = [line.strip() for line in request_body.strip().split("\n") if line.strip()] + elif content_type == "application/json": + data = json.loads(request_body) + if isinstance(data, list): + texts = [str(item) for item in data if str(item).strip()] + elif isinstance(data, dict) and "texts" in data: + texts = [str(t) for t in data["texts"] if str(t).strip()] + else: + raise ValueError('JSON input must be a list or {"texts": [...]}') + else: + raise ValueError(f"Unsupported content type: {content_type}") + + return [{"text": text, "identifier": text} for text in texts] + + +def predict_fn(records, loaded, spec): + """Run batched inference, returning identifiers, probabilities and labels.""" + import numpy as np + import torch + + model, tokenizer, device = loaded["model"], loaded["tokenizer"], loaded["device"] + texts = [record["text"] for record in records] + + if not texts: + return {"identifiers": [], "probabilities": np.zeros((0, 0)), "labels": []} + + batches = [] + with torch.no_grad(): + for start in range(0, len(texts), BATCH_SIZE): + encoded = tokenizer( + texts[start : start + BATCH_SIZE], + padding=True, + truncation=True, + return_tensors="pt", + ) + encoded = {k: v.to(device) for k, v in encoded.items()} + logits = model(**encoded).logits + batches.append(torch.softmax(logits, dim=-1).cpu().numpy()) + + probabilities = np.vstack(batches) if batches else np.zeros((0, 0)) + + return { + "identifiers": [record["identifier"] for record in records], + "probabilities": probabilities, + "labels": task_common.label_names(model.config, probabilities), + } diff --git a/backend/src/apis/app_api/fine_tuning/sagemaker_scripts/train.py b/backend/src/apis/app_api/fine_tuning/sagemaker_scripts/train.py index 145170d68..3a2d47613 100644 --- a/backend/src/apis/app_api/fine_tuning/sagemaker_scripts/train.py +++ b/backend/src/apis/app_api/fine_tuning/sagemaker_scripts/train.py @@ -1,31 +1,38 @@ -"""SageMaker training entry point for fine-tuning text classification models. +"""SageMaker training entry point. -Adapted from the original Flask fine_tune.py for SageMaker HuggingFace DLC. +A dispatcher. It parses the hyperparameters SageMaker passes as CLI args, +resolves the task type against the registry, and hands off to the matching +``task_*`` module. All model-specific behaviour lives in those modules; this +file deliberately knows nothing about auto-classes or collators. SageMaker paths: - - Input data: /opt/ml/input/data/train/ (CSV with text,label columns) - - Model output: /opt/ml/model/ (auto-uploaded to S3 as model.tar.gz) - - Checkpoints: /opt/ml/checkpoints/ (not used) + - Input data: /opt/ml/input/data/train/ (dataset file, or .zip archive) + - Model output: /opt/ml/model/ (auto-uploaded as model.tar.gz) + - Checkpoints: /opt/ml/checkpoints/ -Usage: - The HuggingFace DLC invokes this script with hyperparameters as CLI args: - python train.py --model_name_or_path bert-base-uncased --epochs 3 ... +The HuggingFace DLC invokes this script with hyperparameters as CLI args: + python train.py --model_name_or_path google/vit-base-patch16-224 \ + --task_type image-classification --epochs 3 ... """ import argparse +import json +import logging import os import sys -import shutil -import logging -# Heavy ML dependencies are imported lazily inside train() since they are only -# available in the SageMaker DLC container. Utility functions and callbacks -# must remain importable without torch/transformers so they can be unit-tested -# locally. -try: - from transformers import TrainerCallback -except ImportError: # pragma: no cover – local dev/test without transformers - TrainerCallback = object +try: # package context: unit tests and the app-api container + from .. import task_types + from . import task_common + from . import task_image_classification + from . import task_image_text_classification + from . import task_text_classification +except ImportError: # pragma: no cover - flat sourcedir inside the SageMaker DLC + import task_types # type: ignore + import task_common # type: ignore + import task_image_classification # type: ignore + import task_image_text_classification # type: ignore + import task_text_classification # type: ignore logger = logging.getLogger(__name__) logging.basicConfig( @@ -35,399 +42,42 @@ ) -# ========================================================================= -# Callbacks -# ========================================================================= - - -class DynamoDBProgressCallback(TrainerCallback): - """Reports training progress (0.0-1.0) to DynamoDB. - - Throttles writes to every 10 steps to reduce API calls. - Fails silently so that DynamoDB issues don't abort training. - """ - - def __init__(self, table_name, region, pk, sk): - super().__init__() - self._table_name = table_name - self._pk = pk - self._sk = sk - self._client = None - if table_name and pk and sk: - try: - import boto3 - - self._client = boto3.client("dynamodb", region_name=region) - logger.info( - f"DynamoDB progress callback initialized: " - f"table={table_name}, region={region}" - ) - except Exception as e: - logger.warning(f"Could not create DynamoDB client: {e}") - else: - logger.warning( - f"DynamoDB progress callback disabled — missing config: " - f"table_name={'set' if table_name else 'EMPTY'}, " - f"pk={'set' if pk else 'EMPTY'}, " - f"sk={'set' if sk else 'EMPTY'}" - ) - - def _update_progress(self, progress): - if not self._client: - return - try: - self._client.update_item( - TableName=self._table_name, - Key={ - "PK": {"S": self._pk}, - "SK": {"S": self._sk}, - }, - UpdateExpression="SET training_progress = :p", - ExpressionAttributeValues={ - ":p": {"N": str(round(progress, 4))}, - }, - ) - except Exception as e: - logger.warning(f"Failed to update progress in DynamoDB: {e}") - - def _from_state(self, state): - if getattr(state, "max_steps", 0) and state.max_steps > 0: - return min(1.0, max(0.0, state.global_step / state.max_steps)) - if getattr(state, "num_train_epochs", 0) and getattr( - state, "epoch", None - ) is not None: - total = float(state.num_train_epochs) - if total > 0: - return min(1.0, max(0.0, float(state.epoch) / total)) - return None - - def on_train_begin(self, args, state, control, **kwargs): - self._update_progress(0.0) - - def on_log(self, args, state, control, logs=None, **kwargs): - progress = self._from_state(state) - if progress is not None: - self._update_progress(progress) - - def on_step_end(self, args, state, control, **kwargs): - if state.global_step % 10 == 0: - progress = self._from_state(state) - if progress is not None: - self._update_progress(progress) - - def on_train_end(self, args, state, control, **kwargs): - self._update_progress(1.0) - - -class SageMakerLoggingCallback(TrainerCallback): - """Logs epoch accuracy to stdout (captured by CloudWatch).""" - - def on_evaluate(self, args, state, control, metrics=None, **kwargs): - if state.epoch is not None and state.epoch < args.num_train_epochs: - if metrics is not None: - accuracy = metrics.get("eval_accuracy") - if accuracy is not None: - logger.info( - f"Epoch {int(state.epoch)} finished with " - f"eval_accuracy={accuracy:.4f}" - ) - logger.info("Starting next epoch...") - - -# ========================================================================= -# Helper Functions -# ========================================================================= - - -def resolve_max_context_length(config, tokenizer): - """Resolve the effective maximum context length from model config. - - Checks multiple config attributes and returns the smallest valid value. - Returns None if no valid context length can be determined. - """ - candidates = [ - getattr(config, "max_position_embeddings", None), - getattr(config, "n_positions", None), - getattr(config, "seq_length", None), - getattr(tokenizer, "model_max_length", None), - ] - - def _valid(v): - try: - return v is not None and float(v) > 0 and float(v) < 1_000_000 - except Exception: - return False - - valid_vals = [int(v) for v in candidates if _valid(v)] - return min(valid_vals) if valid_vals else None - - -# Dataset formats the trainer can read, mapped to the pandas reader that loads -# them. A training record has to carry both a "text" and a "label" field, which -# is why plain .txt is absent: it has no way to express the label. (.txt stays -# valid for *inference* input, which is unlabelled — one record per line.) -# -# Kept as data rather than an if/elif chain so the supported-format contract can -# be asserted without importing pandas, which only exists inside the SageMaker -# training container and not in the backend venv. -DATASET_READERS = { - ".csv": ("read_csv", {}), - ".jsonl": ("read_json", {"lines": True}), - ".json": ("read_json", {}), +# Maps a task type to the module implementing it. Adding a task means adding +# a module and one entry here — no branching inside the trainer. +TASK_MODULES = { + task_types.TEXT_CLASSIFICATION: task_text_classification, + task_types.IMAGE_CLASSIFICATION: task_image_classification, + task_types.IMAGE_TEXT_CLASSIFICATION: task_image_text_classification, } -SUPPORTED_DATASET_EXTENSIONS = tuple(DATASET_READERS) -REQUIRED_DATASET_COLUMNS = ("text", "label") +def resolve_task_module(task_type): + """Return the module implementing ``task_type``. - -def find_dataset_in_channel(channel_dir): - """Find the first supported dataset file in a SageMaker input channel. - - Raises FileNotFoundError if the directory is missing, or if it holds no - file with a supported extension. + Raises ValueError for a task the registry knows but no module implements, + which would otherwise surface as a confusing KeyError mid-training. """ - if not os.path.isdir(channel_dir): - raise FileNotFoundError(f"Channel directory does not exist: {channel_dir}") - - for f in sorted(os.listdir(channel_dir)): - if f.lower().endswith(SUPPORTED_DATASET_EXTENSIONS): - return os.path.join(channel_dir, f) - - supported = ", ".join(SUPPORTED_DATASET_EXTENSIONS) - raise FileNotFoundError( - f"No dataset file found in {channel_dir}. Supported formats: {supported}" - ) - - -def resolve_dataset_reader(dataset_path): - """Return the (pandas reader name, kwargs) pair for a dataset file. - - Raises ValueError for an extension the trainer cannot read. - """ - extension = os.path.splitext(dataset_path)[1].lower() - - if extension not in DATASET_READERS: - supported = ", ".join(SUPPORTED_DATASET_EXTENSIONS) - raise ValueError( - f"Unsupported dataset format '{extension}'. Supported formats: {supported}" - ) - - return DATASET_READERS[extension] - - -def validate_dataset_columns(columns, dataset_path): - """Raise ValueError if a required column is absent from the dataset.""" - missing = [c for c in REQUIRED_DATASET_COLUMNS if c not in columns] - if missing: + spec = task_types.get_task_spec(task_type) + module = TASK_MODULES.get(spec.task_type) + if module is None: # pragma: no cover - registry/module drift raise ValueError( - f"Dataset {os.path.basename(dataset_path)} is missing required " - f"column(s): {', '.join(missing)}. Each record needs a \"text\" " - f'and a "label" field.' - ) - - -def load_dataset_frame(dataset_path): - """Load a dataset file into a DataFrame with "text" and "label" columns.""" - import pandas as pd - - reader_name, reader_kwargs = resolve_dataset_reader(dataset_path) - df = getattr(pd, reader_name)(dataset_path, **reader_kwargs) - - validate_dataset_columns(df.columns, dataset_path) - - return df - - -def copy_inference_script(model_output_dir): - """Copy inference.py and requirements.txt into model_output_dir/code/. - - SageMaker Batch Transform discovers code/inference.py in model.tar.gz - and uses it as the custom inference handler. - """ - code_dir = os.path.join(model_output_dir, "code") - os.makedirs(code_dir, exist_ok=True) - - script_dir = os.path.dirname(os.path.abspath(__file__)) - for filename in ("inference.py", "requirements.txt"): - src = os.path.join(script_dir, filename) - if os.path.exists(src): - shutil.copy2(src, os.path.join(code_dir, filename)) - logger.info(f"Copied {filename} to {code_dir}") - else: - logger.warning(f"Script file not found, skipping: {src}") - - -# ========================================================================= -# Main Training Function -# ========================================================================= - - -def train(args): - """Main training logic adapted from the original fine_tune.py.""" - import numpy as np - import pandas as pd - from transformers import ( - AutoTokenizer, - AutoConfig, - AutoModelForSequenceClassification, - Trainer, - TrainingArguments, - ) - from datasets import Dataset - import evaluate - - # SageMaker paths - train_channel = os.environ.get( - "SM_CHANNEL_TRAIN", "/opt/ml/input/data/train" - ) - model_dir = os.environ.get("SM_MODEL_DIR", "/opt/ml/model") - - # Find and load the dataset (CSV / JSONL / JSON) - dataset_path = find_dataset_in_channel(train_channel) - logger.info(f"Loading dataset from {dataset_path}") - df = load_dataset_frame(dataset_path) - - # Label normalization — support non-numeric class labels - label_names = sorted(list(pd.Series(df["label"]).astype(str).unique())) - label2id = {name: i for i, name in enumerate(label_names)} - id2label = {i: name for name, i in label2id.items()} - df["label"] = df["label"].astype(str).map(label2id) - num_labels = len(label_names) - logger.info(f"Label mapping ({num_labels} classes): {label2id}") - - # Load tokenizer and add PAD token - tokenizer = AutoTokenizer.from_pretrained(args.model_name_or_path) - tokenizer.add_special_tokens({"pad_token": "[PAD]"}) - pad_token_id = tokenizer( - "[PAD]", truncation=True, padding=False, return_tensors="pt" - )["input_ids"][0][0].item() - - # Load model config with label mappings - config = AutoConfig.from_pretrained( - args.model_name_or_path, - num_labels=num_labels, - label2id=label2id, - id2label=id2label, - pad_token_id=pad_token_id, - ) - - # Resolve effective context length - max_ctx = resolve_max_context_length(config, tokenizer) - effective_context = ( - min(args.context_length, max_ctx) if max_ctx else args.context_length - ) - logger.info( - f"Context length: requested={args.context_length}, " - f"effective={effective_context}" - f"{ ' (capped)' if max_ctx and args.context_length > max_ctx else ''}" - ) - - # Load model - model = AutoModelForSequenceClassification.from_pretrained( - args.model_name_or_path, - config=config, - torch_dtype="auto", - ) - model.resize_token_embeddings(len(tokenizer)) - - # Tokenization - def tokenize_function(examples): - return tokenizer( - examples["text"], - max_length=effective_context, - padding="max_length", - truncation=True, + f"Task type '{spec.task_type}' is registered but has no trainer module." ) + return module, spec - # Train/test split - dataset = Dataset.from_pandas(df) - dataset = dataset.train_test_split( - test_size=1 - args.split_ratio, seed=args.seed - ) - logger.info( - f"Data split: {len(dataset['train'])} train / " - f"{len(dataset['test'])} test" - ) - tokenized_datasets = dataset.map(tokenize_function, batched=True) - train_dataset = tokenized_datasets["train"].shuffle(seed=args.seed) - eval_dataset = tokenized_datasets["test"].shuffle(seed=args.seed) - - # Training arguments - training_args = TrainingArguments( - output_dir="/opt/ml/checkpoints", - learning_rate=args.learning_rate, - num_train_epochs=args.epochs, - per_device_train_batch_size=args.per_device_train_batch_size, - weight_decay=args.weight_decay, - evaluation_strategy="epoch", - save_strategy="no", - logging_dir="/opt/ml/output/tensorboard", - ) - - # Accuracy metric - metric = evaluate.load("accuracy") - - def compute_metrics(eval_pred): - logits, labels = eval_pred - predictions = np.argmax(logits, axis=-1) - return metric.compute(predictions=predictions, references=labels) - - # Callbacks - callbacks = [SageMakerLoggingCallback()] - progress_cb = DynamoDBProgressCallback( - table_name=args.dynamodb_table_name, - region=args.dynamodb_region, - pk=args.job_pk, - sk=args.job_sk, - ) - callbacks.append(progress_cb) - - # Train - trainer = Trainer( - model=model, - args=training_args, - train_dataset=train_dataset, - eval_dataset=eval_dataset, - compute_metrics=compute_metrics, - callbacks=callbacks, - ) - - logger.info( - f"Starting fine-tuning: model={args.model_name_or_path}, " - f"epochs={args.epochs}, batch_size={args.per_device_train_batch_size}" - ) - trainer.train() - - # Final evaluation - metrics = trainer.evaluate() - logger.info( - f"Final evaluation: accuracy={metrics.get('eval_accuracy', 'N/A')}" - ) - - # Save model and tokenizer to /opt/ml/model/ - trainer.save_model(model_dir) - tokenizer.save_pretrained(model_dir) - logger.info(f"Saved model to {model_dir}") - - # Copy inference handler into model artifact for Batch Transform - copy_inference_script(model_dir) - - logger.info("Training complete.") - - -# ========================================================================= -# Argument Parsing -# ========================================================================= - - -def parse_args(): - """Parse command-line arguments (passed as hyperparameters by SageMaker).""" +def parse_args(argv=None): + """Parse the hyperparameters SageMaker passes as command-line arguments.""" parser = argparse.ArgumentParser() - # Model + # Model and task parser.add_argument("--model_name_or_path", type=str, required=True) + parser.add_argument( + "--task_type", + type=str, + default=task_types.DEFAULT_TASK_TYPE, + choices=list(task_types.TASK_TYPES), + ) # Training hyperparameters parser.add_argument("--epochs", type=int, default=3) @@ -437,6 +87,7 @@ def parse_args(): parser.add_argument("--split_ratio", type=float, default=0.8) parser.add_argument("--seed", type=int, default=42) parser.add_argument("--context_length", type=int, default=512) + parser.add_argument("--image_size", type=int, default=224) # DynamoDB progress reporting parser.add_argument("--dynamodb_table_name", type=str, default="") @@ -451,10 +102,37 @@ def parse_args(): default=os.environ.get("SM_MODEL_DIR", "/opt/ml/model"), ) - args, _ = parser.parse_known_args() + args, _unknown = parser.parse_known_args(argv) return args +def write_task_marker(model_dir, task_type): + """Record the task type inside the model artifact for the inference handler.""" + os.makedirs(model_dir, exist_ok=True) + marker = os.path.join(model_dir, "task_type.json") + with open(marker, "w") as handle: + json.dump({"task_type": task_type}, handle, indent=2) + logger.info(f"Recorded task type '{task_type}' in {marker}") + return marker + + +def main(argv=None): + args = parse_args(argv) + module, spec = resolve_task_module(args.task_type) + + logger.info(f"Dispatching to task '{spec.task_type}' ({spec.display_name})") + module.train(args, spec) + + # Bundle the inference handler into the artifact so Batch Transform can + # serve this model, and record which task it serves. Without the marker + # the handler would fall back to text classification and mis-parse an + # image payload. + model_dir = os.environ.get("SM_MODEL_DIR", "/opt/ml/model") + write_task_marker(model_dir, spec.task_type) + task_common.copy_inference_bundle(model_dir) + + logger.info("Training complete.") + + if __name__ == "__main__": - args = parse_args() - train(args) + main() diff --git a/backend/src/apis/app_api/fine_tuning/sagemaker_service.py b/backend/src/apis/app_api/fine_tuning/sagemaker_service.py index e60281137..7ee0fc65a 100644 --- a/backend/src/apis/app_api/fine_tuning/sagemaker_service.py +++ b/backend/src/apis/app_api/fine_tuning/sagemaker_service.py @@ -7,29 +7,64 @@ import boto3 from botocore.exceptions import ClientError -from .job_models import INSTANCE_COST_PER_HOUR +from . import pricing, task_types logger = logging.getLogger(__name__) -# HuggingFace Deep Learning Container image URIs by region -# PyTorch 2.1 + Transformers 4.36 (GPU, Python 3.10) -_HF_DLC_IMAGES = { - "us-east-1": "763104351884.dkr.ecr.us-east-1.amazonaws.com/huggingface-pytorch-training:2.1.0-transformers4.36.0-gpu-py310-cu121-ubuntu20.04", - "us-east-2": "763104351884.dkr.ecr.us-east-2.amazonaws.com/huggingface-pytorch-training:2.1.0-transformers4.36.0-gpu-py310-cu121-ubuntu20.04", - "us-west-2": "763104351884.dkr.ecr.us-west-2.amazonaws.com/huggingface-pytorch-training:2.1.0-transformers4.36.0-gpu-py310-cu121-ubuntu20.04", - "eu-west-1": "763104351884.dkr.ecr.eu-west-1.amazonaws.com/huggingface-pytorch-training:2.1.0-transformers4.36.0-gpu-py310-cu121-ubuntu20.04", - "ap-southeast-1": "763104351884.dkr.ecr.ap-southeast-1.amazonaws.com/huggingface-pytorch-training:2.1.0-transformers4.36.0-gpu-py310-cu121-ubuntu20.04", +# ========================================================================= +# Deep Learning Container images +# ========================================================================= + +# Keyed by (task DLC family, region). The two families move independently on +# purpose: every text model in the catalog was trained and validated against +# transformers 4.36, and vision models simply do not load there — +# AutoModelForImageClassification predates it but the modern vision +# checkpoints do not, and SigLIP-class dual encoders arrived much later. +# Bumping one shared image to serve vision would silently re-baseline every +# existing text job. +# +# Tags verified present in us-east-1/us-east-2/us-west-2 via +# `aws ecr describe-images --registry-id 763104351884`. eu-west-1 and +# ap-southeast-1 follow the same AWS publishing convention but could not be +# confirmed from here (an SCP denies ecr:DescribeImages in those regions); +# override with the environment variables below if a region lags. +_TRAINING_IMAGE_TAGS = { + task_types.DLC_FAMILY_TEXT: "huggingface-pytorch-training:2.1.0-transformers4.36.0-gpu-py310-cu121-ubuntu20.04", + task_types.DLC_FAMILY_VISION: "huggingface-pytorch-training:2.8.0-transformers4.56.2-gpu-py312-cu129-ubuntu22.04", } - -_HF_DLC_INFERENCE_IMAGES = { - "us-east-1": "763104351884.dkr.ecr.us-east-1.amazonaws.com/huggingface-pytorch-inference:2.1.0-transformers4.37.0-gpu-py310-cu118-ubuntu20.04", - "us-east-2": "763104351884.dkr.ecr.us-east-2.amazonaws.com/huggingface-pytorch-inference:2.1.0-transformers4.37.0-gpu-py310-cu118-ubuntu20.04", - "us-west-2": "763104351884.dkr.ecr.us-west-2.amazonaws.com/huggingface-pytorch-inference:2.1.0-transformers4.37.0-gpu-py310-cu118-ubuntu20.04", - "eu-west-1": "763104351884.dkr.ecr.eu-west-1.amazonaws.com/huggingface-pytorch-inference:2.1.0-transformers4.37.0-gpu-py310-cu118-ubuntu20.04", - "ap-southeast-1": "763104351884.dkr.ecr.ap-southeast-1.amazonaws.com/huggingface-pytorch-inference:2.1.0-transformers4.37.0-gpu-py310-cu118-ubuntu20.04", +_INFERENCE_IMAGE_TAGS = { + task_types.DLC_FAMILY_TEXT: "huggingface-pytorch-inference:2.1.0-transformers4.37.0-gpu-py310-cu118-ubuntu20.04", + task_types.DLC_FAMILY_VISION: "huggingface-pytorch-inference:2.6.0-transformers4.51.3-gpu-py312-cu124-ubuntu22.04", } +# The AWS-owned account that publishes Deep Learning Containers. Same in every +# region we support. +_DLC_REGISTRY_ACCOUNT = "763104351884" + +_SUPPORTED_DLC_REGIONS = ( + "us-east-1", + "us-east-2", + "us-west-2", + "eu-west-1", + "ap-southeast-1", +) + + +def _image_uri_override(kind: str, family: str) -> str: + """Read a per-family image override from the environment. + + A DLC tag can be retired or lag in a region. This is the escape hatch + that fixes it without a code deploy, e.g. + ``FINE_TUNING_TRAINING_IMAGE_VISION=.dkr.ecr...``. + """ + return os.environ.get(f"FINE_TUNING_{kind}_IMAGE_{family.upper()}", "").strip() + + +def build_image_uri(region: str, tag: str) -> str: + """Compose a full DLC ECR image URI.""" + return f"{_DLC_REGISTRY_ACCOUNT}.dkr.ecr.{region}.amazonaws.com/{tag}" + class SageMakerService: """Wrapper around boto3 SageMaker client for training and inference operations.""" @@ -50,12 +85,29 @@ def __init__( self._security_group_id = security_group_id or os.environ.get("SAGEMAKER_SECURITY_GROUP_ID", "") self._subnet_ids = subnet_ids or os.environ.get("SAGEMAKER_SUBNET_IDS", "") - def get_huggingface_image_uri(self) -> str: - """Return the HuggingFace training DLC image URI for the current region.""" - uri = _HF_DLC_IMAGES.get(self._region) - if not uri: - raise ValueError(f"No HuggingFace DLC image configured for region {self._region}") - return uri + def _resolve_image_uri(self, kind: str, task_type: Optional[str]) -> str: + """Resolve the DLC image for a task, honouring any environment override.""" + spec = task_types.get_task_spec(task_type) + family = spec.dlc_family + + override = _image_uri_override(kind, family) + if override: + logger.info(f"Using overridden {kind.lower()} image for {family}: {override}") + return override + + tags = _TRAINING_IMAGE_TAGS if kind == "TRAINING" else _INFERENCE_IMAGE_TAGS + tag = tags.get(family) + if not tag: + raise ValueError(f"No {kind.lower()} DLC image configured for task family '{family}'") + if self._region not in _SUPPORTED_DLC_REGIONS: + raise ValueError( + f"No HuggingFace DLC image configured for region {self._region}" + ) + return build_image_uri(self._region, tag) + + def get_huggingface_image_uri(self, task_type: Optional[str] = None) -> str: + """Return the training DLC image URI for ``task_type`` in this region.""" + return self._resolve_image_uri("TRAINING", task_type) def create_training_job( self, @@ -67,6 +119,8 @@ def create_training_job( instance_count: int = 1, max_runtime: int = 86400, source_dir_s3_uri: str = "", + task_type: Optional[str] = None, + volume_size_gb: Optional[int] = None, ) -> dict: """Create a SageMaker training job. @@ -74,9 +128,13 @@ def create_training_job( sagemaker_submit_directory hyperparameters so the HuggingFace DLC uses the custom training script instead of the default. + ``task_type`` selects the DLC image family; omitting it keeps the + historical text-classification container. + Returns the response from create_training_job API call. """ - image_uri = self.get_huggingface_image_uri() + image_uri = self.get_huggingface_image_uri(task_type) + spec = task_types.get_task_spec(task_type) # Inject custom script hyperparameters if source_dir provided if source_dir_s3_uri: @@ -112,7 +170,11 @@ def create_training_job( "ResourceConfig": { "InstanceType": instance_type, "InstanceCount": instance_count, - "VolumeSizeInGB": 100, + # An image dataset holds the archive, the unpacked copy and + # the model checkpoint at once, so the text default is not + # enough headroom for the archive-based tasks. + "VolumeSizeInGB": volume_size_gb + or (200 if spec.requires_archive else 100), }, "StoppingCondition": { "MaxRuntimeInSeconds": max_runtime, @@ -212,21 +274,27 @@ def get_training_logs(self, job_name: str, limit: int = 100) -> List[str]: raise @staticmethod - def calculate_cost(instance_type: str, billable_seconds: int) -> float: - """Calculate estimated cost based on instance type and billable time.""" - cost_per_hour = INSTANCE_COST_PER_HOUR.get(instance_type, 0.0) - return round(cost_per_hour * (billable_seconds / 3600), 4) + def calculate_cost( + instance_type: str, billable_seconds: int, *, transform: bool = False + ) -> float: + """Estimated cost for billable time on an instance. + + Training and Batch Transform are priced separately — see + ``pricing.py`` — so the caller has to say which one it is measuring. + """ + return pricing.calculate_cost( + instance_type, billable_seconds, transform=transform + ) # ===================================================================== # Batch Transform (Inference) Methods # ===================================================================== - def get_huggingface_inference_image_uri(self) -> str: - """Return the HuggingFace inference DLC image URI for the current region.""" - uri = _HF_DLC_INFERENCE_IMAGES.get(self._region) - if not uri: - raise ValueError(f"No HuggingFace inference DLC image configured for region {self._region}") - return uri + def get_huggingface_inference_image_uri( + self, task_type: Optional[str] = None + ) -> str: + """Return the inference DLC image URI for ``task_type`` in this region.""" + return self._resolve_image_uri("INFERENCE", task_type) def create_transform_job( self, @@ -237,6 +305,7 @@ def create_transform_job( instance_type: str, instance_count: int = 1, max_runtime: int = 3600, + task_type: Optional[str] = None, ) -> dict: """Create a SageMaker Batch Transform job. @@ -244,9 +313,14 @@ def create_transform_job( 1. Create a SageMaker Model from the training artifact 2. Create a Transform Job using that model + ``task_type`` selects both the DLC image family and the payload + contract — the image tasks send a .zip archive rather than newline + delimited text, and need a far larger MaxPayloadInMB for it. + Returns the response from create_transform_job API call. """ - image_uri = self.get_huggingface_inference_image_uri() + image_uri = self.get_huggingface_inference_image_uri(task_type) + spec = task_types.get_task_spec(task_type) model_name = f"model-{job_name}" subnets = [s.strip() for s in self._subnet_ids.split(",") if s.strip()] @@ -286,7 +360,7 @@ def create_transform_job( "S3Uri": input_s3_uri, } }, - "ContentType": "text/plain", + "ContentType": spec.inference_content_type, }, "TransformOutput": { "S3OutputPath": output_s3_uri, @@ -295,7 +369,7 @@ def create_transform_job( "InstanceType": instance_type, "InstanceCount": instance_count, }, - "MaxPayloadInMB": 6, + "MaxPayloadInMB": spec.inference_max_payload_mb, } if max_runtime: diff --git a/backend/src/apis/app_api/fine_tuning/script_packaging_service.py b/backend/src/apis/app_api/fine_tuning/script_packaging_service.py index 2d509510c..f03a08042 100644 --- a/backend/src/apis/app_api/fine_tuning/script_packaging_service.py +++ b/backend/src/apis/app_api/fine_tuning/script_packaging_service.py @@ -15,8 +15,26 @@ # Directory containing the SageMaker scripts (relative to this module) SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "sagemaker_scripts") -# Files to include in the source directory tar.gz -SCRIPT_FILES = ["train.py", "inference.py", "requirements.txt"] +# Directory containing modules shared between the app-api and the training +# container. task_types.py is the task registry, and both sides have to agree +# on it, so it is packaged rather than duplicated. +PACKAGE_DIR = os.path.dirname(__file__) + +# Files to include in the source directory tar.gz, flattened to the archive +# root. The task modules travel with the dispatcher because train.py resolves +# the task type at runtime and imports whichever module implements it. +SCRIPT_FILES = [ + "train.py", + "inference.py", + "requirements.txt", + "task_common.py", + "task_text_classification.py", + "task_image_classification.py", + "task_image_text_classification.py", +] + +# Files pulled from the package directory rather than sagemaker_scripts/. +SHARED_FILES = ["task_types.py"] # S3 key for the packaged scripts SCRIPTS_S3_KEY = "scripts/sourcedir.tar.gz" @@ -38,12 +56,23 @@ def __init__(self, s3_client=None, bucket_name: Optional[str] = None): ) self._cached_s3_uri: Optional[str] = None + @staticmethod + def _source_paths() -> list: + """Return (archive name, source path) for every packaged file. + + Ordered deterministically so the content hash is stable across calls — + an unstable hash would re-upload the source dir on every job. + """ + paths = [(name, os.path.join(SCRIPTS_DIR, name)) for name in SCRIPT_FILES] + paths += [(name, os.path.join(PACKAGE_DIR, name)) for name in SHARED_FILES] + return sorted(paths, key=lambda pair: pair[0]) + def _compute_content_hash(self) -> str: """Compute SHA256 hash of all script file contents.""" hasher = hashlib.sha256() - for filename in sorted(SCRIPT_FILES): - filepath = os.path.join(SCRIPTS_DIR, filename) + for name, filepath in self._source_paths(): if os.path.exists(filepath): + hasher.update(name.encode("utf-8")) with open(filepath, "rb") as f: hasher.update(f.read()) return hasher.hexdigest() @@ -56,10 +85,9 @@ def _create_tar_gz(self) -> bytes: """ buf = io.BytesIO() with tarfile.open(fileobj=buf, mode="w:gz") as tar: - for filename in SCRIPT_FILES: - filepath = os.path.join(SCRIPTS_DIR, filename) + for name, filepath in self._source_paths(): if os.path.exists(filepath): - tar.add(filepath, arcname=filename) + tar.add(filepath, arcname=name) else: logger.warning(f"Script file not found: {filepath}") buf.seek(0) diff --git a/backend/src/apis/app_api/fine_tuning/task_types.py b/backend/src/apis/app_api/fine_tuning/task_types.py new file mode 100644 index 000000000..5526b02be --- /dev/null +++ b/backend/src/apis/app_api/fine_tuning/task_types.py @@ -0,0 +1,277 @@ +"""Task-type registry for the fine-tuning feature. + +A *task type* bundles everything that varies between one kind of fine-tuning +job and another: what a training record looks like, how the dataset is +packaged for upload, which HuggingFace auto-class loads the model, which Deep +Learning Container it runs in, and what the inference output looks like. + +Before this module existed those answers were hardcoded inline in ``train.py`` +and ``inference.py``, which is why adding a second modality meant a rewrite +rather than a registration. + +**This module must stay importable without torch, transformers, pandas or +PIL.** It is imported three different ways: + +* by ``routes.py`` in the app-api container, to validate a job *before* it is + submitted and a GPU is billed; +* by the training and inference scripts running inside the SageMaker DLC; +* by the unit tests, which run in the backend venv where the ML stack is + absent. + +Keep it pure data and stdlib. The same reasoning already governs +``DATASET_READERS`` in the training script: the supported-format contract has +to be assertable without importing pandas. +""" + +from dataclasses import dataclass +from typing import Dict, Mapping, Optional, Tuple + + +# ========================================================================= +# Task type identifiers +# ========================================================================= + +TEXT_CLASSIFICATION = "text-classification" +IMAGE_CLASSIFICATION = "image-classification" +IMAGE_TEXT_CLASSIFICATION = "image-text-classification" + +#: Task assumed for a job record written before task types existed, and for a +#: request that omits the field. Must stay ``TEXT_CLASSIFICATION`` — every +#: historical job in DynamoDB is one of these and has no ``task_type`` +#: attribute to read. +DEFAULT_TASK_TYPE = TEXT_CLASSIFICATION + + +# ========================================================================= +# Deep Learning Container families +# ========================================================================= + +# Which DLC image family a task runs in. Vision tasks need a far newer +# transformers than the text tasks were built against, and bumping a single +# shared image would re-baseline every existing text job. Keying the image +# map by family lets the two move independently: text jobs keep running the +# exact container they were validated on. +DLC_FAMILY_TEXT = "text" +DLC_FAMILY_VISION = "vision" + + +# ========================================================================= +# Spec +# ========================================================================= + +@dataclass(frozen=True) +class TaskSpec: + """Everything the platform needs to know about one fine-tuning task type.""" + + task_type: str + display_name: str + description: str + + # --- Training record contract ------------------------------------- + #: Columns every training record must carry. + required_columns: Tuple[str, ...] + #: Column holding the target class. + label_column: str + #: Column holding an image path relative to the archive root, or None for + #: text-only tasks. + image_column: Optional[str] + #: Column holding free text, or None for image-only tasks. + text_column: Optional[str] + + # --- Upload contract ---------------------------------------------- + #: Extensions the user may upload for training. + upload_extensions: Tuple[str, ...] + #: Extensions the manifest itself may use. For archive-based tasks the + #: manifest lives *inside* the archive, so this differs from + #: ``upload_extensions``. + manifest_extensions: Tuple[str, ...] + #: True when the upload is an archive bundling a manifest plus image files. + requires_archive: bool + + # --- Inference contract ------------------------------------------- + #: Extensions the user may upload as Batch Transform input. + inference_upload_extensions: Tuple[str, ...] + #: ContentType handed to Batch Transform for this task. + inference_content_type: str + #: MaxPayloadInMB for the transform job. Batch Transform caps this at 100. + inference_max_payload_mb: int + + # --- Runtime ------------------------------------------------------- + dlc_family: str + #: HuggingFace Hub pipeline tags whose models can serve this task. Drives + #: both the model-search filter and the pre-flight check on a custom id. + hf_pipeline_tags: Tuple[str, ...] + default_instance_type: str + default_hyperparameters: Mapping[str, str] + + def supports_extension(self, filename: str) -> bool: + """True when ``filename`` is an acceptable training upload.""" + return filename.lower().endswith(self.upload_extensions) + + def supports_inference_extension(self, filename: str) -> bool: + """True when ``filename`` is an acceptable Batch Transform input.""" + return filename.lower().endswith(self.inference_upload_extensions) + + +# ========================================================================= +# Shared hyperparameter defaults +# ========================================================================= + +_COMMON_HYPERPARAMETERS = { + "epochs": "3", + "learning_rate": "5e-5", + "weight_decay": "0.01", + "split_ratio": "0.8", + "seed": "42", +} + +# Manifest formats a task can read. Kept identical across tasks so a +# researcher who already has a CSV workflow keeps it when they add images. +_MANIFEST_EXTENSIONS = (".csv", ".jsonl", ".json") + + +# ========================================================================= +# Registry +# ========================================================================= + +TASK_SPECS: Dict[str, TaskSpec] = { + TEXT_CLASSIFICATION: TaskSpec( + task_type=TEXT_CLASSIFICATION, + display_name="Text classification", + description=( + "Assign a label to a piece of text. Upload a CSV/JSONL/JSON file " + 'where each record has a "text" and a "label" field.' + ), + required_columns=("text", "label"), + label_column="label", + image_column=None, + text_column="text", + upload_extensions=_MANIFEST_EXTENSIONS, + manifest_extensions=_MANIFEST_EXTENSIONS, + requires_archive=False, + inference_upload_extensions=(".txt", ".csv", ".jsonl", ".json"), + inference_content_type="text/plain", + inference_max_payload_mb=6, + dlc_family=DLC_FAMILY_TEXT, + hf_pipeline_tags=( + "fill-mask", + "text-classification", + "feature-extraction", + "token-classification", + "text-generation", + ), + default_instance_type="ml.g5.xlarge", + default_hyperparameters={ + **_COMMON_HYPERPARAMETERS, + "per_device_train_batch_size": "16", + "context_length": "512", + }, + ), + IMAGE_CLASSIFICATION: TaskSpec( + task_type=IMAGE_CLASSIFICATION, + display_name="Image classification", + description=( + "Assign a label to an image. Upload a .zip containing a " + 'manifest (CSV/JSONL/JSON) with "image" and "label" fields, plus ' + "the image files the manifest points at." + ), + required_columns=("image", "label"), + label_column="label", + image_column="image", + text_column=None, + upload_extensions=(".zip",), + manifest_extensions=_MANIFEST_EXTENSIONS, + requires_archive=True, + inference_upload_extensions=(".zip",), + # Batch Transform receives the whole archive as one payload and the + # handler unpacks it, which keeps the single-CSV result contract (and + # therefore the whole result viewer) identical to the text tasks. + inference_content_type="application/zip", + inference_max_payload_mb=100, + dlc_family=DLC_FAMILY_VISION, + hf_pipeline_tags=( + "image-classification", + "image-feature-extraction", + "zero-shot-image-classification", + ), + default_instance_type="ml.g6.xlarge", + default_hyperparameters={ + **_COMMON_HYPERPARAMETERS, + "per_device_train_batch_size": "16", + "image_size": "224", + }, + ), + IMAGE_TEXT_CLASSIFICATION: TaskSpec( + task_type=IMAGE_TEXT_CLASSIFICATION, + display_name="Image + text classification", + description=( + "Assign a label to an image/text pair. Upload a .zip containing a " + 'manifest (CSV/JSONL/JSON) with "image", "text" and "label" ' + "fields, plus the image files the manifest points at." + ), + required_columns=("image", "text", "label"), + label_column="label", + image_column="image", + text_column="text", + upload_extensions=(".zip",), + manifest_extensions=_MANIFEST_EXTENSIONS, + requires_archive=True, + inference_upload_extensions=(".zip",), + inference_content_type="application/zip", + inference_max_payload_mb=100, + dlc_family=DLC_FAMILY_VISION, + # Deliberately narrow. This task needs a *dual encoder* exposing both + # get_image_features and get_text_features, which in practice means + # the CLIP/SigLIP/ALIGN family — exactly what carries the + # zero-shot-image-classification tag. + # + # "image-text-to-text" (LLaVA, Qwen-VL) and "visual-question-answering" + # (ViLT, BLIP) are generative or fusion models with no text tower to + # pool, and are excluded on purpose: allowing them would let the + # pre-flight pass a model that only fails later, on a billed GPU, + # inside the trainer's dual-encoder check. + hf_pipeline_tags=("zero-shot-image-classification",), + default_instance_type="ml.g6.xlarge", + default_hyperparameters={ + **_COMMON_HYPERPARAMETERS, + "per_device_train_batch_size": "16", + "image_size": "224", + "context_length": "77", + }, + ), +} + +#: Stable, deterministic ordering for anything user-facing. +TASK_TYPES: Tuple[str, ...] = ( + TEXT_CLASSIFICATION, + IMAGE_CLASSIFICATION, + IMAGE_TEXT_CLASSIFICATION, +) + +#: Task types whose upload is an archive of a manifest plus image files. +ARCHIVE_TASK_TYPES: Tuple[str, ...] = tuple( + t for t in TASK_TYPES if TASK_SPECS[t].requires_archive +) + + +def get_task_spec(task_type: Optional[str]) -> TaskSpec: + """Return the spec for ``task_type``. + + ``None`` and the empty string resolve to :data:`DEFAULT_TASK_TYPE` so that + a job record written before task types existed still loads. + + Raises ValueError for an unknown task type. + """ + resolved = task_type or DEFAULT_TASK_TYPE + spec = TASK_SPECS.get(resolved) + if spec is None: + supported = ", ".join(TASK_TYPES) + raise ValueError( + f"Unknown task type '{task_type}'. Supported task types: {supported}" + ) + return spec + + +def requires_images(task_type: Optional[str]) -> bool: + """True when the task's training records reference image files.""" + return get_task_spec(task_type).image_column is not None diff --git a/backend/src/apis/app_api/kb_migration/ingestion_consumer.py b/backend/src/apis/app_api/kb_migration/ingestion_consumer.py index ba99c0b5a..5af29c385 100644 --- a/backend/src/apis/app_api/kb_migration/ingestion_consumer.py +++ b/backend/src/apis/app_api/kb_migration/ingestion_consumer.py @@ -261,7 +261,19 @@ def set_document_terminal( #: a slow success into a permanent failure in dev — three redeliveries each #: re-ingested, and the document only reached INDEXED 54 s after the last attempt #: had already been dead-lettered. -DOC_STATUSES_IN_FLIGHT = frozenset({"STARTING", "PENDING", "IN_PROGRESS"}) +#: +#: ⚠️ ``TEXT_INDEXED`` is **not in the packaged service model's DocumentStatus +#: enum** — the live service returns statuses the SDK does not declare. Observed in +#: dev on a document with image extraction enabled: it reported ``TEXT_INDEXED`` +#: (text searchable, media still processing) and later became ``INDEXED``. It is +#: treated as in-flight rather than as done, because marking a document complete at +#: that point would tell the user an image-only page is ready while the vision +#: model has not finished — precisely the "upload worked but the assistant cannot +#: see it" report this module exists to prevent. Do not derive this set from the +#: SDK enum; it is deliberately wider. +DOC_STATUSES_IN_FLIGHT = frozenset( + {"STARTING", "PENDING", "IN_PROGRESS", "TEXT_INDEXED"} +) #: Terminal and unusable. Worth failing the document rather than retrying forever. DOC_STATUSES_FAILED = frozenset({"FAILED", "METADATA_UPDATE_FAILED"}) @@ -344,12 +356,36 @@ def wait_until_indexed( deadline = time.monotonic() + timeout_seconds status, updated_at = document_status(backend, kb_ref, document_id) - while status in DOC_STATUSES_IN_FLIGHT and time.monotonic() < deadline: + while _still_working(status, document_id) and time.monotonic() < deadline: sleep(interval_seconds) status, updated_at = document_status(backend, kb_ref, document_id) return status, updated_at +def _still_working(status: str, document_id: str) -> bool: + """Whether to keep waiting on ``status``. + + Anything not recognised counts as still working, deliberately. The live service + already returns at least one status the packaged model does not declare + (``TEXT_INDEXED``), so treating unknown values as terminal would dead-letter + documents the day AWS adds another. Waiting is bounded by the caller's deadline, + so the cost of guessing wrong here is one poll budget rather than a lost + document — and the log line names the value so it can be classified properly. + """ + if status in DOC_STATUSES_IN_FLIGHT: + return True + if status in (DOC_STATUS_INDEXED, DOC_STATUS_NOT_FOUND, *DOC_STATUSES_PARTIAL): + return False + if status in DOC_STATUSES_FAILED: + return False + logger.warning( + f"document {document_id} reported unrecognised status {status!r}; treating " + f"it as still indexing. If this is terminal, add it to the appropriate set " + f"in ingestion_consumer.py" + ) + return True + + def wait_until_retrievable( backend: Any, kb_ref: str, @@ -358,12 +394,34 @@ def wait_until_retrievable( interval_seconds: Optional[float] = None, sleep: Any = time.sleep, ) -> Optional[str]: - """Poll until a retrieval actually returns ``document_id``. - - Returns the timestamp at which it first became retrievable, or ``None`` on - timeout. A probe that itself errors is treated as "not yet", not as a document - failure: the document is usually fine and merely slow, and failing it would fail - uploads that are about to work. + """Confirm a retrieval really returns ``document_id``, filtered to that document. + + Returns the timestamp at which it was first confirmed, or ``None`` on timeout. + A probe that itself errors is treated as "not yet", not as a document failure: + the document is usually fine and merely slow, and failing it would fail uploads + that are about to work. + + THE FILTER IS THE WHOLE POINT + An earlier version searched for the document *id as the query text* and checked + whether that document appeared in the top 5 results. A document id carries no + meaning to an embedding model, so the search returned whatever the reranker + liked best — measured in dev with two documents in the knowledge base, querying + ``DOC-40e985680a63`` returned five chunks and every one of them belonged to a + *different* document. The probe reported "not retrievable" for a document that + was perfectly retrievable. + + That failure scales the wrong way: the more documents a knowledge base holds, + the less likely the target appears in an unfiltered top-5, so every upload to a + mature knowledge base would burn its full poll budget and then dead-letter. It + only ever worked when the knowledge base held a single document, where anything + returned was necessarily the right thing. + + An ``equals`` filter on ``document_id`` makes the question exact: chunks come + back only for this document, so a non-empty result *is* proof of retrievability + and an empty one is a true negative. Verified against dev: each document + returned 5 of its own chunks, and a fabricated id returned none. ``equals`` is + in ``ISOLATION_SAFE_FILTER_OPERATORS``, so it passes the adapter's filter + validation. The timeouts default to ``None`` and are resolved from the module constants *at call time*, rather than being bound as default arguments. Default arguments are @@ -378,14 +436,23 @@ def wait_until_retrievable( if interval_seconds is None: interval_seconds = RETRIEVABLE_POLL_INTERVAL_SECONDS + # Exact-match only. A prefix or substring operator would let `DOC-1` match + # `DOC-10` and confirm the wrong document as retrievable. + document_filter = {"equals": {"key": "document_id", "value": document_id}} + deadline = time.monotonic() + timeout_seconds while time.monotonic() < deadline: try: - chunks = asyncio.run(backend.search(kb_ref, document_id, 5)) + chunks = asyncio.run( + backend.search(kb_ref, document_id, 5, retrieval_filter=document_filter) + ) except Exception as exc: # noqa: BLE001 - a probe failure is not a document failure logger.warning(f"retrievability probe for {document_id} failed: {exc}") chunks = [] + # The filter already restricts the result set to this document, so anything + # coming back is the answer. The per-chunk check stays as a belt-and-braces + # guard against a filter that is silently ignored by a future API change. for chunk in chunks or []: metadata = getattr(chunk, "metadata", None) or {} if metadata.get("document_id") == document_id: @@ -463,17 +530,38 @@ def handle_object(bucket: str, key: str) -> Dict[str, Any]: return {"routed": "managed", "ingested": False, "document_id": document_id, "status": status} - if status in DOC_STATUSES_IN_FLIGHT: - # Already being indexed. Do NOT ingest again — that would discard the - # progress this invocation is waiting on. Leave it non-terminal so the - # event source brings us back, by which time Bedrock may have finished. + if status == DOC_STATUS_NOT_FOUND: + # Bedrock has never heard of it, so this is the first delivery. Submit. + try: + asyncio.run(backend.ingest(assistant_id, source)) + except Exception as exc: + logger.error(f"direct ingestion of {document_id} failed: {exc}", exc_info=True) + set_document_terminal(assistant_id, document_id, STATUS_FAILED, error=str(exc)) + raise + else: + # Already submitted — a redelivery, or a document still being worked on. + # Do NOT ingest again: re-submitting discards the progress this invocation + # is about to wait for, which is what turned a slow success into a + # permanent failure in dev. logger.info( - f"document {document_id} is {status} in the knowledge base; not " - f"re-ingesting, waiting for redelivery" + f"document {document_id} is already {status} in the knowledge base; " + f"not re-ingesting" ) - raise IngestionRoutingError( - f"document {document_id} is still {status}; leaving it for redelivery" + + # One wait, whichever way we arrived. Both "just submitted" and "found it + # mid-flight" need the same thing: give Bedrock time, bounded by a budget that + # fits inside this Lambda, because redelivery is capped at 2 retries. + if _still_working(status, document_id) or status == DOC_STATUS_NOT_FOUND: + status, bedrock_updated_at = wait_until_indexed(backend, assistant_id, document_id) + + if status in DOC_STATUSES_FAILED: + logger.error(f"document {document_id} became {status} during indexing") + set_document_terminal( + assistant_id, document_id, STATUS_FAILED, + error=f"the knowledge base reports this document as {status}", ) + return {"routed": "managed", "ingested": True, "document_id": document_id, + "status": status} if status in DOC_STATUSES_PARTIAL: logger.warning( @@ -481,37 +569,14 @@ def handle_object(bucket: str, key: str) -> Dict[str, Any]: f"content or metadata did not index" ) - already_indexed = status == DOC_STATUS_INDEXED or status in DOC_STATUSES_PARTIAL - - if not already_indexed: - try: - asyncio.run(backend.ingest(assistant_id, source)) - except Exception as exc: - logger.error(f"direct ingestion of {document_id} failed: {exc}", exc_info=True) - set_document_terminal(assistant_id, document_id, STATUS_FAILED, error=str(exc)) - raise - - # Submitted. Wait briefly for indexing so a small document finishes in this - # one invocation, then hand the slow ones back to redelivery. - status, bedrock_updated_at = wait_until_indexed(backend, assistant_id, document_id) - - if status in DOC_STATUSES_FAILED: - logger.error(f"document {document_id} became {status} during indexing") - set_document_terminal( - assistant_id, document_id, STATUS_FAILED, - error=f"the knowledge base reports this document as {status}", - ) - return {"routed": "managed", "ingested": True, "document_id": document_id, - "status": status} - - if status not in (DOC_STATUS_INDEXED, *DOC_STATUSES_PARTIAL): - # Still indexing. Deliberately NOT marked terminal, and deliberately - # not given an invented timestamp — the next delivery will find it - # INDEXED and record Bedrock's own. - raise IngestionRoutingError( - f"document {document_id} is {status} after submission; leaving it " - f"for redelivery to confirm indexing" - ) + if status not in (DOC_STATUS_INDEXED, *DOC_STATUSES_PARTIAL): + # Still not done inside our budget. Deliberately NOT marked terminal, and + # deliberately not given an invented timestamp — a later delivery will find + # it INDEXED and record Bedrock's own. + raise IngestionRoutingError( + f"document {document_id} is {status} after waiting; leaving it for " + f"redelivery to confirm indexing" + ) # INDEXED. `indexedAt` is Bedrock's OWN timestamp, not this process's clock — # an earlier version recorded `_now_iso()` immediately after the ingest call diff --git a/backend/src/apis/app_api/kb_sync/worker.py b/backend/src/apis/app_api/kb_sync/worker.py index c1fdbd94f..81428e852 100644 --- a/backend/src/apis/app_api/kb_sync/worker.py +++ b/backend/src/apis/app_api/kb_sync/worker.py @@ -65,8 +65,6 @@ logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) -_NATIVE_PREFIX = "application/vnd.google-apps." - def _now_timestamp() -> str: # Normalize the +00:00 offset to a single trailing Z; "…+00:00Z" (offset AND diff --git a/backend/src/apis/app_api/kb_upgrade/routes.py b/backend/src/apis/app_api/kb_upgrade/routes.py index c5dd33822..db2fcd318 100644 --- a/backend/src/apis/app_api/kb_upgrade/routes.py +++ b/backend/src/apis/app_api/kb_upgrade/routes.py @@ -28,6 +28,7 @@ ) from apis.shared.assistants.service import resolve_assistant_permission from apis.shared.auth import User, get_current_user_from_session +from apis.shared.security.log_sanitize import scrub_log logger = logging.getLogger(__name__) @@ -88,7 +89,7 @@ async def read_upgrade_status( # unavailable upgrade card would be a strictly worse outcome. Logged at # error so the failure is not silent. logger.error( - f"kb {assistant_id}: could not derive upgrade status: {exc}", + f"kb {scrub_log(assistant_id)}: could not derive upgrade status: {scrub_log(exc)}", exc_info=True, ) return UpgradeStatusResponse(phase="none", canUpgrade=False) diff --git a/backend/src/apis/app_api/main.py b/backend/src/apis/app_api/main.py index cb9349532..324b4613a 100644 --- a/backend/src/apis/app_api/main.py +++ b/backend/src/apis/app_api/main.py @@ -204,6 +204,7 @@ async def lifespan(app: FastAPI): from apis.app_api.shares.routes import conversations_share_router, shares_router, shared_view_router from apis.app_api.voice import router as voice_router from apis.app_api.user_menu_links.routes import router as user_menu_links_router +from apis.app_api.announcements.routes import router as announcements_router from apis.app_api.system_prompts.routes import router as system_prompts_router from apis.app_api.runs.routes import router as runs_router from apis.app_api.schedules.routes import router as schedules_router @@ -242,6 +243,7 @@ async def lifespan(app: FastAPI): app.include_router(shared_view_router) # Shared conversation read-only view app.include_router(voice_router) # Cookie-authenticated WS proxy for Nova Sonic voice mode (#211) app.include_router(user_menu_links_router) # Public read of admin-managed user-menu links +app.include_router(announcements_router) # Feature announcements feed + ack; 404s while ANNOUNCEMENTS_ENABLED off app.include_router(system_prompts_router) # Public read of admin-managed system prompts app.include_router(runs_router) # Headless "Run now" + grant lifecycle (scheduled-runs PR-1; SCHEDULED_RUNS_ENABLED + RBAC gated at runtime) app.include_router(schedules_router) # Schedule CRUD (scheduled-runs B1; inert — SCHEDULED_RUNS_ENABLED + RBAC gated, nothing fires yet) @@ -264,8 +266,17 @@ async def lifespan(app: FastAPI): # environment, so its presence is the enablement signal. if os.environ.get("ARTIFACTS_RENDER_TOKEN_SECRET_ARN"): from apis.app_api.artifacts.routes import router as artifacts_router + from apis.app_api.artifacts.shares import ( + artifact_shares_router, + shared_artifacts_router, + ) app.include_router(artifacts_router) - logger.info("Artifact render-token routes enabled") + # Artifact sharing rides the same enablement signal: a share is only + # ever consumed by minting a render token, so it cannot be useful + # without the artifacts feature being on. + app.include_router(artifact_shares_router) + app.include_router(shared_artifacts_router) + logger.info("Artifact render-token and sharing routes enabled") # Mount static file directories for serving generated content # These are created by tools (visualization, code interpreter, etc.) diff --git a/backend/src/apis/app_api/memory_spaces/models.py b/backend/src/apis/app_api/memory_spaces/models.py index 66cd15a45..aff563d4a 100644 --- a/backend/src/apis/app_api/memory_spaces/models.py +++ b/backend/src/apis/app_api/memory_spaces/models.py @@ -7,7 +7,7 @@ from __future__ import annotations -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List from pydantic import BaseModel, ConfigDict, Field diff --git a/backend/src/apis/app_api/memory_spaces/routes.py b/backend/src/apis/app_api/memory_spaces/routes.py index ef86ff2b3..9dc09462c 100644 --- a/backend/src/apis/app_api/memory_spaces/routes.py +++ b/backend/src/apis/app_api/memory_spaces/routes.py @@ -42,6 +42,7 @@ ) from apis.shared.memory.store import MemorySpaceStoreError +from apis.shared.security.log_sanitize import scrub_log from apis.app_api.memory_spaces.models import ( ConsolidateRequest, ConsolidationReportResponse, @@ -252,7 +253,7 @@ def export_space( except MemorySpaceError as e: raise _translate(e) except MemorySpaceStoreError as e: - logger.error("memory-spaces: export failed for space=%s: %s", space_id, e) + logger.error("memory-spaces: export failed for space=%s: %s", scrub_log(space_id), scrub_log(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="failed to read memory space contents for export", @@ -292,7 +293,7 @@ def consolidate_space( except MemorySpaceError as e: raise _translate(e) except MemorySpaceStoreError as e: - logger.error("memory-spaces: consolidate failed for space=%s: %s", space_id, e) + logger.error("memory-spaces: consolidate failed for space=%s: %s", scrub_log(space_id), scrub_log(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="failed to consolidate memory space storage", diff --git a/backend/src/apis/app_api/sessions/routes.py b/backend/src/apis/app_api/sessions/routes.py index 9ced1f9d2..7567b665a 100644 --- a/backend/src/apis/app_api/sessions/routes.py +++ b/backend/src/apis/app_api/sessions/routes.py @@ -9,6 +9,8 @@ from apis.shared.sessions.models import ( UpdateSessionMetadataRequest, SessionInterruptRequest, + SessionSteerRequest, + SessionSteerResponse, SessionMetadataResponse, SessionMetadata, SessionPreferences, @@ -31,7 +33,9 @@ ) from .services.session_service import SessionService from apis.app_api.shares.service import get_share_service +from apis.app_api.artifacts.service import get_artifact_share_service from apis.shared.auth.dependencies import get_current_user_from_session +from apis.shared.feature_flags import mid_turn_steering_enabled from apis.shared.auth.models import User from apis.shared.system_prompts.service import get_system_prompts_service @@ -453,6 +457,17 @@ async def delete_session_endpoint( session_id ) + # 4. Revoke artifact shares from this session. Artifacts outlive + # the chat that produced them, so without this a deleted + # conversation leaves live links to its artifacts. Best-effort + # and never-raising, like the conversation cascade above — and a + # no-op when artifacts aren't enabled for this environment. + background_tasks.add_task( + get_artifact_share_service().delete_for_session, + session_id, + user_id + ) + logger.info("Successfully deleted session") return Response(status_code=204) @@ -513,6 +528,7 @@ async def bulk_delete_sessions_endpoint( try: service = SessionService() share_service = get_share_service() + artifact_share_service = get_artifact_share_service() for session_id in session_ids: try: @@ -536,6 +552,11 @@ async def bulk_delete_sessions_endpoint( share_service.delete_shares_for_session, session_id ) + background_tasks.add_task( + artifact_share_service.delete_for_session, + session_id, + user_id + ) results.append(BulkDeleteSessionResult( session_id=session_id, success=True, @@ -717,6 +738,102 @@ async def signal_turn_interrupted_endpoint( ) +@router.post("/{session_id}/steer", response_model=SessionSteerResponse, response_model_by_alias=True) +async def steer_running_turn_endpoint( + session_id: str, + body: SessionSteerRequest, + current_user: User = Depends(get_current_user_from_session), +): + """Queue a follow-up for injection into the turn that is streaming right now. + + Mid-turn steering (docs/specs/mid-turn-steering.md). PR #916 made Enter + mean "say this" while a response streams, but the follow-up sat in the + composer until the turn ended — so a user who saw the agent open the wrong + file could only wait or Stop-and-resend, and the second discards a partial + generation and re-establishes the prefix. This endpoint arms the text on + the session's single-flight lease row; the container running the turn + peeks it at its next tool boundary and appends it to the tool-result + message, so the agent reads it before choosing its next action. + + Lives on app-api, not inference-api, for the same reason ``/interrupt`` + does: the AgentCore Runtime data plane proxies only ``/invocations`` and + ``/ping``, so a steer route on inference-api would 404 in cloud. The lease + row is the cross-container side channel — exactly the mechanism the Stop + path already proves — and it is owner-scoped, so a steer armed against a + turn that has since ended is ignored rather than misdelivered to the next + one. + + Returns 200 with ``queued=false`` when there is no live turn to steer, or + when the turn ended between the user typing and this request landing. That + race resolving to "not queued" is the correct outcome, not an error: the + SPA leaves the entry in its queue and the existing end-of-turn flush sends + it as a normal turn. 429 when the inbox is at its cap (same fallback). + """ + if not mid_turn_steering_enabled(): + raise HTTPException(status_code=404, detail="Mid-turn steering is not enabled") + + user_id = current_user.user_id + + logger.info("POST /sessions/.../steer") + + from apis.shared.sessions.session_lease import ( + SteerQueueFullError, + request_session_steer, + ) + + try: + queued = await request_session_steer( + session_id, + user_id, + text=body.text, + entry_id=body.entry_id, + ) + except SteerQueueFullError: + raise HTTPException( + status_code=429, + detail="Too many follow-ups are already queued for this turn", + ) + except Exception: + logger.error("Error queueing a mid-turn steer", exc_info=True) + raise HTTPException(status_code=500, detail="Failed to queue the follow-up") + + return SessionSteerResponse(queued=queued, entry_id=body.entry_id) + + +@router.delete("/{session_id}/steer/{entry_id}", status_code=204) +async def withdraw_steer_endpoint( + session_id: str, + entry_id: str, + current_user: User = Depends(get_current_user_from_session), +): + """Withdraw a queued follow-up the user removed from the composer. + + Best-effort and idempotent: an unknown id, an already-consumed entry, and + a turn that has since ended all answer 204, because the user's intent — + "don't send that" — is satisfied in every one of those cases. Only the + caller's own session's inbox is reachable, since the lease row is keyed + under ``USER#{user_id}``. + """ + if not mid_turn_steering_enabled(): + raise HTTPException(status_code=404, detail="Mid-turn steering is not enabled") + + user_id = current_user.user_id + + logger.info("DELETE /sessions/.../steer/...") + + try: + from apis.shared.sessions.session_lease import remove_steer_entry + + await remove_steer_entry(session_id, user_id, entry_id) + except Exception: + # The entry is either still queued (and will be injected, which the + # SPA can render) or already gone. Neither is worth a 500 on a + # withdrawal the user has already seen disappear from their composer. + logger.warning("Failed to withdraw a queued steer", exc_info=True) + + return Response(status_code=204) + + @router.delete("/{session_id}/pending-interrupts/{interrupt_id:path}", status_code=204) async def dismiss_pending_interrupt_endpoint( session_id: str, diff --git a/backend/src/apis/app_api/shares/models.py b/backend/src/apis/app_api/shares/models.py index 423ebf3a6..548db86e1 100644 --- a/backend/src/apis/app_api/shares/models.py +++ b/backend/src/apis/app_api/shares/models.py @@ -89,6 +89,35 @@ class ShareListResponse(BaseModel): shares: List[ShareResponse] = Field(..., description="List of shares for the session") +class SharedConversationArtifact(BaseModel): + """One artifact pinned into a conversation share's snapshot. + + The recipient shape: no owner id, and no session id to go back to. + A recipient reaches this artifact only through the conversation + share that carries it, so the pair (share id, artifact id) is the + whole of their handle on it. + + `version` is the version the artifact stood at when the conversation + was shared, not its current HEAD — the same point-in-time promise + the messages make. + """ + + model_config = ConfigDict(populate_by_name=True) + + artifact_id: str = Field(..., alias="artifactId") + version: int + title: str = Field(default="") + content_type: str = Field(default="", alias="contentType") + produced_by_message_index: Optional[int] = Field( + default=None, + alias="producedByMessageIndex", + description="0-based index of the assistant message that produced " + "this artifact, so the shared view can anchor its card under the " + "same turn the owner sees it under. Null for artifacts written " + "before that linkage existed.", + ) + + class SharedConversationResponse(BaseModel): """Response model for retrieving a shared conversation""" @@ -102,3 +131,11 @@ class SharedConversationResponse(BaseModel): created_at: str = Field(..., alias="createdAt", description="ISO 8601 timestamp of share creation") owner_id: str = Field(..., alias="ownerId", description="User ID of the share creator") messages: List[MessageResponse] = Field(..., description="Snapshot of conversation messages") + artifacts: List[SharedConversationArtifact] = Field( + default_factory=list, + description="Artifacts the conversation produced, pinned at the " + "versions they stood at when it was shared. Empty for shares " + "created before artifacts were captured, and for conversations " + "that produced none — the two are indistinguishable and neither " + "is an error.", + ) diff --git a/backend/src/apis/app_api/shares/routes.py b/backend/src/apis/app_api/shares/routes.py index 6d4629cf2..934ef1ba8 100644 --- a/backend/src/apis/app_api/shares/routes.py +++ b/backend/src/apis/app_api/shares/routes.py @@ -8,12 +8,23 @@ """ import logging +from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Response from apis.shared.auth.dependencies import get_current_user_from_session from apis.shared.auth.models import User +# The artifacts domain owns render-token minting; this module owns the +# grant that authorizes it. Both live under app_api, so this is a +# same-package import, not a service boundary crossing. +from apis.app_api.artifacts.models import RenderTokenResponse +from apis.app_api.artifacts.service import ( + ArtifactNotFoundError, + RenderTokenConfigError, + get_render_token_service, +) + from .models import ( CreateShareRequest, ShareListResponse, @@ -211,3 +222,72 @@ async def get_shared_conversation( sanitized_share_id = share_id.replace("\r", "").replace("\n", "") logger.error(f"Error retrieving shared conversation {sanitized_share_id}: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Failed to retrieve shared conversation") + + +@shared_view_router.post( + "/{share_id}/artifacts/{artifact_id}/render-token", + response_model=RenderTokenResponse, +) +async def mint_shared_conversation_artifact_token( + share_id: str, + artifact_id: str, + current_user: User = Depends(get_current_user_from_session), +): + """Mint a render URL for an artifact inside a shared conversation. + + The grant is the CONVERSATION share — there is no separate artifact + share record, and none is created. Sharing a conversation shares the + artifacts it produced, so the conversation share is the single thing + that grants, updates and revokes them: change its allowlist and the + artifacts follow in the same write, revoke it and they die with it. + Parallel artifact-share rows would each need cascading, and a missed + cascade is an artifact still readable after its conversation was + locked down. + + The version served is the one pinned in the snapshot, so a recipient + sees the artifact the transcript around it describes rather than + whatever the owner has edited it into since. + + `resolve_shared_artifact` is the access boundary and does both + checks — may this viewer open this share, and is this artifact one + the snapshot pinned. The mint it feeds performs none of its own. + """ + try: + owner_id, version = get_share_service().resolve_shared_artifact( + share_id=share_id, + artifact_id=artifact_id, + requester=current_user, + ) + url, exp = get_render_token_service().mint_for_conversation_share( + owner_id=owner_id, + artifact_id=artifact_id, + version=version, + conversation_share_id=share_id, + viewer=current_user, + ) + except ShareNotFoundError: + # Also the "not in this snapshot" case, deliberately: an artifact + # the share does not carry is indistinguishable from one that + # does not exist, so this reveals nothing about what the owner has. + raise HTTPException(status_code=404, detail="Artifact not found") + except AccessDeniedError: + raise HTTPException(status_code=403, detail="Access denied") + except ArtifactNotFoundError: + # Pinned by the snapshot but gone from the table — deleted since + # the conversation was shared. + raise HTTPException(status_code=404, detail="Artifact not found") + except ShareTableNotFoundError: + raise HTTPException( + status_code=503, + detail="Share feature unavailable - table not deployed", + ) + except RenderTokenConfigError: + logger.error("artifact render token service misconfigured", exc_info=True) + raise HTTPException( + status_code=500, detail="Artifact rendering is unavailable" + ) + + return RenderTokenResponse( + url=url, + expires_at=datetime.fromtimestamp(exp, tz=timezone.utc).isoformat(), + ) diff --git a/backend/src/apis/app_api/shares/service.py b/backend/src/apis/app_api/shares/service.py index 9df891e71..5ee49879b 100644 --- a/backend/src/apis/app_api/shares/service.py +++ b/backend/src/apis/app_api/shares/service.py @@ -25,6 +25,7 @@ CreateShareRequest, ShareListResponse, ShareResponse, + SharedConversationArtifact, SharedConversationResponse, UpdateShareRequest, ) @@ -106,8 +107,25 @@ async def create_share( if not self._snapshot_store.enabled: raise ShareStorageUnavailableError() + # Artifacts the conversation produced, pinned at the version each + # stood at right now. + # + # They belong in the snapshot for the same reason the messages + # do: this share is a point-in-time copy, and an artifact + # resolved live would drift under a recipient who is reading a + # frozen conversation — they would see a chart the transcript + # around it never describes. Pinning also makes the snapshot the + # ALLOWLIST: the mint route will serve an (artifact, version) + # pair only if it appears here, so a recipient cannot name an + # arbitrary artifact of the owner's. + artifacts_snapshot = self._snapshot_artifacts(session_id, user) + body_bytes = json.dumps( - {"metadata": metadata_snapshot, "messages": messages_snapshot} + { + "metadata": metadata_snapshot, + "messages": messages_snapshot, + "artifacts": artifacts_snapshot, + } ).encode("utf-8") try: @@ -566,6 +584,16 @@ def _build_share_response(self, item: dict) -> ShareResponse: def _load_snapshot_body(self, item: dict) -> Tuple[dict, list]: """Return ``(metadata, messages)`` for a share item. + A narrowing of :meth:`_load_snapshot_raw` kept because it is what + every existing caller wants; anything needing another key of the + body (the pinned artifact list) reads the raw dict instead. + """ + body = self._load_snapshot_raw(item) + return body.get("metadata", {}) or {}, body.get("messages", []) or [] + + def _load_snapshot_raw(self, item: dict) -> dict: + """Return the whole snapshot body for a share item. + Handles three item shapes for backward compatibility: - **New** (``body_ref`` present): fetch the JSON body from S3. @@ -574,6 +602,10 @@ def _load_snapshot_body(self, item: dict) -> Tuple[dict, list]: offload. Existing shares predate the offload and stay readable with no migration. - **Malformed** (neither): unreadable → ``ShareNotFoundError``. + + Callers must treat every key as optional. Bodies written before a + key existed simply do not have it, and there is no migration — + conversation sharing is in production. """ body_ref = item.get("body_ref") if body_ref: @@ -588,14 +620,22 @@ def _load_snapshot_body(self, item: dict) -> Tuple[dict, list]: f"key={key}: {e}" ) raise ShareNotFoundError() from e - return body.get("metadata", {}) or {}, body.get("messages", []) or [] + return body if isinstance(body, dict) else {} if item.get("messages") is not None: # Legacy inline share — DynamoDB stored floats as Decimal; convert # back so downstream JSON/Pydantic handling matches the S3 path. - metadata = self._convert_decimals_to_float(item.get("metadata", {}) or {}) - messages = self._convert_decimals_to_float(item.get("messages", [])) - return metadata, messages + # Legacy inline shares predate artifacts entirely, so there + # is no `artifacts` key to recover here — the caller's + # tolerance for a missing one is what covers them. + return { + "metadata": self._convert_decimals_to_float( + item.get("metadata", {}) or {} + ), + "messages": self._convert_decimals_to_float( + item.get("messages", []) + ), + } logger.warning( f"Share {self._sanitize_id(str(item.get('share_id', '')))} has neither " @@ -617,6 +657,99 @@ def _delete_snapshot_body(self, item: dict) -> None: if key: self._snapshot_store.delete(key) + @staticmethod + def _snapshot_artifacts(session_id: str, user: User) -> list[dict]: + """The session's artifacts at HEAD, for the snapshot body. + + Best-effort and never raising. Sharing a conversation must not + fail because the artifacts feature is off in this environment, + or because its table hiccuped — a share with no artifacts is the + behaviour every share had before this existed, and it degrades + to exactly that. The alternative, failing the share, would trade + a missing picture for a missing conversation. + """ + try: + from apis.app_api.artifacts.service import ( + get_artifact_list_service, + ) + + return get_artifact_list_service().heads_for_session( + user_id=user.user_id, session_id=session_id + ) + except Exception: + logger.warning( + "could not snapshot artifacts for session %s — sharing " + "the conversation without them", + ShareService._sanitize_id(session_id), + exc_info=True, + ) + return [] + + def resolve_shared_artifact( + self, *, share_id: str, artifact_id: str, requester: User + ) -> tuple[str, int]: + """Authorize one artifact inside a shared conversation. + + Returns (owner_id, pinned_version) for a caller that is about to + mint. Raises ShareNotFoundError when the share is gone or does + not carry that artifact, and AccessDeniedError when the viewer + may not open the share. + + ############################################################ + # This is the access-control boundary for artifacts in shared + # conversations, and it is the whole of it — the mint it feeds + # (`mint_for_conversation_share`) performs no checks of its + # own, by design and by the comment on it. + # + # Two things have to hold, and both are here: + # 1. the viewer may open this conversation share, and + # 2. the artifact is one the SNAPSHOT pinned. + # + # (2) is what stops a recipient swapping in another artifact id + # belonging to the same owner. `sub` on the minted token is a + # partition address, so without it any valid share id would be + # a read primitive over the owner's whole artifact partition. + # An unknown artifact is a 404 rather than a 403, so it also + # reveals nothing about what the owner has. + ############################################################ + """ + self._ensure_enabled() + + item = self._get_share_item(share_id) + if not item: + raise ShareNotFoundError() + + self._check_access(item, requester) + + for entry in self._load_snapshot_artifacts(item): + if str(entry.get("artifact_id", "")) == artifact_id: + return str(item["owner_id"]), int(entry.get("version", 0)) + + raise ShareNotFoundError() + + def _load_snapshot_artifacts(self, item: dict) -> list[dict]: + """The pinned artifact list from a share's snapshot body. + + Absent on every share created before this feature, and on any + share whose owner had no artifacts — both are a normal empty + list, not an error. Conversation sharing is already in + production, so this MUST stay tolerant of a body with no + `artifacts` key; there is no migration and none is needed. + """ + try: + body = self._load_snapshot_raw(item) + except ShareNotFoundError: + raise + except Exception: + logger.warning( + "could not read snapshot artifacts for share %s", + self._sanitize_id(str(item.get("share_id", ""))), + exc_info=True, + ) + return [] + raw = body.get("artifacts") + return raw if isinstance(raw, list) else [] + def _build_shared_conversation_response(self, item: dict) -> SharedConversationResponse: from apis.shared.sessions.models import MessageResponse @@ -629,6 +762,20 @@ def _build_shared_conversation_response(self, item: dict) -> SharedConversationR except Exception as e: logger.warning(f"Skipping malformed message in share {item['share_id']}: {e}") + artifacts = [] + for entry in self._load_snapshot_artifacts(item): + try: + artifacts.append( + SharedConversationArtifact.model_validate(entry) + ) + except Exception as e: + # One malformed entry must not cost the recipient the + # conversation, the same way a malformed message does not. + logger.warning( + f"Skipping malformed artifact in share " + f"{self._sanitize_id(str(item.get('share_id', '')))}: {e}" + ) + return SharedConversationResponse( share_id=item["share_id"], title=metadata.get("title", "Untitled Conversation"), @@ -636,6 +783,7 @@ def _build_shared_conversation_response(self, item: dict) -> SharedConversationR created_at=item["created_at"], owner_id=item["owner_id"], messages=messages, + artifacts=artifacts, ) diff --git a/backend/src/apis/app_api/skills/service.py b/backend/src/apis/app_api/skills/service.py index 3e1657421..5eae8f35f 100644 --- a/backend/src/apis/app_api/skills/service.py +++ b/backend/src/apis/app_api/skills/service.py @@ -31,6 +31,7 @@ ) from apis.shared.skills.bundle import generate_skill_md from apis.shared.skills.resource_types import resolve_upload_content_type +from apis.shared.security.log_sanitize import scrub_log from apis.shared.skills.resource_store import ( SkillResourceStore, SkillResourceStoreError, @@ -192,7 +193,7 @@ async def create_skill( self._write_skill_md(created) logger.info( - f"Admin {admin.email} created skill: {skill.skill_id}", + f"Admin {scrub_log(admin.email)} created skill: {scrub_log(skill.skill_id)}", extra={ "event": "skill_created", "skill_id": skill.skill_id, @@ -233,7 +234,7 @@ async def update_skill( # is cheap and idempotent. self._write_skill_md(updated) logger.info( - f"Admin {admin.email} updated skill: {skill_id}", + f"Admin {scrub_log(admin.email)} updated skill: {scrub_log(skill_id)}", extra={ "event": "skill_updated", "skill_id": skill_id, @@ -275,7 +276,7 @@ async def delete_skill( if deleted: logger.info( - f"Admin {admin.email} deleted skill: {skill_id}", + f"Admin {scrub_log(admin.email)} deleted skill: {scrub_log(skill_id)}", extra={ "event": "skill_deleted", "skill_id": skill_id, @@ -397,7 +398,7 @@ async def add_resource( self._gc_orphaned(existing, new_resources) logger.info( - f"Admin {admin.email} uploaded reference file to skill {skill_id}", + f"Admin {scrub_log(admin.email)} uploaded reference file to skill {scrub_log(skill_id)}", extra={ "event": "skill_resource_added", "skill_id": skill_id, @@ -456,7 +457,7 @@ async def delete_resource( self._gc_orphaned(existing, new_resources) logger.info( - f"Admin {admin.email} deleted reference file from skill {skill_id}", + f"Admin {scrub_log(admin.email)} deleted reference file from skill {scrub_log(skill_id)}", extra={ "event": "skill_resource_deleted", "skill_id": skill_id, @@ -628,7 +629,7 @@ async def set_roles_for_skill( await self._remove_skill_from_role(role_id, skill_id, admin) logger.info( - f"Admin {admin.email} set roles for skill {skill_id}", + f"Admin {scrub_log(admin.email)} set roles for skill {scrub_log(skill_id)}", extra={ "event": "skill_roles_updated", "skill_id": skill_id, diff --git a/backend/src/apis/app_api/skills/user_service.py b/backend/src/apis/app_api/skills/user_service.py index 83a58f880..672748215 100644 --- a/backend/src/apis/app_api/skills/user_service.py +++ b/backend/src/apis/app_api/skills/user_service.py @@ -31,6 +31,7 @@ ) from .service import SkillCatalogService, get_skill_catalog_service +from apis.shared.security.log_sanitize import scrub_log logger = logging.getLogger(__name__) @@ -179,7 +180,7 @@ async def create_my_skill( self._invalidate(skill_id) logger.info( - f"User {user.email} created skill: {skill_id}", + f"User {scrub_log(user.email)} created skill: {scrub_log(skill_id)}", extra={ "event": "user_skill_created", "skill_id": skill_id, @@ -209,7 +210,7 @@ async def update_my_skill( self._invalidate(skill_id) logger.info( - f"User {user.email} updated skill: {skill_id}", + f"User {scrub_log(user.email)} updated skill: {scrub_log(skill_id)}", extra={ "event": "user_skill_updated", "skill_id": skill_id, @@ -238,7 +239,7 @@ async def delete_my_skill(self, skill_id: str, user: User) -> None: self._invalidate(skill_id) logger.info( - f"User {user.email} deleted skill: {skill_id}", + f"User {scrub_log(user.email)} deleted skill: {scrub_log(skill_id)}", extra={ "event": "user_skill_deleted", "skill_id": skill_id, diff --git a/backend/src/apis/inference_api/chat/models.py b/backend/src/apis/inference_api/chat/models.py index acb5136f2..221f2f2a5 100644 --- a/backend/src/apis/inference_api/chat/models.py +++ b/backend/src/apis/inference_api/chat/models.py @@ -6,7 +6,9 @@ import json from typing import Any, Dict, List, Optional -from pydantic import BaseModel, field_validator +from pydantic import BaseModel, Field, field_validator + +from apis.shared.sessions.session_lease import STEER_QUEUE_MAX_CHARS # Hard upper bound on a user-supplied custom system prompt. Mirrors the # limit applied inside SystemPromptBuilder.from_user_prompt — surfacing @@ -78,6 +80,27 @@ class AppContextUpdateEntry(BaseModel): structured_content: Optional[Dict[str, Any]] = None +class CarriedSteerEntry(BaseModel): + """A queued follow-up carried into the turn this request starts. + + Mid-turn steering's paused-turn path (docs/specs/mid-turn-steering.md). + A turn paused for OAuth consent or tool approval has no running loop to + steer, and the pause releases its lease — inbox and all — when the stream + closes. The follow-ups the user typed meanwhile are still in their + composer, so the resume request carries them here and the route seeds them + onto the resumed turn's lease, where the ordinary ``SteeringHook`` picks + them up at its first tool boundary. + + ``id`` is the client-minted queue-entry id, unchanged from the one the + normal ``/sessions/{id}/steer`` path uses: it is what ``steering_applied`` + names back, and what makes carrying an entry idempotent against the + composer's own end-of-turn flush. + """ + + id: str = Field(min_length=1, max_length=128) + text: str = Field(min_length=1, max_length=STEER_QUEUE_MAX_CHARS) + + class InvocationRequest(BaseModel): """Input for /invocations endpoint with multi-provider support""" @@ -138,6 +161,12 @@ class InvocationRequest(BaseModel): # new one. `message` is ignored in that case — the original prompt is # already in the agent's interrupt context. interrupt_responses: Optional[List[InterruptResponseEntry]] = None + # Follow-ups the user queued while this session's turn was paused awaiting + # consent or approval. Seeded onto this turn's steering inbox after the + # lease is acquired, so the agent reads them at its next tool boundary + # instead of the user having to send them as a separate turn that abandons + # the pause. Ignored when mid-turn steering is disabled. + steering: Optional[List[CarriedSteerEntry]] = None # When true, this is a "Continue" after a max_tokens truncation. Like a # resume, `message` is ignored: instead of synthesizing a new user turn, # the agent re-enters the loop with an empty prompt so the model diff --git a/backend/src/apis/inference_api/chat/routes.py b/backend/src/apis/inference_api/chat/routes.py index cab21e703..c93315a1d 100644 --- a/backend/src/apis/inference_api/chat/routes.py +++ b/backend/src/apis/inference_api/chat/routes.py @@ -8,7 +8,6 @@ """ import asyncio -import contextlib import json import logging from typing import AsyncGenerator, Optional, Union @@ -26,7 +25,11 @@ build_conversational_error_event, ) from apis.inference_api.runtime_health import ping_payload -from apis.shared.feature_flags import agents_enabled, skills_enabled +from apis.shared.feature_flags import ( + agents_enabled, + mid_turn_steering_enabled, + skills_enabled, +) from apis.shared.files.file_resolver import get_file_resolver from apis.shared.models.managed_models import list_managed_models from apis.shared.quota import ( @@ -291,14 +294,33 @@ def _merge_inference_params( * supported with admin default -> use the default unless the request provides a value within bounds; out-of-bounds values are clamped. - Request keys for params the managed model says nothing about pass through - untouched — the per-provider translation table will drop unknowns. + **Omission means unsupported, for any model that declares a spec at all.** + A param the spec doesn't mention is dropped rather than passed through. + This inverts the original default, which forwarded any request key that + appeared in ``KNOWN_CANONICAL_PARAMS``. That was a latent turn-killer: + Anthropic deprecated ``temperature``/``top_p``/``top_k`` on Claude Opus 4.7 + and later, where a non-default value returns a hard 400. Our curated + templates for those models correctly *omit* the params — but omitting is + not the same as declaring ``supported: false``, so a request that carried a + temperature reached Bedrock and killed the turn mid-stream. Inverting the + default closes the whole class instead of requiring every future template + to enumerate each newly-deprecated param and never miss one. + + Models with **no** spec keep the permissive behavior: an admin who + hand-created a record without a ``supportedParams`` block hasn't declared + anything, so there is nothing to read an omission against. Every drop is + logged, which is what makes the inversion observable if it takes away a + param someone was relying on. """ merged: dict = {} spec_map = {} if managed_model and managed_model.supported_params: spec_map = managed_model.supported_params.params or {} + # A non-empty spec is the signal that the admin described this model's + # params deliberately. An empty/absent one is silence, not a claim. + spec_is_authoritative = bool(spec_map) + seen_keys: set[str] = set() for name, spec in spec_map.items(): seen_keys.add(name) @@ -347,15 +369,27 @@ def _merge_inference_params( elif spec.default is not None: merged[name] = spec.default - # Pass through request keys the admin spec doesn't mention, but only when - # they're in the canonical allow-list. Without this gate, a user could - # submit a future canonical key (or one a future provider mapping starts - # forwarding) and bypass the admin's per-model bounds entirely. Unknown - # keys are dropped here; the provider translation table is the second - # line of defense for ones it doesn't understand. + # Request keys the spec doesn't mention. For a model that declared a spec, + # silence means unsupported and the key is dropped. For one that declared + # nothing, fall back to the canonical allow-list: without that gate a user + # could submit a future canonical key (or one a future provider mapping + # starts forwarding) and bypass the admin's per-model bounds entirely. + # The provider translation table remains the second line of defense. for name, value in request_params.items(): if name in seen_keys or value is None: continue + if spec_is_authoritative: + # Logged at INFO on purpose: this is the inversion taking something + # away. If a param a caller depended on starts disappearing, this + # line is how it gets found — grep `omitted from its supportedParams`. + logger.info( + "Dropping inference param '%s' for model %s — omitted from its " + "supportedParams spec, which declares %d param(s)", + _sanitize_log(name), + _sanitize_log(getattr(managed_model, "model_id", "?")), + len(spec_map), + ) + continue if name not in KNOWN_CANONICAL_PARAMS: logger.info( "Dropping unrecognized inference param '%s' for model %s", @@ -397,15 +431,16 @@ async def _resolve_model_settings( Returns ``(caching_enabled, inference_params, mantle_api_mode, mantle_region, provider)``. A single registry lookup drives all of them. - The Mantle fields are server-authoritative (recorded on the model): + The API-surface fields are server-authoritative (recorded on the model): ``mantle_api_mode`` selects Chat Completions vs the Responses API and - ``mantle_region`` optionally pins inference to a specific region; both - ``None`` for non-Mantle models. ``provider`` is the model's registered - provider (e.g. ``"mantle"``), returned so callers can recover it when the - request/binding didn't carry one — without it a Mantle model like - ``openai.gpt-5.4`` misroutes to Bedrock ConverseStream and fails with an - invalid-model-identifier error. Resolving these here keeps them off the - client request — the SPA can't override. + ``mantle_region`` optionally pins inference to a specific region. Both are + meaningful on either OpenAI-compatible Bedrock surface — ``"mantle"`` and + ``"bedrock-responses"`` — and ``None`` for every other provider. + ``provider`` is the model's registered provider, returned so callers can + recover it when the request/binding didn't carry one — without it a Mantle + model like ``openai.gpt-5.4`` misroutes to Bedrock ConverseStream and fails + with an invalid-model-identifier error. Resolving these here keeps them off + the client request — the SPA can't override. """ request_params = dict(request_inference_params or {}) @@ -1880,7 +1915,7 @@ async def invocations(request: InvocationRequest, current_user: User = Depends(g # answerable without knowing whether the turn ran an approved snapshot or a draft. logger.info( "Agent configuration for this turn: %s", - f"published version {resolved_version}" if resolved_version else "live record / draft", + f"published version {scrub_log(resolved_version)}" if resolved_version else "live record / draft", ) # ⚠️ Both bookkeeping writes below are skipped for a reviewer preview, because a @@ -2160,6 +2195,25 @@ async def invocations(request: InvocationRequest, current_user: User = Depends(g ), ) + # Mid-turn steering, paused-turn path (docs/specs/mid-turn-steering.md). + # A turn paused for consent or approval had no running loop to steer, + # and the pause released its lease — inbox and all. Follow-ups the user + # queued meanwhile ride the resume request and are seeded onto the lease + # we just took, so the ordinary SteeringHook injects them at this turn's + # first tool boundary. One injection path, one ack path, whichever way + # the entry arrived. Best-effort: a failed seed degrades to the + # composer's end-of-turn flush. + if input_data.steering and mid_turn_steering_enabled(): + try: + from apis.shared.sessions.session_lease import seed_steer_queue + + await seed_steer_queue( + session_lease, + [{"id": entry.id, "text": entry.text} for entry in input_data.steering], + ) + except Exception: + logger.warning("Failed to seed carried-over steering", exc_info=True) + try: # Resume requests rebuild the agent from the persisted PausedTurnSnapshot # so a refresh / cache eviction / pod restart between pause and resume @@ -2598,6 +2652,12 @@ def _session_title_sse() -> Optional[str]: # cached and shared across turns, so per-turn state must never live on it # (see #741/#751). turn_agent_id=input_data.rag_assistant_id, + # This turn's lease doubles as the mid-turn steering inbox + # (docs/specs/mid-turn-steering.md). Passed per turn for the + # same reason as turn_agent_id — the agent is cached, the lease + # is not. None for preview sessions and the local + # no-DynamoDB path, where steering is simply inert. + turn_lease=session_lease, ): yield event # Interleave the finished title between agent events (same diff --git a/backend/src/apis/shared/announcements/__init__.py b/backend/src/apis/shared/announcements/__init__.py new file mode 100644 index 000000000..b3223fb41 --- /dev/null +++ b/backend/src/apis/shared/announcements/__init__.py @@ -0,0 +1,5 @@ +"""Feature announcements: admin-authored notices with per-user acknowledgement. + +See ``docs/specs/feature-announcements.md``. PR-1 ships the storage layer and +the admin CRUD only — nothing here is read by a user-facing surface yet. +""" diff --git a/backend/src/apis/shared/announcements/models.py b/backend/src/apis/shared/announcements/models.py new file mode 100644 index 000000000..7ff90ce8b --- /dev/null +++ b/backend/src/apis/shared/announcements/models.py @@ -0,0 +1,599 @@ +"""Models for admin-authored feature announcements and per-user acknowledgements. + +See ``docs/specs/feature-announcements.md``. + +Two item shapes share one table: + + - **Announcement** — ``PK: ANNOUNCEMENTS``, ``SK: ANNOUNCEMENT#``. + A single fixed partition, exactly as ``user_menu_links`` uses, because the + data is global / single-tenant. When per-org scoping is needed the PK + becomes ``ANNOUNCEMENTS#`` without touching the SK shape. + - **Acknowledgement** — ``PK: USER#``, + ``SK: ACK##R``. The same per-user partition + shape ``user_settings`` established. Revision-keyed (§D4) so editing an + announcement does not un-dismiss it for everybody, while an explicit + "show this again" does. + +The whole field set is modelled here even though PR-1 consumes only part of it: +the table is the expensive thing to change, the routes are not. +""" + +from dataclasses import dataclass, field +from datetime import timedelta +from typing import Any, Dict, List, Literal, Optional + +from pydantic import BaseModel, Field, field_validator, model_validator + +from apis.shared.timestamps import from_iso, to_iso, utc_now_iso +# One implementation of the http(s)-only check, not two — announcements +# validate ``ctaUrl`` for exactly the reason ``user_menu_links`` validates +# ``url`` (Angular's DomSanitizer never sees a request made with curl). +from apis.shared.user_menu_links.models import validate_http_url + +AnnouncementSurface = Literal["panel", "banner", "modal"] +AnnouncementSeverity = Literal["info", "success", "warning"] +AnnouncementState = Literal["draft", "scheduled", "published", "archived"] +AckAction = Literal["seen", "dismissed", "acknowledged"] + +ANNOUNCEMENTS_PK = "ANNOUNCEMENTS" +ANNOUNCEMENT_SK_PREFIX = "ANNOUNCEMENT#" +ACK_SK_PREFIX = "ACK#" + +#: ``targetRoles`` entry meaning "everyone" (§D9). A display filter, never a grant. +TARGET_EVERYONE = "*" + +TITLE_MAX_LENGTH = 140 +BODY_MAX_BYTES = 16 * 1024 + +#: Acknowledgement actions are *ranked*, and the stored rank only ever +#: increases (§D2). ``seen`` is written automatically on render, so it races +#: the user's click on the ✕; without a monotonic guard a late ``seen`` write +#: would clobber ``dismissed`` and the banner would come back. Same failure +#: class as #741 / #751 — per-user state moving backwards — so it gets the +#: same discipline: the guard lives in the DynamoDB condition expression, not +#: in application ordering. +ACTION_RANKS: Dict[str, int] = {"seen": 1, "dismissed": 2, "acknowledged": 3} + +#: A rank at or above this suppresses the loud surfaces (banner, modal). +SUPPRESSING_RANK = ACTION_RANKS["dismissed"] + +#: Ack TTLs (§5). Bounded forever without a sweeper. +ACK_TTL_AFTER_EXPIRY = timedelta(days=90) +ACK_TTL_OPEN_ENDED = timedelta(days=730) + +#: Ack counters live as **top-level** attributes on the announcement item, +#: one per (revision, action) — ``ackCountsR1Seen`` and friends. +#: +#: Top-level rather than a nested ``ackCounts`` map for one reason: +#: DynamoDB's ``ADD`` only works on top-level attributes, and it creates the +#: attribute (treating a missing one as 0) in the same atomic write. A nested +#: map needs ``SET path = if_not_exists(path, :zero) + :one``, which raises +#: ValidationException until the parent map exists — so every announcement +#: authored before this shipped would need an init-then-retry path around +#: every ack. One atomic write with no fallback beats a tidier shape with a +#: repair branch on the hot path. +#: +#: Keyed by revision because "Show again" (§D4) is a deliberate re-broadcast: +#: rolling its acks into the previous revision's totals would silently inflate +#: them and make the numbers lie about the version people actually saw. +ACK_COUNT_ATTR_PREFIX = "ackCounts" + + +def ack_count_attr(revision: int, action: str) -> str: + """Attribute name holding the count of users who reached ``action``.""" + return f"{ACK_COUNT_ATTR_PREFIX}R{int(revision)}{action.capitalize()}" + + +_LOUD_SURFACES = frozenset({"banner", "modal"}) + + +def action_rank(action: str) -> int: + """Rank for an ack action; raises on an unknown one.""" + try: + return ACTION_RANKS[action] + except KeyError: + raise ValueError(f"unknown acknowledgement action: {action!r}") from None + + +def _validate_iso(value: Optional[str], field_name: str) -> Optional[str]: + """Normalize an ISO-8601 timestamp, or raise. + + Stored normalized (``…Z``) so string comparison against ``utc_now_iso()`` + in the visibility filter is a valid instant comparison. + """ + if value is None or value == "": + return None + try: + return to_iso(from_iso(value)) + except (ValueError, TypeError) as e: + raise ValueError(f"{field_name} must be an ISO-8601 timestamp") from e + + +def _validate_surfaces_expiry( + surfaces: List[str], expires_at: Optional[str] +) -> None: + """A loud surface must say when it stops being loud (§5, §11). + + An unbounded banner or modal is the announcement-fatigue failure mode with + no backstop, so the requirement is enforced at the model layer rather than + left to the admin form. + """ + if expires_at: + return + loud = sorted(_LOUD_SURFACES.intersection(surfaces)) + if loud: + raise ValueError( + "expiresAt is required when surfaces include " + ", ".join(loud) + ) + + +def _validate_body_size(value: Optional[str]) -> Optional[str]: + if value is None: + return value + if len(value.encode("utf-8")) > BODY_MAX_BYTES: + raise ValueError(f"body_markdown must be at most {BODY_MAX_BYTES} bytes") + return value + + +# ============================================================================= +# Stored items +# ============================================================================= + + +@dataclass +class Announcement: + """One admin-authored announcement stored in DynamoDB.""" + + announcement_id: str + title: str + body_markdown: str + created_at: str + updated_at: str + publish_at: str + summary: Optional[str] = None + surfaces: List[str] = field(default_factory=lambda: ["panel"]) + severity: str = "info" + state: str = "draft" + expires_at: Optional[str] = None + # ⚠️ **Display filter, not an RBAC grant (§D9).** CLAUDE.md's rule that a + # role list on a resource must be written through to each role's + # ``granted*`` list governs tools, models, and skills — things with a + # ``can_access_*`` predicate behind them. Announcement visibility is not + # access control: there is no capability conferred and nothing to inherit, + # so this list is matched against ``User.roles`` at read time and lives + # **only** on this item. Do not "fix" it into ``apis/shared/rbac/``; doing + # so would put display metadata into the access-decision path. + target_roles: List[str] = field(default_factory=lambda: ["*"]) + show_to_new_users: bool = False + requires_ack: bool = False + cta_label: Optional[str] = None + cta_url: Optional[str] = None + revision: int = 1 + created_by: Optional[str] = None + #: Ack funnel counters, carried through read → write. + #: + #: **Not domain state — a projection that must survive.** Every admin + #: mutation (``update_announcement``, ``set_state``, ``bump_revision``) + #: is a full ``put_item`` of this dataclass, so any attribute the model + #: does not know about is destroyed by it. Without this field, publishing + #: or archiving an announcement — or hitting "Show again" — would silently + #: zero every stat the feature exists to report. + #: + #: The read-modify-write does mean an ack landing in the same instant as + #: an admin edit can lose its increment. That is the documented cost of + #: approximate O(1) counters (§9); admin writes are rare, and the ack + #: itself is never at risk because it is a different item. + ack_counts: Dict[str, int] = field(default_factory=dict) + + def to_dynamo_item(self) -> Dict[str, Any]: + item: Dict[str, Any] = { + "PK": ANNOUNCEMENTS_PK, + "SK": f"{ANNOUNCEMENT_SK_PREFIX}{self.announcement_id}", + "announcementId": self.announcement_id, + "title": self.title, + "bodyMarkdown": self.body_markdown, + "surfaces": list(self.surfaces), + "severity": self.severity, + "state": self.state, + "publishAt": self.publish_at, + "targetRoles": list(self.target_roles), + "showToNewUsers": self.show_to_new_users, + "requiresAck": self.requires_ack, + "revision": int(self.revision), + "createdAt": self.created_at, + "updatedAt": self.updated_at, + } + if self.summary: + item["summary"] = self.summary + if self.expires_at: + item["expiresAt"] = self.expires_at + if self.cta_label: + item["ctaLabel"] = self.cta_label + if self.cta_url: + item["ctaUrl"] = self.cta_url + if self.created_by: + item["createdBy"] = self.created_by + # Carried forward verbatim so a full-item put cannot destroy them. + for attr, value in (self.ack_counts or {}).items(): + item[attr] = int(value) + return item + + @classmethod + def from_dynamo_item(cls, item: Dict[str, Any]) -> "Announcement": + try: + created_at = item["createdAt"] + updated_at = item["updatedAt"] + publish_at = item["publishAt"] + except KeyError as e: + raise ValueError( + f"Announcement item {item.get('SK', '?')} is missing required " + f"field: {e.args[0]}" + ) from e + return cls( + announcement_id=item["announcementId"], + title=item["title"], + body_markdown=item.get("bodyMarkdown", ""), + summary=item.get("summary"), + surfaces=list(item.get("surfaces") or ["panel"]), + severity=item.get("severity", "info"), + state=item.get("state", "draft"), + publish_at=publish_at, + expires_at=item.get("expiresAt"), + target_roles=list(item.get("targetRoles") or ["*"]), + show_to_new_users=bool(item.get("showToNewUsers", False)), + requires_ack=bool(item.get("requiresAck", False)), + cta_label=item.get("ctaLabel"), + cta_url=item.get("ctaUrl"), + revision=int(item.get("revision", 1)), + created_at=created_at, + updated_at=updated_at, + created_by=item.get("createdBy"), + ack_counts={ + key: int(value) + for key, value in item.items() + if key.startswith(ACK_COUNT_ATTR_PREFIX) + }, + ) + + def ack_ttl(self, action: str) -> Optional[int]: + """Epoch-seconds TTL for an ack on this announcement, or None to keep it. + + ``acknowledged`` on a ``requiresAck`` announcement is a compliance + record, so it is deliberately **not** expired — the archive path + disposes of those on purpose (§5). + """ + if self.requires_ack and action == "acknowledged": + return None + if self.expires_at: + anchor = from_iso(self.expires_at) + ACK_TTL_AFTER_EXPIRY + else: + anchor = from_iso(self.publish_at) + ACK_TTL_OPEN_ENDED + return int(anchor.timestamp()) + + +@dataclass +class AnnouncementAck: + """One user's acknowledgement of one revision of one announcement.""" + + user_id: str + announcement_id: str + revision: int + action: str + action_at: str + surface: str + action_rank: int = 0 + ttl: Optional[int] = None + + def __post_init__(self) -> None: + if not self.action_rank: + self.action_rank = action_rank(self.action) + + @staticmethod + def sort_key(announcement_id: str, revision: int) -> str: + return f"{ACK_SK_PREFIX}{announcement_id}#R{int(revision)}" + + @staticmethod + def partition_key(user_id: str) -> str: + return f"USER#{user_id}" + + @classmethod + def from_dynamo_item(cls, item: Dict[str, Any]) -> "AnnouncementAck": + pk = item.get("PK", "") + return cls( + user_id=pk[len("USER#"):] if pk.startswith("USER#") else pk, + announcement_id=item["announcementId"], + revision=int(item["revision"]), + action=item["action"], + action_rank=int(item.get("actionRank", 0)), + action_at=item.get("actionAt", ""), + surface=item.get("surface", ""), + ttl=int(item["ttl"]) if item.get("ttl") is not None else None, + ) + + +# ============================================================================= +# Pydantic request/response models +# ============================================================================= + + +class AnnouncementCreate(BaseModel): + title: str = Field(..., min_length=1, max_length=TITLE_MAX_LENGTH) + body_markdown: str = Field(..., min_length=1) + summary: Optional[str] = Field(None, max_length=280) + surfaces: List[AnnouncementSurface] = Field(default_factory=lambda: ["panel"]) + severity: AnnouncementSeverity = "info" + # Only an *unpublished* state may be chosen at create time. Going live is + # its own action, so an announcement can never be published by the same + # call that is still validating its body. + state: Literal["draft", "scheduled"] = "draft" + publish_at: Optional[str] = None + expires_at: Optional[str] = None + target_roles: List[str] = Field(default_factory=lambda: ["*"]) + show_to_new_users: bool = False + requires_ack: bool = False + cta_label: Optional[str] = Field(None, max_length=64) + cta_url: Optional[str] = Field(None, max_length=2048) + + @field_validator("body_markdown") + @classmethod + def _check_body_size(cls, v: str) -> str: + return _validate_body_size(v) + + @field_validator("cta_url") + @classmethod + def _check_cta_url(cls, v: Optional[str]) -> Optional[str]: + return validate_http_url(v) + + @field_validator("publish_at") + @classmethod + def _check_publish_at(cls, v: Optional[str]) -> Optional[str]: + return _validate_iso(v, "publish_at") + + @field_validator("expires_at") + @classmethod + def _check_expires_at(cls, v: Optional[str]) -> Optional[str]: + return _validate_iso(v, "expires_at") + + @model_validator(mode="after") + def _check_invariants(self) -> "AnnouncementCreate": + validate_announcement_invariants( + surfaces=list(self.surfaces), + expires_at=self.expires_at, + publish_at=self.publish_at, + cta_label=self.cta_label, + cta_url=self.cta_url, + ) + return self + + +class AnnouncementUpdate(BaseModel): + """Partial update — all fields optional. + + Two fields are deliberately **absent**, because both are transitions rather + than content: + + * ``state`` — ``/publish`` and ``/archive`` own it. Accepting it here would + make the publish guard decorative: an archived announcement could be put + back on screen by a PATCH that looks like an ordinary body edit. + * ``revision`` — bumping it re-shows the announcement to everyone who + already dismissed it, so it is the explicit ``/revise`` action (§D4) and + never a side effect of fixing a typo. + """ + + title: Optional[str] = Field(None, min_length=1, max_length=TITLE_MAX_LENGTH) + body_markdown: Optional[str] = Field(None, min_length=1) + summary: Optional[str] = Field(None, max_length=280) + surfaces: Optional[List[AnnouncementSurface]] = None + severity: Optional[AnnouncementSeverity] = None + publish_at: Optional[str] = None + expires_at: Optional[str] = None + target_roles: Optional[List[str]] = None + show_to_new_users: Optional[bool] = None + requires_ack: Optional[bool] = None + cta_label: Optional[str] = Field(None, max_length=64) + cta_url: Optional[str] = Field(None, max_length=2048) + + @field_validator("body_markdown") + @classmethod + def _check_body_size(cls, v: Optional[str]) -> Optional[str]: + return _validate_body_size(v) + + @field_validator("cta_url") + @classmethod + def _check_cta_url(cls, v: Optional[str]) -> Optional[str]: + return validate_http_url(v) + + @field_validator("publish_at") + @classmethod + def _check_publish_at(cls, v: Optional[str]) -> Optional[str]: + return _validate_iso(v, "publish_at") + + @field_validator("expires_at") + @classmethod + def _check_expires_at(cls, v: Optional[str]) -> Optional[str]: + return _validate_iso(v, "expires_at") + + +class AnnouncementAckRequest(BaseModel): + """Body of ``POST /announcements/{id}/ack`` (consumed in PR-2).""" + + action: AckAction + surface: AnnouncementSurface + + +class AnnouncementResponse(BaseModel): + announcement_id: str + title: str + body_markdown: str + summary: Optional[str] = None + surfaces: List[str] + severity: str + state: str + publish_at: str + expires_at: Optional[str] = None + target_roles: List[str] + show_to_new_users: bool + requires_ack: bool + cta_label: Optional[str] = None + cta_url: Optional[str] = None + revision: int + created_at: str + updated_at: str + created_by: Optional[str] = None + + @classmethod + def from_announcement(cls, a: Announcement) -> "AnnouncementResponse": + return cls( + announcement_id=a.announcement_id, + title=a.title, + body_markdown=a.body_markdown, + summary=a.summary, + surfaces=list(a.surfaces), + severity=a.severity, + state=a.state, + publish_at=a.publish_at, + expires_at=a.expires_at, + target_roles=list(a.target_roles), + show_to_new_users=a.show_to_new_users, + requires_ack=a.requires_ack, + cta_label=a.cta_label, + cta_url=a.cta_url, + revision=a.revision, + created_at=a.created_at, + updated_at=a.updated_at, + created_by=a.created_by, + ) + + +class AnnouncementListResponse(BaseModel): + announcements: List[AnnouncementResponse] + total: int + + +class AnnouncementStatsResponse(BaseModel): + """Reach for one announcement, at its **current** revision. + + The three counts are a **funnel, not a partition**: a user who + acknowledged also counts as dismissed and as seen, because the stored rank + only ever rises through them (§D2). So ``seen >= dismissed >= + acknowledged`` always holds, and "how many only ever saw it" is + ``seen - dismissed``. Reading them as disjoint buckets would understate + every stage. + + Everything here is approximate by construction and must be labelled that + way in the UI (§11): + + - the counts are incremented on a **second** write after the ack itself + lands, so a failure between the two under-counts by one. That is the + documented trade for O(1) stats with no GSI and no scan. + - ``targeted`` is a denominator that moves as people join and roles + change. **Do not build compliance reporting on it.** + - **Nothing is backfilled.** The counters are incremented by the ack write + path, so acks recorded before this shipped are invisible here — an + existing environment starts every announcement at zero on deploy day + even where people have already read and dismissed it. The ack rows + themselves are intact; only the tallies begin at the deploy. There is no + cheap repair for this (counting the existing rows is the scan the design + exists to avoid), so read early numbers as "reach since stats shipped". + """ + + announcement_id: str + revision: int + seen: int + dismissed: int + acknowledged: int + #: Active users this announcement is aimed at, or None when the audience + #: cannot be counted — see ``AnnouncementsService.get_stats``. + targeted: Optional[int] = None + + +# ============================================================================= +# User-facing response models +# +# Deliberately NOT a subset alias of ``AnnouncementResponse``. That model is +# the admin view and carries ``state``, ``targetRoles``, ``showToNewUsers``, +# ``createdBy`` and the audit timestamps — telling a user which roles a notice +# was aimed at, or that one exists in a state they cannot see, is a small +# information leak with no upside. Two explicit models means adding an admin +# field can never widen the user payload by accident. +# ============================================================================= + + +class UserAnnouncement(BaseModel): + """One announcement as a user sees it.""" + + announcement_id: str + title: str + body_markdown: str + summary: Optional[str] = None + surfaces: List[str] + severity: str + publish_at: str + expires_at: Optional[str] = None + requires_ack: bool + cta_label: Optional[str] = None + cta_url: Optional[str] = None + revision: int + #: No acknowledgement recorded at this revision — drives the unread dot. + is_unread: bool + #: Acked an earlier revision but not this one, so the panel says "Updated" + #: rather than "New" (§D4). + is_updated: bool + + @classmethod + def from_announcement( + cls, a: Announcement, *, is_unread: bool, is_updated: bool + ) -> "UserAnnouncement": + return cls( + announcement_id=a.announcement_id, + title=a.title, + body_markdown=a.body_markdown, + summary=a.summary, + surfaces=list(a.surfaces), + severity=a.severity, + publish_at=a.publish_at, + expires_at=a.expires_at, + requires_ack=a.requires_ack, + cta_label=a.cta_label, + cta_url=a.cta_url, + revision=a.revision, + is_unread=is_unread, + is_updated=is_updated, + ) + + +class AnnouncementFeedResponse(BaseModel): + """``GET /announcements`` — already filtered and capped (§D5, §D7). + + ``banner`` and ``modal`` are populated from PR-2 onward even though no SPA + surface renders them until PR-4 / PR-5; the contract is complete so those + PRs are pure frontend. + """ + + panel: List[UserAnnouncement] + banner: Optional[UserAnnouncement] = None + modal: Optional[UserAnnouncement] = None + unread_count: int + + +def validate_announcement_invariants( + *, + surfaces: List[str], + expires_at: Optional[str], + publish_at: Optional[str] = None, + cta_label: Optional[str] = None, + cta_url: Optional[str] = None, +) -> None: + """Cross-field rules, shared by create validation and post-merge update. + + Raised as ``ValueError`` so pydantic reports it as a 422 on create and the + route maps it to a 400 on a partial update whose *merged* result is + invalid — the same split ``user_menu_links`` uses. + """ + _validate_surfaces_expiry(surfaces, expires_at) + if cta_url and not cta_label: + raise ValueError("cta_label is required when cta_url is set") + if cta_label and not cta_url: + raise ValueError("cta_url is required when cta_label is set") + if publish_at and expires_at and expires_at <= publish_at: + raise ValueError("expires_at must be after publish_at") diff --git a/backend/src/apis/shared/announcements/repository.py b/backend/src/apis/shared/announcements/repository.py new file mode 100644 index 000000000..1fee93730 --- /dev/null +++ b/backend/src/apis/shared/announcements/repository.py @@ -0,0 +1,511 @@ +"""DynamoDB repository for feature announcements and per-user acknowledgements. + +Two access patterns, one table: + + - announcements: ``query`` on the fixed ``ANNOUNCEMENTS`` partition, exactly + as ``UserMenuLinksRepository.list_links`` does. Volume is tens of items, so + no GSI. + - acknowledgements: ``query`` on ``USER#`` with ``begins_with(SK, "ACK#")``, + bounded by the number of announcements a user has ever interacted with. + +The write worth reading closely is :meth:`record_ack`. +""" + +import logging +import os +import uuid +from typing import Dict, List, Optional + +import boto3 +from botocore.exceptions import ClientError + +from apis.shared.timestamps import utc_now_iso + +from .models import ( + ACK_SK_PREFIX, + ACTION_RANKS, + ANNOUNCEMENT_SK_PREFIX, + ANNOUNCEMENTS_PK, + Announcement, + AnnouncementAck, + AnnouncementCreate, + AnnouncementUpdate, + ack_count_attr, + action_rank, + validate_announcement_invariants, +) +from apis.shared.user_menu_links.models import validate_http_url + +logger = logging.getLogger(__name__) + + +class AnnouncementsRepository: + """CRUD for announcements + the monotonic ack write path.""" + + def __init__(self, table_name: Optional[str] = None, region: Optional[str] = None): + self._table_name = table_name or os.getenv("DYNAMODB_ANNOUNCEMENTS_TABLE_NAME") + self._region = region or os.getenv("AWS_REGION", "us-west-2") + self._enabled = bool(self._table_name) + + if not self._enabled: + logger.warning( + "DYNAMODB_ANNOUNCEMENTS_TABLE_NAME not set. " + "Announcements repository is disabled." + ) + return + + profile = os.getenv("AWS_PROFILE") + if profile: + session = boto3.Session(profile_name=profile) + self._dynamodb = session.resource("dynamodb", region_name=self._region) + else: + self._dynamodb = boto3.resource("dynamodb", region_name=self._region) + self._table = self._dynamodb.Table(self._table_name) + logger.info(f"Initialized announcements repository: table={self._table_name}") + + @property + def enabled(self) -> bool: + return self._enabled + + # ------------------------------------------------------------------ + # Announcements + # ------------------------------------------------------------------ + + async def list_announcements( + self, states: Optional[List[str]] = None + ) -> List[Announcement]: + if not self._enabled: + return [] + + kwargs = dict( + KeyConditionExpression="PK = :pk AND begins_with(SK, :sk)", + ExpressionAttributeValues={ + ":pk": ANNOUNCEMENTS_PK, + ":sk": ANNOUNCEMENT_SK_PREFIX, + }, + ) + try: + response = self._table.query(**kwargs) + items = response.get("Items", []) + while "LastEvaluatedKey" in response: + response = self._table.query( + ExclusiveStartKey=response["LastEvaluatedKey"], **kwargs + ) + items.extend(response.get("Items", [])) + except ClientError: + logger.error("Error listing announcements", exc_info=True) + raise + + announcements = [Announcement.from_dynamo_item(item) for item in items] + if states: + wanted = set(states) + announcements = [a for a in announcements if a.state in wanted] + # Newest first — the admin list and the What's-New panel both read + # reverse-chronologically. + announcements.sort(key=lambda a: (a.publish_at, a.created_at), reverse=True) + return announcements + + async def get_announcement(self, announcement_id: str) -> Optional[Announcement]: + if not self._enabled: + return None + try: + response = self._table.get_item( + Key={ + "PK": ANNOUNCEMENTS_PK, + "SK": f"{ANNOUNCEMENT_SK_PREFIX}{announcement_id}", + } + ) + item = response.get("Item") + if not item: + return None + return Announcement.from_dynamo_item(item) + except ClientError: + logger.error("Error getting announcement", exc_info=True) + raise + + async def create_announcement( + self, data: AnnouncementCreate, created_by: Optional[str] = None + ) -> Announcement: + if not self._enabled: + raise RuntimeError("Announcements repository is not enabled") + + now = utc_now_iso() + announcement = Announcement( + announcement_id=str(uuid.uuid4()), + title=data.title, + body_markdown=data.body_markdown, + summary=data.summary, + surfaces=list(data.surfaces), + severity=data.severity, + state=data.state, + publish_at=data.publish_at or now, + expires_at=data.expires_at, + target_roles=list(data.target_roles), + show_to_new_users=data.show_to_new_users, + requires_ack=data.requires_ack, + cta_label=data.cta_label, + cta_url=data.cta_url, + revision=1, + created_at=now, + updated_at=now, + created_by=created_by, + ) + + try: + self._table.put_item( + Item=announcement.to_dynamo_item(), + ConditionExpression="attribute_not_exists(SK)", + ) + except ClientError: + logger.error("Error creating announcement", exc_info=True) + raise + + logger.info(f"Created announcement: {announcement.announcement_id}") + return announcement + + async def update_announcement( + self, announcement_id: str, updates: AnnouncementUpdate + ) -> Optional[Announcement]: + """Partial update. ``revision`` is untouched by design (§D4) — a typo + fix must not re-fire a modal at the whole user base.""" + if not self._enabled: + return None + + existing = await self.get_announcement(announcement_id) + if not existing: + return None + + for field_name, value in updates.model_dump(exclude_none=True).items(): + setattr(existing, field_name, value) + existing.updated_at = utc_now_iso() + + # Re-validate the merged record: a PATCH that only adds `banner` to + # `surfaces` is individually valid and jointly not. + validate_announcement_invariants( + surfaces=list(existing.surfaces), + expires_at=existing.expires_at, + publish_at=existing.publish_at, + cta_label=existing.cta_label, + cta_url=existing.cta_url, + ) + validate_http_url(existing.cta_url) + + try: + self._table.put_item(Item=existing.to_dynamo_item()) + except ClientError: + logger.error("Error updating announcement", exc_info=True) + raise + + logger.info(f"Updated announcement: {announcement_id}") + return existing + + async def set_state( + self, announcement_id: str, state: str + ) -> Optional[Announcement]: + if not self._enabled: + return None + existing = await self.get_announcement(announcement_id) + if not existing: + return None + existing.state = state + existing.updated_at = utc_now_iso() + try: + self._table.put_item(Item=existing.to_dynamo_item()) + except ClientError: + logger.error("Error updating announcement state", exc_info=True) + raise + logger.info(f"Announcement {announcement_id} state -> {state}") + return existing + + async def bump_revision(self, announcement_id: str) -> Optional[Announcement]: + """"Show this again" (§D4). + + Every user's suppression lapses at once because their acks are keyed by + the old revision. The R1 acks stay readable, which is what lets the + panel mark the entry *Updated* rather than plain unread. + """ + if not self._enabled: + return None + existing = await self.get_announcement(announcement_id) + if not existing: + return None + existing.revision = int(existing.revision) + 1 + existing.updated_at = utc_now_iso() + try: + self._table.put_item(Item=existing.to_dynamo_item()) + except ClientError: + logger.error("Error bumping announcement revision", exc_info=True) + raise + logger.info( + f"Announcement {announcement_id} revision -> {existing.revision}" + ) + return existing + + async def delete_announcement(self, announcement_id: str) -> bool: + if not self._enabled: + return False + existing = await self.get_announcement(announcement_id) + if not existing: + return False + try: + self._table.delete_item( + Key={ + "PK": ANNOUNCEMENTS_PK, + "SK": f"{ANNOUNCEMENT_SK_PREFIX}{announcement_id}", + } + ) + except ClientError: + logger.error("Error deleting announcement", exc_info=True) + raise + logger.info(f"Deleted announcement: {announcement_id}") + return True + + # ------------------------------------------------------------------ + # Acknowledgements + # ------------------------------------------------------------------ + + async def record_ack( + self, + *, + user_id: str, + announcement_id: str, + revision: int, + action: str, + surface: str, + ttl: Optional[int] = None, + ) -> bool: + """Record an ack, **monotonically** (§D2). + + Returns True if this write raised the stored rank, False if the guard + rejected it because an equal-or-stronger action was already recorded. + **False is success, not an error.** ``seen`` is written automatically + the moment a surface renders, so it races the user's click on the ✕; + without the condition a late ``seen`` would overwrite ``dismissed`` and + the banner would come back on the next load. The guard belongs in the + database, not in application ordering — see #741 / #751, whose shape + was exactly this. + """ + if not self._enabled: + return False + + rank = action_rank(action) + now = utc_now_iso() + + expression_values = { + ":rank": rank, + ":action": action, + ":now": now, + ":announcementId": announcement_id, + ":revision": int(revision), + ":surface": surface, + } + set_clause = ( + "SET actionRank = :rank, #action = :action, actionAt = :now, " + "announcementId = :announcementId, #revision = :revision, " + "#surface = :surface" + ) + names = { + "#action": "action", # reserved word + "#revision": "revision", + "#surface": "surface", + } + if ttl is None: + # A compliance-bearing ack (§5): clear any TTL a weaker earlier + # action may have set, rather than letting it expire the record. + update_expression = f"{set_clause} REMOVE #ttl" + names["#ttl"] = "ttl" + else: + update_expression = f"{set_clause}, #ttl = :ttl" + names["#ttl"] = "ttl" + expression_values[":ttl"] = int(ttl) + + try: + response = self._table.update_item( + Key={ + "PK": AnnouncementAck.partition_key(user_id), + "SK": AnnouncementAck.sort_key(announcement_id, revision), + }, + UpdateExpression=update_expression, + ConditionExpression=( + "attribute_not_exists(actionRank) OR actionRank < :rank" + ), + ExpressionAttributeNames=names, + ExpressionAttributeValues=expression_values, + # The rank this user held *before* this write. Absent when the + # item is new, which reads as 0. It is what makes the counters + # count users rather than clicks: without it a `seen` followed + # by a `dismissed` would add two to the seen total. + ReturnValues="UPDATED_OLD", + ) + except ClientError as e: + if e.response.get("Error", {}).get("Code") == "ConditionalCheckFailedException": + # Already at or above this rank. Nothing to do, and nothing wrong. + logger.debug( + "Ack not raised (already >= rank): user=%s announcement=%s " + "revision=%s action=%s", + user_id, + announcement_id, + revision, + action, + ) + return False + logger.error("Error recording announcement ack", exc_info=True) + raise + + previous_rank = int(response.get("Attributes", {}).get("actionRank", 0)) + self._increment_ack_counts( + announcement_id=announcement_id, + revision=revision, + from_rank=previous_rank, + to_rank=rank, + ) + return True + + def _increment_ack_counts( + self, + *, + announcement_id: str, + revision: int, + from_rank: int, + to_rank: int, + ) -> None: + """Bump the funnel counters for every rank this write crossed. + + A user landing straight on ``acknowledged`` crosses all three, so all + three rise; one already at ``dismissed`` crosses only the third. That + is what keeps ``seen >= dismissed >= acknowledged`` true without ever + reading the counters back. + + **Best-effort on purpose.** This is a second write after the ack has + already been durably recorded, and the ack is the record that matters + (§D2/§D3). A failure here is logged and swallowed: an under-counted + stat is a worse dashboard, while raising would turn a successful + acknowledgement into a 500 and lose the user's click. The spec asks + for exactly this trade — approximate, O(1), no GSI, no scan. + """ + crossed = [ + action + for action, action_value in ACTION_RANKS.items() + if from_rank < action_value <= to_rank + ] + if not crossed: + return + + names = {} + values = {":one": 1} + clauses = [] + for index, action in enumerate(crossed): + alias = f"#c{index}" + names[alias] = ack_count_attr(revision, action) + clauses.append(f"{alias} :one") + + try: + # ADD, not SET: it creates a missing attribute as 0 in the same + # atomic write, so announcements authored before stats shipped + # need no backfill and no init-then-retry branch. + self._table.update_item( + Key={ + "PK": ANNOUNCEMENTS_PK, + "SK": f"{ANNOUNCEMENT_SK_PREFIX}{announcement_id}", + }, + UpdateExpression="ADD " + ", ".join(clauses), + ExpressionAttributeNames=names, + ExpressionAttributeValues=values, + ) + except ClientError: + logger.warning( + "Ack recorded but its stats counters were not incremented: " + "announcement=%s revision=%s crossed=%s", + announcement_id, + revision, + crossed, + exc_info=True, + ) + + async def get_ack_counts( + self, announcement_id: str, revision: int + ) -> Dict[str, int]: + """Funnel counts for one revision, zero-filled. + + Reads the announcement item itself — the counters live on it, which is + the whole point of the design: no GSI on ``announcementId`` and no + scan of the ack partitions. + """ + zero = {action: 0 for action in ACTION_RANKS} + if not self._enabled: + return zero + + try: + response = self._table.get_item( + Key={ + "PK": ANNOUNCEMENTS_PK, + "SK": f"{ANNOUNCEMENT_SK_PREFIX}{announcement_id}", + } + ) + except ClientError: + logger.error("Error reading announcement ack counts", exc_info=True) + raise + + item = response.get("Item") + if not item: + return zero + return { + action: int(item.get(ack_count_attr(revision, action), 0)) + for action in ACTION_RANKS + } + + async def list_acks(self, user_id: str) -> List[AnnouncementAck]: + """Every ack this user has ever written, across revisions.""" + if not self._enabled: + return [] + + kwargs = dict( + KeyConditionExpression="PK = :pk AND begins_with(SK, :sk)", + ExpressionAttributeValues={ + ":pk": AnnouncementAck.partition_key(user_id), + ":sk": ACK_SK_PREFIX, + }, + ) + try: + response = self._table.query(**kwargs) + items = response.get("Items", []) + while "LastEvaluatedKey" in response: + response = self._table.query( + ExclusiveStartKey=response["LastEvaluatedKey"], **kwargs + ) + items.extend(response.get("Items", [])) + except ClientError: + logger.error("Error listing announcement acks", exc_info=True) + raise + + return [AnnouncementAck.from_dynamo_item(item) for item in items] + + async def get_ack( + self, user_id: str, announcement_id: str, revision: int + ) -> Optional[AnnouncementAck]: + if not self._enabled: + return None + try: + response = self._table.get_item( + Key={ + "PK": AnnouncementAck.partition_key(user_id), + "SK": AnnouncementAck.sort_key(announcement_id, revision), + } + ) + item = response.get("Item") + if not item: + return None + return AnnouncementAck.from_dynamo_item(item) + except ClientError: + logger.error("Error getting announcement ack", exc_info=True) + raise + + +_repository: Optional[AnnouncementsRepository] = None + + +def get_announcements_repository() -> AnnouncementsRepository: + global _repository + if _repository is None: + _repository = AnnouncementsRepository() + return _repository diff --git a/backend/src/apis/shared/announcements/service.py b/backend/src/apis/shared/announcements/service.py new file mode 100644 index 000000000..aa6c4a451 --- /dev/null +++ b/backend/src/apis/shared/announcements/service.py @@ -0,0 +1,232 @@ +"""Service layer for feature announcements. + +Thin over the repository, with the policy that must not live in a route +handler because both the admin surface and the user surface go through it: + + - ``panel`` is forced into ``surfaces`` (§D1) — dismissing a loud surface can + never destroy the information. + - ack TTLs are derived from the announcement, not supplied by the caller + (§5), so a client cannot pick its own retention. + - the user feed is assembled here, so ``GET /announcements`` and the ack + endpoint's 404 check answer "can this user see it?" the same way. +""" + +import logging +from datetime import datetime, timezone +from typing import List, Optional, Sequence + +from apis.shared.users.repository import UserRepository + +from .models import ( + TARGET_EVERYONE, + Announcement, + AnnouncementAck, + AnnouncementCreate, + AnnouncementStatsResponse, + AnnouncementUpdate, +) +from .repository import AnnouncementsRepository, get_announcements_repository +from .visibility import AnnouncementFeed, compute_feed + +logger = logging.getLogger(__name__) + +#: A published announcement can only come from these; archived is terminal. +PUBLISHABLE_STATES = frozenset({"draft", "scheduled", "published"}) + +#: Canonical surface order, so the stored list is deterministic regardless of +#: what order the admin form submitted. +_SURFACE_ORDER = ("panel", "banner", "modal") + + +def _normalize_surfaces(surfaces: Optional[List[str]]) -> List[str]: + """Force ``panel`` on and put the list in canonical order (§D1).""" + chosen = set(surfaces or []) + chosen.add("panel") + return [s for s in _SURFACE_ORDER if s in chosen] + + +class AnnouncementsService: + def __init__(self, repository: AnnouncementsRepository): + self._repo = repository + + @property + def enabled(self) -> bool: + return self._repo.enabled + + # ── Announcements ──────────────────────────────────────────────────── + + async def list_announcements( + self, states: Optional[List[str]] = None + ) -> List[Announcement]: + return await self._repo.list_announcements(states=states) + + async def get_announcement(self, announcement_id: str) -> Optional[Announcement]: + return await self._repo.get_announcement(announcement_id) + + async def create_announcement( + self, data: AnnouncementCreate, created_by: Optional[str] = None + ) -> Announcement: + data = data.model_copy(update={"surfaces": _normalize_surfaces(data.surfaces)}) + return await self._repo.create_announcement(data, created_by=created_by) + + async def update_announcement( + self, announcement_id: str, updates: AnnouncementUpdate + ) -> Optional[Announcement]: + if updates.surfaces is not None: + updates = updates.model_copy( + update={"surfaces": _normalize_surfaces(updates.surfaces)} + ) + return await self._repo.update_announcement(announcement_id, updates) + + async def publish(self, announcement_id: str) -> Optional[Announcement]: + existing = await self._repo.get_announcement(announcement_id) + if not existing: + return None + if existing.state not in PUBLISHABLE_STATES: + raise ValueError( + f"cannot publish an announcement in state '{existing.state}'" + ) + return await self._repo.set_state(announcement_id, "published") + + async def archive(self, announcement_id: str) -> Optional[Announcement]: + """Stop showing it. Acks are deliberately kept — the record of who saw + what outlives the notice.""" + return await self._repo.set_state(announcement_id, "archived") + + async def revise(self, announcement_id: str) -> Optional[Announcement]: + return await self._repo.bump_revision(announcement_id) + + async def delete_announcement(self, announcement_id: str) -> bool: + return await self._repo.delete_announcement(announcement_id) + + # ── Acknowledgements ───────────────────────────────────────────────── + + async def record_ack( + self, + *, + user_id: str, + announcement: Announcement, + action: str, + surface: str, + ) -> bool: + """Record an ack against the announcement's **current** revision. + + Returns whether the stored rank was raised; False means an + equal-or-stronger action was already recorded, which is a no-op, not a + failure (§D2). + """ + return await self._repo.record_ack( + user_id=user_id, + announcement_id=announcement.announcement_id, + revision=announcement.revision, + action=action, + surface=surface, + ttl=announcement.ack_ttl(action), + ) + + async def list_acks(self, user_id: str) -> List[AnnouncementAck]: + return await self._repo.list_acks(user_id) + + async def get_ack( + self, user_id: str, announcement_id: str, revision: int + ) -> Optional[AnnouncementAck]: + return await self._repo.get_ack(user_id, announcement_id, revision) + + async def get_stats( + self, announcement_id: str + ) -> Optional[AnnouncementStatsResponse]: + """Reach for one announcement at its current revision, or None if gone. + + Counts come from the counters on the announcement item itself — no GSI + on ``announcementId``, no scan of the ack partitions. They are a + funnel, not a partition (see ``AnnouncementStatsResponse``). + """ + announcement = await self._repo.get_announcement(announcement_id) + if announcement is None: + return None + + counts = await self._repo.get_ack_counts( + announcement_id, announcement.revision + ) + return AnnouncementStatsResponse( + announcement_id=announcement_id, + revision=announcement.revision, + seen=counts.get("seen", 0), + dismissed=counts.get("dismissed", 0), + acknowledged=counts.get("acknowledged", 0), + targeted=await self._estimate_targeted(announcement), + ) + + async def _estimate_targeted( + self, announcement: Announcement + ) -> Optional[int]: + """Roughly how many active users this announcement is aimed at. + + **Only answerable for a ``"*"`` audience, and None otherwise.** The + count comes from a ``Select="COUNT"`` query on the users table's + ``StatusLoginIndex`` — but that index is projected ``INCLUDE`` with + ``userId``/``email``/``name``/``emailDomain`` and **not** ``roles``, so + a role-filtered count cannot be evaluated against it. The alternatives + are both worse than an honest None: widening the projection means + replacing a GSI on the users table (and CFN reporting green well + before the index is ACTIVE), while a filtered table scan is the + option the spec ranks last for exactly this reason. + + Nor is there a membership list to count instead: roles arrive as JWT + claims mapped at login, so nothing stores "who holds this role". + + None means "not estimated" and the UI must say so — it does **not** + mean zero. Even the ``"*"`` number is an estimate that moves as people + join, and §11 is explicit that no compliance reporting should be built + on it. + """ + if TARGET_EVERYONE not in (announcement.target_roles or []): + return None + try: + users = UserRepository() + if not users.enabled: + return None + return await users.count_active_users() + except Exception: + logger.warning( + "Could not estimate the targeted audience for %s", + announcement.announcement_id, + exc_info=True, + ) + return None + + # ── User-facing feed ───────────────────────────────────────────────── + + async def build_feed( + self, + *, + user_id: str, + user_roles: Sequence[str], + user_created_at: Optional[str] = None, + now: Optional[datetime] = None, + ) -> AnnouncementFeed: + """What this user should see, already filtered and capped (§D5, §D7). + + Two DynamoDB queries — the published announcements and this user's + acks. Both are tens of items, and neither is on the model call path + (§D12). + """ + announcements = await self._repo.list_announcements(states=["published"]) + acks = await self._repo.list_acks(user_id) + return compute_feed( + announcements=announcements, + user_roles=user_roles, + acks=acks, + now=now or datetime.now(timezone.utc), + user_created_at=user_created_at, + ) + + +_service: Optional[AnnouncementsService] = None + + +def get_announcements_service() -> AnnouncementsService: + global _service + if _service is None: + _service = AnnouncementsService(get_announcements_repository()) + return _service diff --git a/backend/src/apis/shared/announcements/visibility.py b/backend/src/apis/shared/announcements/visibility.py new file mode 100644 index 000000000..eb6dce8e9 --- /dev/null +++ b/backend/src/apis/shared/announcements/visibility.py @@ -0,0 +1,251 @@ +"""Who sees which announcement — the whole of it, in one pure function. + +Spec §D5: **the server computes visibility, the client renders what it is +handed.** The alternative — ship every announcement plus the ack list and +filter in the SPA — puts these rules in two languages, lets them drift, and +leaks announcements to users who were never targeted. + +So this module is deliberately dependency-free: no DynamoDB, no FastAPI, no +clock of its own. It takes the announcements, the user's roles, the user's +acks, and `now`, and returns exactly what the response should contain. That +makes the rules table-testable without moto, which is the point — this is +where the logic lives, so this is where the tests are. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Dict, Iterable, List, Optional, Sequence + +from apis.shared.timestamps import from_iso + +from .models import ( + SUPPRESSING_RANK, + TARGET_EVERYONE, + Announcement, + AnnouncementAck, +) + +logger = logging.getLogger(__name__) + +#: Tie-break order when more than one announcement wants the single banner or +#: modal slot. Not a semantic severity scale — it is the loudness the banner +#: colours already imply, used only to decide which of several eligible items +#: goes first. Lower sorts earlier. +SEVERITY_ORDER: Dict[str, int] = {"warning": 0, "success": 1, "info": 2} + +@dataclass +class VisibleAnnouncement: + """One announcement plus this user's relationship to it.""" + + announcement: Announcement + #: No ack at all for the current revision — drives the unread dot. + is_unread: bool + #: Acked an *earlier* revision but not this one. The panel says "Updated" + #: rather than plain "New", which is the whole reason acks are keyed by + #: revision (§D4). + is_updated: bool + + +@dataclass +class AnnouncementFeed: + """Everything ``GET /announcements`` returns, already filtered and capped.""" + + panel: List[VisibleAnnouncement] = field(default_factory=list) + banner: Optional[VisibleAnnouncement] = None + modal: Optional[VisibleAnnouncement] = None + unread_count: int = 0 + + def contains(self, announcement_id: str) -> bool: + """Whether this user may act on ``announcement_id`` at all. + + Membership is checked against the **panel**, which holds every eligible + announcement — the banner and modal are capped subsets of it, and a + dismissed item leaves those two but stays here. + """ + return any(v.announcement.announcement_id == announcement_id for v in self.panel) + + def get(self, announcement_id: str) -> Optional[Announcement]: + for v in self.panel: + if v.announcement.announcement_id == announcement_id: + return v.announcement + return None + + +def _parse(value: Optional[str], *, what: str) -> Optional[datetime]: + """Parse a stored timestamp, or None if it is absent or unusable. + + Never raises. Every caller here treats None as "no constraint", so a row + hand-written into DynamoDB with a broken date fails toward *showing* the + announcement. That is the recoverable direction — the spec makes the same + choice for a missing ``created_at`` (§D6), and for the same reason: an + announcement that appears when it should not is visible and fixable, while + one that silently never appears is neither. + """ + if not value: + return None + try: + return from_iso(value) + except (ValueError, TypeError): + logger.warning("Unparseable %s on announcement row: %r", what, value) + return None + + +def _targets_user(announcement: Announcement, roles: Sequence[str]) -> bool: + """§D9 — a display filter over ``User.roles``, never an RBAC grant. + + There is no ``can_access_*`` predicate behind this and nothing is inherited; + it decides what a notice board shows, not what a user may do. + """ + targets = announcement.target_roles or [TARGET_EVERYONE] + if TARGET_EVERYONE in targets: + return True + return bool(set(targets) & set(roles or [])) + + +def _joined_before_publication( + announcement: Announcement, user_created_at: Optional[datetime] +) -> bool: + """§D6 — new-user backfill suppression. + + A user who joined *after* an announcement was published does not see it: + without this, someone signing up eighteen months from now meets a queue of + modals about features that have always existed from their point of view. + + ``showToNewUsers`` is the deliberate exception, for a standing notice that + genuinely applies to everyone who ever joins. + + Fallback: no usable ``created_at`` means treat the user as **existing** and + show the announcement. + """ + if announcement.show_to_new_users: + return True + if user_created_at is None: + return True + published_at = _parse(announcement.publish_at, what="publishAt") + if published_at is None: + return True + return published_at > user_created_at + + +def _within_window(announcement: Announcement, now: datetime) -> bool: + published_at = _parse(announcement.publish_at, what="publishAt") + if published_at is not None and published_at > now: + return False + expires_at = _parse(announcement.expires_at, what="expiresAt") + if expires_at is not None and expires_at <= now: + return False + return True + + +def _sort_key_for_slot(visible: VisibleAnnouncement) -> tuple: + """Highest severity, then oldest ``publishAt`` (§D7). + + Oldest-first drains the queue in the order things happened; whatever loses + the slot stays eligible for the next page load. + """ + a = visible.announcement + published_at = _parse(a.publish_at, what="publishAt") + return ( + SEVERITY_ORDER.get(a.severity, len(SEVERITY_ORDER)), + published_at.timestamp() if published_at else 0.0, + a.announcement_id, + ) + + +def compute_feed( + *, + announcements: Iterable[Announcement], + user_roles: Sequence[str], + acks: Iterable[AnnouncementAck], + now: datetime, + user_created_at: Optional[str] = None, +) -> AnnouncementFeed: + """Filter and cap the announcements this user should see. + + Note where the ack check lands. The spec's filter chain lists it as step 5, + before the caps — but D1 and D2 are explicit that dismissing a loud surface + keeps the entry in the panel, so a literal reading would delete the durable + record the whole design is built around. The eligibility filter is + therefore steps 1–4, and the ack suppression applies **only when choosing + the banner and the modal**. + """ + # Every datetime this function compares comes from `from_iso`, which is + # always tz-aware. A naive `now` would therefore raise on the first + # comparison rather than mis-sort, so normalize it instead of trusting + # each caller. + if now.tzinfo is None: + now = now.replace(tzinfo=timezone.utc) + + joined_at = _parse(user_created_at, what="user createdAt") + + # Highest rank this user has recorded per (announcement, revision). + ranks: Dict[tuple, int] = {} + revisions_seen: Dict[str, set] = {} + for ack in acks: + key = (ack.announcement_id, int(ack.revision)) + ranks[key] = max(ranks.get(key, 0), int(ack.action_rank)) + revisions_seen.setdefault(ack.announcement_id, set()).add(int(ack.revision)) + + eligible: List[VisibleAnnouncement] = [] + for a in announcements: + if a.state != "published": + continue + if not _within_window(a, now): + continue + if not _targets_user(a, user_roles): + continue + if not _joined_before_publication(a, joined_at): + continue + + current = (a.announcement_id, int(a.revision)) + acked_current = current in ranks + acked_earlier = any( + r < int(a.revision) for r in revisions_seen.get(a.announcement_id, ()) + ) + eligible.append( + VisibleAnnouncement( + announcement=a, + is_unread=not acked_current, + is_updated=not acked_current and acked_earlier, + ) + ) + + # The panel is uncapped and newest-first — it is a list, and a list of five + # is fine. + eligible.sort( + key=lambda v: ( + _parse(v.announcement.publish_at, what="publishAt") + or datetime.min.replace(tzinfo=timezone.utc), + v.announcement.announcement_id, + ), + reverse=True, + ) + + def _unsuppressed(surface: str) -> List[VisibleAnnouncement]: + return [ + v + for v in eligible + if surface in v.announcement.surfaces + and ranks.get( + (v.announcement.announcement_id, int(v.announcement.revision)), 0 + ) + < SUPPRESSING_RANK + ] + + banner_candidates = sorted(_unsuppressed("banner"), key=_sort_key_for_slot) + # `requiresAck` first, so a blocking notice is never queued behind an + # informational one. + modal_candidates = sorted( + _unsuppressed("modal"), + key=lambda v: (not v.announcement.requires_ack, *_sort_key_for_slot(v)), + ) + + return AnnouncementFeed( + panel=eligible, + banner=banner_candidates[0] if banner_candidates else None, + modal=modal_candidates[0] if modal_candidates else None, + unread_count=sum(1 for v in eligible if v.is_unread), + ) diff --git a/backend/src/apis/shared/auth_providers/models.py b/backend/src/apis/shared/auth_providers/models.py index 23fd59c05..9d45d9223 100644 --- a/backend/src/apis/shared/auth_providers/models.py +++ b/backend/src/apis/shared/auth_providers/models.py @@ -47,8 +47,8 @@ class AuthProvider: logo_url: Optional[str] = None button_color: Optional[str] = None # Metadata - created_at: str = field(default_factory=lambda: utc_now_iso()) - updated_at: str = field(default_factory=lambda: utc_now_iso()) + created_at: str = field(default_factory=utc_now_iso) + updated_at: str = field(default_factory=utc_now_iso) created_by: Optional[str] = None # Cognito federated identity provider name cognito_provider_name: Optional[str] = None diff --git a/backend/src/apis/shared/costs/calculator.py b/backend/src/apis/shared/costs/calculator.py index 37f09b83c..788e64e0e 100644 --- a/backend/src/apis/shared/costs/calculator.py +++ b/backend/src/apis/shared/costs/calculator.py @@ -69,6 +69,11 @@ def calculate_message_cost( # - cacheReadInputTokens: tokens read from cache # - cacheWriteInputTokens: tokens written to cache # Total input = inputTokens + cacheReadInputTokens + cacheWriteInputTokens + # + # That is the Bedrock Converse convention. The OpenAI family reports an + # *inclusive* inputTokens instead, so its usage is rewritten to this + # shape at the model seam — see apis/shared/models/usage_normalization.py. + # Feeding raw OpenAI usage in here bills every cached token twice. # Calculate costs (per million tokens) input_cost = (input_tokens / 1_000_000) * input_price diff --git a/backend/src/apis/shared/feature_flags.py b/backend/src/apis/shared/feature_flags.py index ded46be9a..5681a5e6c 100644 --- a/backend/src/apis/shared/feature_flags.py +++ b/backend/src/apis/shared/feature_flags.py @@ -151,3 +151,76 @@ def agent_marketplace_enabled() -> bool: is now this flag alone. """ return os.environ.get("AGENT_MARKETPLACE_ENABLED", "").strip().lower() != "false" + + +def mid_turn_steering_enabled() -> bool: + """Whether a follow-up may be injected into a turn that is still running. + + Covers the lease-row steering inbox, the runtime ``SteeringHook`` that + injects at each tool boundary, the app-api ``/sessions/{id}/steer`` + endpoint, and the ``steering_applied`` SSE event (see + ``docs/specs/mid-turn-steering.md``). **Default ON with a kill switch** + (house style, mirroring ``scheduled_runs_enabled``): unset or empty + resolves to enabled; only the literal ``"false"`` (case-insensitive) + disables. + + While off, the hook is still registered but returns immediately, the steer + endpoint 404s, and the SPA never POSTs — leaving exactly PR #916's + behaviour, where a follow-up typed mid-stream is queued in the composer + and flushed on the turn's falling edge. That fallback is permanent, not + transitional: a turn that calls no tools has no boundary to inject at. + """ + return os.environ.get("MID_TURN_STEERING_ENABLED", "").strip().lower() != "false" + + +def announcements_enabled() -> bool: + """Whether the feature-announcement system is enabled for this environment. + + Covers the admin authoring surface (``/admin/announcements``) today, and + the user-facing ``GET /announcements`` + ack endpoint and the panel / + banner / modal surfaces as those land. **Default ON with a kill switch** + (house style, mirroring ``scheduled_runs_enabled``): unset or empty + resolves to enabled; only the literal ``"false"`` (case-insensitive) + disables. + + While off, the admin router is unmounted so the surface 404s; the data and + code remain intact. There is no separate RBAC capability on this axis — + *who* may author is the delegable ``admin.announcements`` scope, and *who + sees* a published announcement is the announcement's own ``targetRoles`` + display filter (which is deliberately **not** an RBAC grant; see + ``docs/specs/feature-announcements.md`` §D9). + """ + return os.environ.get("ANNOUNCEMENTS_ENABLED", "").strip().lower() != "false" + + +def artifact_share_inbox_enabled() -> bool: + """Whether a recipient can *discover* artifacts shared with them. + + Covers the ``GET /shared-artifacts`` inbox endpoint and, through it, + the library page's "Shared with you" tab. **Default OFF, opt-in** + (the deferred-feature pattern, mirroring the long-deleted + ``FINE_TUNING_ENABLED``): only the literal ``"true"`` + (case-insensitive) enables it. Every other flag in this module ships + default-on with a kill switch; this one is deliberately the other + way round, because the surface it gates lands before the product + decision about it does. + + ############################################################ + # This flag gates the READ ONLY. The recipient fan-out rows the + # inbox reads are written UNCONDITIONALLY, by every share write, + # whether or not this is on. + # + # That asymmetry is the whole point. If the writes were gated too, + # turning this on would expose an inbox missing every share created + # while it was off — a wrong answer rather than an empty one, and + # one nobody can see is wrong. Writing the pointer rows regardless + # costs one small row per recipient and makes the flip complete and + # instant, with no backfill to sequence. + # + # So: do not "optimise" the write path by wrapping it in this flag. + ############################################################ + """ + return ( + os.environ.get("ARTIFACT_SHARE_INBOX_ENABLED", "").strip().lower() + == "true" + ) diff --git a/backend/src/apis/shared/files/workspace.py b/backend/src/apis/shared/files/workspace.py index d72c5627d..f230fa927 100644 --- a/backend/src/apis/shared/files/workspace.py +++ b/backend/src/apis/shared/files/workspace.py @@ -26,7 +26,7 @@ import re import uuid from datetime import datetime, timezone -from typing import Any, Dict, List, Optional +from typing import Any, Dict, Optional import boto3 from botocore.config import Config diff --git a/backend/src/apis/shared/harness/governance.py b/backend/src/apis/shared/harness/governance.py index a5abadc94..0a286a186 100644 --- a/backend/src/apis/shared/harness/governance.py +++ b/backend/src/apis/shared/harness/governance.py @@ -38,7 +38,7 @@ import logging import os from datetime import datetime, timezone -from typing import Any, Dict, Optional +from typing import Any, Optional import boto3 diff --git a/backend/src/apis/shared/models/bedrock_responses.py b/backend/src/apis/shared/models/bedrock_responses.py new file mode 100644 index 000000000..e08ea4d3c --- /dev/null +++ b/backend/src/apis/shared/models/bedrock_responses.py @@ -0,0 +1,384 @@ +"""Shared ``bedrock-runtime`` OpenAI Responses model construction. + +The second OpenAI-compatible Bedrock surface, alongside Bedrock Mantle +(:mod:`apis.shared.models.mantle`). Both speak the OpenAI wire protocol with a +short-term bearer token; they differ in host, IAM, model-id shape and — the +reason this module exists — **prompt caching**. + +Why this transport at all +------------------------- + +GPT-5.6 supports prompt caching only on the **Responses API**, on either +endpoint. Converse is the tempting path — it drops into the existing +``BedrockModel`` plumbing with SigV4 and no new auth — and it is the one path +with *zero* caching. At Sol's published rates a ~30k stable prefix costs +~$0.13/turn on Converse against ~$0.013 on a Responses cache hit. + +``bedrock-runtime`` over Mantle-Responses because it adds CRIS, invocation +logging, CloudWatch metrics and Cost Explorer itemization, and Global CRIS is +cheaper for this family. We give up server-side tool use (unused) and In-Region +inference (not offered here for this model anyway). + +How it differs from the Mantle builder +-------------------------------------- + +Strands' ``bedrock_mantle_config`` hardcodes the Mantle host +(``models/_openai_bedrock.py``) and *rejects* a caller-supplied ``base_url`` / +``api_key`` when set, so pointing at ``bedrock-runtime`` means not using that +config at all — plain ``client_args`` instead. + +That trade has one consequence worth naming: ``bedrock_mantle_config`` re-mints +its bearer token per request, while a static ``api_key`` in ``client_args`` +freezes at construction. Our microVMs live 18-50 minutes against a 12-hour +token cap, so a frozen token would work *by luck*. :func:`build_bedrock_responses_model` +instead returns a subclass overriding ``_resolve_client_args()`` — which Strands +calls per request — to mint fresh. A handful of lines that removes a class of +"worked in dev, expired in prod" failure. + +Usage semantics are normalized here as they are on the Mantle path: OpenAI's +``input_tokens`` is inclusive of both cache buckets, which every cost path we +own treats as disjoint. See :mod:`apis.shared.models.usage_normalization`. +""" + +import logging +import os +from typing import Any, Dict, Optional + +# The OpenAI Responses API's native param names are a property of the *API*, +# not of the transport, so this is the Mantle map aliased rather than copied — +# the two surfaces can never drift apart. +from .mantle import MANTLE_RESPONSES_PARAM_MAP as BEDROCK_RESPONSES_PARAM_MAP +from .usage_normalization import usage_normalized + +logger = logging.getLogger(__name__) + +__all__ = [ + "BEDROCK_RESPONSES_PARAM_MAP", + "BEDROCK_RUNTIME_OPENAI_PATH", + "EXPLICIT_CACHE_ENABLED_ENV", + "EXPLICIT_CACHE_OPTIONS", + "EXPLICIT_CACHE_TTL", + "apply_explicit_prompt_cache", + "build_bedrock_responses_model", + "build_prompt_cache_key", + "explicit_prompt_cache_enabled", + "get_bedrock_runtime_openai_base_url", +] + +# bedrock-runtime is a regional endpoint on amazonaws.com (Mantle is api.aws). +_BEDROCK_RUNTIME_HOST_TEMPLATE = "https://bedrock-runtime.{region}.amazonaws.com" + +# The OpenAI-compatible base path. Unlike Mantle — where the path varies by +# model family and the SDK derives it from the model id — bedrock-runtime +# serves every OpenAI-compatible model from this one path. +BEDROCK_RUNTIME_OPENAI_PATH = "/openai/v1" + +# Cross-Region inference profile prefixes. GPT-5.6 is not offered as an +# in-Region model on this endpoint, so a bare `openai.` id is a +# misconfiguration — but this is a warning, not a gate: a future model may +# well be served in-Region and should not need a code change to run. +_INFERENCE_PROFILE_PREFIXES = ("us.", "global.", "eu.", "apac.") + + +def get_bedrock_runtime_openai_base_url(region: Optional[str] = None) -> str: + """OpenAI-compatible base URL for ``bedrock-runtime`` in ``region``. + + Args: + region: AWS region. ``None`` -> ``AWS_REGION``. + + Returns: + e.g. ``https://bedrock-runtime.us-west-2.amazonaws.com/openai/v1``. + + Raises: + ValueError: When no region can be resolved. Deliberately loud: this + endpoint is regional and silently defaulting to some other region + produces an opaque auth failure at the first turn, not a clear one. + """ + resolved = _resolve_region(region) + return _BEDROCK_RUNTIME_HOST_TEMPLATE.format(region=resolved) + BEDROCK_RUNTIME_OPENAI_PATH + + +def _resolve_region(region: Optional[str]) -> str: + """Resolve the region for both the base URL and the token signature.""" + resolved = region or os.environ.get("AWS_REGION") + if not resolved: + raise ValueError( + "No AWS region available for the bedrock-runtime OpenAI endpoint. " + "Set AWS_REGION, or pin a region on the managed model." + ) + return resolved + + +def _warn_on_missing_inference_profile(model_id: str) -> None: + """Log when a model id names no cross-Region inference profile.""" + if not model_id.startswith(_INFERENCE_PROFILE_PREFIXES): + logger.warning( + "Model id %r names no inference profile (expected one of %s). " + "bedrock-runtime does not offer in-Region inference for the " + "GPT-5.6 family, so this will likely be rejected — record the " + "profile-prefixed id on the managed model.", + model_id, + ", ".join(_INFERENCE_PROFILE_PREFIXES), + ) + + +# ── Explicit prompt caching (GPT-5.6) ──────────────────────────────────────── +# +# ⛔ OPT-IN, DEFAULT OFF — measured as a pessimization on our workload. +# +# The plan was: mark where the reusable prefix ends, so a change in +# conversation history costs a *read* of the static prefix rather than a full +# re-write at the 1.25x premium. That reasoning had the counterfactual wrong. +# GPT-5.6's default **implicit** caching does not re-write history when it +# grows — it appends the delta — so a single breakpoint after the static +# prefix does not save a re-write. It stops the history being cached at all. +# +# Measured live on `us.openai.gpt-5.6-sol` (dev-ai, us-west-2, 8k static +# prefix, 5 turns, ~1.5k tokens of history growth per turn), priced at the +# Price List ratios (input 1x, cache read 0.1x, cache write 1.25x): +# +# uncached input cacheRead cacheWrite input-equivalents +# explicit 22,790 23,228 5,807 32,372 +# implicit 10 38,410 13,405 20,607 +# +# Explicit cost ~57% MORE. Under explicit the uncached input grew every turn +# (1,516 -> 7,600) while cacheRead stayed flat at 5,807; under implicit the +# whole growing conversation stayed cached. +# +# The code is kept because the placement, not the mechanism, is what failed — +# the API allows up to 4 breakpoints, and a scheme that also marks the end of +# history could plausibly beat implicit. Nobody should turn this on again +# without re-running `scripts/probe_gpt56_cache_rates.py --mode both +# --grow-history` and beating the implicit arm. +# +# Opt-in: only the literal string "true" enables it. +# +# ⚠️ Deliberately NOT wired into the CDK Runtime construct. +# `AWS::BedrockAgentCore::Runtime` caps EnvironmentVariables at 50 and +# `inference-agentcore-construct.ts` is AT that cap — a 51st entry fails +# CloudFormation *changeset validation*, i.e. after synth, tsc, jest and green +# CI (it broke the dev Platform Stack deploy on 2026-08-05). +EXPLICIT_CACHE_ENABLED_ENV = "BEDROCK_RESPONSES_EXPLICIT_CACHE_ENABLED" + +# Request-level cache controls. `ttl` is the string form of the same window +# the cache-status classifier measures gaps against +# (``OPENAI_RESPONSES_CACHE_TTL_SECONDS``); a test asserts the two agree so a +# change to one cannot silently diverge from the other. +EXPLICIT_CACHE_TTL = "30m" +EXPLICIT_CACHE_OPTIONS: Dict[str, str] = {"mode": "explicit", "ttl": EXPLICIT_CACHE_TTL} + +# Marks the end of the reusable prefix. Goes on a *content block*, not on the +# request — see the AWS explicit-prompt-caching guidance for GPT-5.6. +_CACHE_BREAKPOINT = {"mode": "explicit"} + + +def explicit_prompt_cache_enabled() -> bool: + """Whether to send explicit cache breakpoints on this transport. + + **Default OFF** — see the measurement above; explicit mode cost ~57% more + than the model's default implicit caching on a conversation with growing + history. Only the literal string ``"true"`` opts in. + + Read per call (no module-level caching) so tests and live config changes + behave predictably; the env read is negligible next to request assembly. + """ + return os.environ.get(EXPLICIT_CACHE_ENABLED_ENV, "").lower() == "true" + + +def build_prompt_cache_key( + system_prompt: Optional[str], + tool_specs: Optional[Any], +) -> str: + """Cache key for requests that share a prefix. + + Derived from the same fingerprints the prompt-cache observability layer + records on each call, so requests with an identical static prefix route to + one cache entry and any config change rotates the key *by construction* — + there is no separate list to keep in sync. + + Deliberately covers only the static prefix (system prompt + tool + definitions). Including conversation history would rotate the key every + turn, which is precisely the cache-busting this exists to prevent. + """ + from apis.shared.observability import fingerprint_canonical_json, fingerprint_text + + return f"{fingerprint_text(system_prompt)}:{fingerprint_canonical_json(tool_specs or [])}" + + +def apply_explicit_prompt_cache( + request: Dict[str, Any], + system_prompt: Optional[str], + tool_specs: Optional[Any], +) -> Dict[str, Any]: + """Stamp explicit cache controls onto a formatted Responses request. + + Strands emits the system prompt as the top-level ``instructions`` string, + but a breakpoint has to sit on a *content block*. So the instructions are + re-expressed as the ``developer`` message the AWS guidance shows, placed at + the head of ``input`` and carrying the breakpoint — which puts the cache + boundary exactly at the end of the static prefix (tools + system), before + any conversation history. + + Args: + request: The request dict from ``OpenAIResponsesModel._format_request``. + Mutated in place and returned. + system_prompt: This turn's system prompt. + tool_specs: This turn's tool specifications. + + Returns: + ``request``. + + Note: + With no system prompt there is no content block marking the end of a + static prefix, so this returns the request untouched and the model + keeps its default **implicit** caching. Switching to explicit mode with + a badly placed boundary would be worse than not switching at all. + """ + instructions = request.get("instructions") + if not instructions: + return request + + developer_message = { + "type": "message", + "role": "developer", + "content": [ + { + "type": "input_text", + "text": instructions, + "prompt_cache_breakpoint": dict(_CACHE_BREAKPOINT), + } + ], + } + request.pop("instructions", None) + existing_input = request.get("input") + request["input"] = [developer_message, *(existing_input or [])] + + # `prompt_cache_key` is a first-class SDK parameter; `prompt_cache_options` + # is not, so it rides `extra_body`. Merge rather than assign — a caller's + # `params` may already carry an extra_body. + request.setdefault("prompt_cache_key", build_prompt_cache_key(system_prompt, tool_specs)) + extra_body = dict(request.get("extra_body") or {}) + extra_body.setdefault("prompt_cache_options", dict(EXPLICIT_CACHE_OPTIONS)) + request["extra_body"] = extra_body + + return request + + +_model_cls: Optional[type] = None + + +def _bedrock_responses_model_cls() -> type: + """Build (once) the model class used for this transport. + + Two layers over Strands' ``OpenAIResponsesModel``: + + 1. a per-request bearer-token mint, because ``client_args`` is resolved + once at construction and our token is short-term; + 2. the OpenAI usage normalization every OpenAI-family model needs. + + Memoized so repeated agent builds reuse one type — keeps ``isinstance`` + stable and avoids leaking a class per turn. + """ + global _model_cls + if _model_cls is not None: + return _model_cls + + # Lazy import: strands is heavy, and apis.shared is imported broadly. + from strands.models import OpenAIResponsesModel + + class BedrockRuntimeResponsesModel(OpenAIResponsesModel): + """``OpenAIResponsesModel`` that re-mints its Bedrock bearer token per request.""" + + def __init__(self, bedrock_region: str, **kwargs: Any) -> None: + # Set before super().__init__ so a base-class call into + # _resolve_client_args() during construction still resolves. + self._bedrock_region = bedrock_region + super().__init__(**kwargs) + + def _resolve_client_args(self) -> Dict[str, Any]: + """Return client kwargs with a freshly minted bearer token. + + Strands calls this per request. The token is a presigned SigV4 + request that expires with the signing credentials (12h cap), so + minting here rather than at construction is what keeps a + long-lived model instance working. + """ + # Lazy import keeps boto3 off this module's import path. + from apis.shared.bedrock.bearer_token import generate_bedrock_bearer_token + + args = dict(super()._resolve_client_args()) + args["api_key"] = generate_bedrock_bearer_token(self._bedrock_region) + return args + + def _format_request( + self, + messages: Any, + tool_specs: Optional[Any] = None, + system_prompt: Optional[str] = None, + *args: Any, + **kwargs: Any, + ) -> Dict[str, Any]: + """Format the request, then mark where the reusable prefix ends. + + The three leading parameters are named because this override needs + two of them; ``tool_choice`` / ``model_state`` (and anything a + future SDK adds) ride ``*args`` / ``**kwargs`` untouched. Strands + calls this both positionally with five arguments and by keyword, + so both forms have to work. + """ + request = super()._format_request(messages, tool_specs, system_prompt, *args, **kwargs) + if not explicit_prompt_cache_enabled(): + return request + return apply_explicit_prompt_cache( + request, system_prompt=system_prompt, tool_specs=tool_specs + ) + + _model_cls = usage_normalized(BedrockRuntimeResponsesModel) + return _model_cls + + +def build_bedrock_responses_model( + model_id: str, + region: Optional[str] = None, + params: Optional[Dict[str, Any]] = None, +): + """Build a Strands Responses model targeting ``bedrock-runtime``. + + Args: + model_id: A cross-Region inference profile id, e.g. + ``us.openai.gpt-5.6-sol`` or ``global.openai.gpt-5.6-sol``. + region: AWS region for the endpoint and the token signature. ``None`` + -> ``AWS_REGION``. One resolved value drives both, so the URL and + the signature can never disagree. + params: Already-native Responses params (canonical names must be + pre-translated via :data:`BEDROCK_RESPONSES_PARAM_MAP`). Spread + verbatim into ``responses.create()`` by the SDK. + + Returns: + A configured Responses model reporting disjoint token usage. + + Raises: + ValueError: When no region can be resolved. + """ + resolved_region = _resolve_region(region) + _warn_on_missing_inference_profile(model_id) + + # base_url is fixed for the life of the model; only api_key is re-minted + # per request (see BedrockRuntimeResponsesModel._resolve_client_args). + # A placeholder api_key is supplied because the OpenAI client requires one + # at construction; it is replaced before any request goes out. + client_args: Dict[str, Any] = { + "base_url": _BEDROCK_RUNTIME_HOST_TEMPLATE.format(region=resolved_region) + + BEDROCK_RUNTIME_OPENAI_PATH, + "api_key": "placeholder-replaced-per-request", + } + + config: Dict[str, Any] = {"model_id": model_id} + if params: + config["params"] = params + + return _bedrock_responses_model_cls()( + bedrock_region=resolved_region, + client_args=client_args, + **config, + ) diff --git a/backend/src/apis/shared/models/managed_models.py b/backend/src/apis/shared/models/managed_models.py index 26a73668d..7994c2e1d 100644 --- a/backend/src/apis/shared/models/managed_models.py +++ b/backend/src/apis/shared/models/managed_models.py @@ -19,47 +19,94 @@ logger = logging.getLogger(__name__) +# Providers whose models prompt-cache by default when the field is unset. +_CACHING_DEFAULT_PROVIDERS = ('bedrock', 'bedrock-responses') + +# Providers where caching is not optional — see _resolve_supports_caching. +_CACHING_FORCED_PROVIDERS = ('bedrock-responses',) + + def _resolve_supports_caching(supports_caching: Optional[bool], provider: str) -> bool: """ Resolve the supports_caching value based on explicit setting or provider defaults. Args: supports_caching: Explicit value from model data (None if not set) - provider: The model provider (bedrock, openai, gemini) + provider: The model provider (bedrock, openai, gemini, mantle, + bedrock-responses) Returns: bool: Whether the model supports caching """ + normalized_provider = provider.lower() + + # On bedrock-responses caching is a fact, not a setting: it is implicit and + # server-side, and nothing we send turns it off. A stored False there would + # be untrue, and its only practical effect is that the cache rates get + # cleared — which prices cached tokens at $0.00 while the provider bills + # them in full. On a warm conversation nearly every input token is a cache + # read, so that is close to total under-reporting of the model's spend. + # + # Normalized rather than honored, exactly like `apiMode` on the same + # transport, so no client can persist the impossible state. + if normalized_provider in _CACHING_FORCED_PROVIDERS: + return True + if supports_caching is not None: return supports_caching - # Default behavior: Only Bedrock models support caching by default - # Admins can explicitly set this to False for Bedrock models that don't support it - return provider.lower() == 'bedrock' + # Default behavior: Bedrock Converse models, and the bedrock-runtime + # Responses transport. Admins can explicitly set this to False for Bedrock + # models that don't support it. + # + # Deliberately NOT 'mantle': Mantle hosts open-weight models that mostly + # don't cache, and openai.gpt-5.4 there is implicit-only with no write fee. + return normalized_provider in _CACHING_DEFAULT_PROVIDERS + + +# The two OpenAI-compatible Bedrock surfaces. Both ride the OpenAI wire +# protocol with a short-term bearer token; they differ in host, IAM and +# model-id shape. The `apiMode` / `region` fields are meaningful on both, +# which is why they are wire-named generically even though the Python +# attributes still carry the historical `mantle_` prefix. +_OPENAI_SURFACE_PROVIDERS = ('mantle', 'bedrock-responses') def _resolve_mantle_api_mode(api_mode: Optional[str], provider: str) -> Optional[str]: - """Resolve the Bedrock Mantle API surface for a model. + """Resolve the OpenAI-compatible API surface for a model. + + On ``provider == 'mantle'`` this selects Chat Completions vs the Responses + API — a per-model fact Mantle exposes no API to discover. Defaults to + ``'chat'`` when unset. + + On ``provider == 'bedrock-responses'`` the answer is fixed: that transport + exists precisely because GPT-5.6 serves prompt caching only over the + Responses API, so an admin cannot select Chat Completions there. Anything + stored is normalized to ``'responses'`` rather than honored — a model + silently downgraded to Chat Completions would lose caching, which is the + whole point of the transport, and would fail quietly rather than loudly. - Only meaningful for ``provider == 'mantle'`` — it selects Chat Completions - vs the Responses API, a per-model fact Mantle exposes no API to discover. - Defaults to ``'chat'`` for Mantle models when unset; ``None`` for every - other provider (the field is inert there). + ``None`` for every other provider (the field is inert there). """ - if provider.lower() != 'mantle': + normalized_provider = provider.lower() + if normalized_provider == 'bedrock-responses': + return 'responses' + if normalized_provider != 'mantle': return None mode = (api_mode or '').lower() return mode if mode in ('chat', 'responses') else 'chat' def _resolve_mantle_region(region: Optional[str], provider: str) -> Optional[str]: - """Resolve the Bedrock Mantle region override for a model. + """Resolve the region override for an OpenAI-compatible Bedrock surface. - Only meaningful for ``provider == 'mantle'`` — pins inference to the region - hosting the model, independent of the app's region. ``None`` (fall back to - the app's region at agent-build time) when unset or for other providers. + Meaningful on both ``'mantle'`` and ``'bedrock-responses'`` — pins + inference to a specific region independent of the app's region, and drives + both the endpoint host and the region the bearer token is signed for. + ``None`` (fall back to the app's region at agent-build time) when unset or + for other providers. """ - if provider.lower() != 'mantle': + if provider.lower() not in _OPENAI_SURFACE_PROVIDERS: return None return region or None diff --git a/backend/src/apis/shared/models/mantle.py b/backend/src/apis/shared/models/mantle.py index 1121b615e..c2e6fd543 100644 --- a/backend/src/apis/shared/models/mantle.py +++ b/backend/src/apis/shared/models/mantle.py @@ -18,6 +18,8 @@ from enum import Enum from typing import Any, Dict, Optional +from .usage_normalization import usage_normalized + class MantleApiMode(str, Enum): """OpenAI-compatible API surface a Bedrock Mantle model speaks. @@ -121,7 +123,12 @@ def build_mantle_model( if region: bedrock_mantle_config["region"] = region - model_cls = ( + # Wrapped so the model reports Bedrock-Converse token-bucket semantics: + # OpenAI's `input_tokens` is inclusive of the cache buckets, and Strands + # drops `cache_write_tokens` outright. Applied here — the single OpenAI- + # family construction seam both consumers share — so no downstream reader + # of the usage dict needs to know which provider produced it. + model_cls = usage_normalized( OpenAIResponsesModel if api_mode == MantleApiMode.RESPONSES else OpenAIModel diff --git a/backend/src/apis/shared/models/models.py b/backend/src/apis/shared/models/models.py index 3457e195c..6b861efa5 100644 --- a/backend/src/apis/shared/models/models.py +++ b/backend/src/apis/shared/models/models.py @@ -204,18 +204,22 @@ class ManagedModelCreate(BaseModel): mantle_api_mode: Optional[str] = Field( None, alias="apiMode", - description="Bedrock Mantle API surface (provider='mantle' only): 'chat' " - "(OpenAI Chat Completions, the default) or 'responses' (OpenAI " - "Responses API — required by models that don't serve Chat " - "Completions, e.g. openai.gpt-5.x). Ignored for other providers." + description="OpenAI-compatible API surface: 'chat' (OpenAI Chat " + "Completions, the default) or 'responses' (OpenAI Responses " + "API — required by models that don't serve Chat Completions, " + "e.g. openai.gpt-5.x). Selectable for provider='mantle'; " + "forced to 'responses' for provider='bedrock-responses', " + "which exists because GPT-5.6 caches only over that API. " + "Ignored for other providers." ) mantle_region: Optional[str] = Field( None, alias="region", - description="Bedrock Mantle region override (provider='mantle' only): pins " - "inference to the region hosting the model (e.g. 'us-east-1'), " - "independent of where the app runs. Empty -> the app's region. " - "Ignored for other providers." + description="Region override for an OpenAI-compatible Bedrock surface " + "(provider='mantle' or 'bedrock-responses'): pins inference to " + "the region hosting the model (e.g. 'us-east-1'), independent " + "of where the app runs, and signs the bearer token for it. " + "Empty -> the app's region. Ignored for other providers." ) mantle_endpoint_path: Optional[str] = Field( None, @@ -292,14 +296,16 @@ class ManagedModelUpdate(BaseModel): mantle_api_mode: Optional[str] = Field( None, alias="apiMode", - description="Bedrock Mantle API surface (provider='mantle' only): 'chat' " - "or 'responses'. Ignored for other providers." + description="OpenAI-compatible API surface: 'chat' or 'responses'. " + "Selectable for provider='mantle'; forced to 'responses' for " + "provider='bedrock-responses'. Ignored for other providers." ) mantle_region: Optional[str] = Field( None, alias="region", - description="Bedrock Mantle region override (provider='mantle' only). " - "Empty -> the app's region. Ignored for other providers." + description="Region override for an OpenAI-compatible Bedrock surface " + "(provider='mantle' or 'bedrock-responses'). Empty -> the " + "app's region. Ignored for other providers." ) mantle_endpoint_path: Optional[str] = Field( None, @@ -382,14 +388,17 @@ class ManagedModel(BaseModel): mantle_api_mode: Optional[str] = Field( None, alias="apiMode", - description="Bedrock Mantle API surface (provider='mantle' only): 'chat' " - "(default) or 'responses'. Ignored for other providers." + description="OpenAI-compatible API surface: 'chat' (default) or " + "'responses'. Selectable for provider='mantle'; forced to " + "'responses' for provider='bedrock-responses'. Ignored for " + "other providers." ) mantle_region: Optional[str] = Field( None, alias="region", - description="Bedrock Mantle region override (provider='mantle' only). " - "Empty -> the app's region. Ignored for other providers." + description="Region override for an OpenAI-compatible Bedrock surface " + "(provider='mantle' or 'bedrock-responses'). Empty -> the " + "app's region. Ignored for other providers." ) mantle_endpoint_path: Optional[str] = Field( None, diff --git a/backend/src/apis/shared/models/usage_normalization.py b/backend/src/apis/shared/models/usage_normalization.py new file mode 100644 index 000000000..8a4b766f1 --- /dev/null +++ b/backend/src/apis/shared/models/usage_normalization.py @@ -0,0 +1,241 @@ +"""Provider-aware token-usage normalization. + +Every cost and context-size path we own assumes the **Bedrock Converse** +convention: ``inputTokens``, ``cacheReadInputTokens`` and +``cacheWriteInputTokens`` are three *disjoint* buckets whose sum is the call's +total input. ``CostCalculator.calculate_message_cost`` documents that contract +and prices each bucket at its own rate; the context-attribution sum in the +stream coordinator adds all three to get "current context size". + +The OpenAI family reports the opposite convention — ``input_tokens`` is +**inclusive**. Per AWS's GPT-5.6 prompt-caching guidance the identity is:: + + input_tokens = cached_tokens + cache_write_tokens + non-cached remainder + +Strands passes ``input_tokens`` straight through as ``inputTokens`` while +*also* reporting ``cacheReadInputTokens`` +(``strands/models/openai_responses.py`` and ``strands/models/openai.py``), so +without normalization every cached token is billed twice: once at the full +input rate and once at the cache-read rate. With cache writes it is worse — +a written token would be billed at the input rate *plus* the 1.25x write +premium. + +This module fixes both halves, once, at the earliest seam we control: + +1. :func:`normalize_usage` restores disjointness for the OpenAI family and + leaves Bedrock usage untouched. +2. :func:`usage_normalized` wraps a Strands OpenAI-family model class so the + normalization is applied while the model formats its ``metadata`` chunk — + ahead of the cost calculator, the metadata writers, the prompt-cache + observability layer and the SSE stream. Downstream consumers keep reading + plain Converse-shaped usage dicts and need no provider awareness. + +The wrapper is also where ``cache_write_tokens`` re-enters the pipeline. +Strands never reads it off the Responses usage object, so +``cacheWriteInputTokens`` is structurally 0 for GPT-5.6 — which pins +``wastedUsd`` at $0 and makes the 1.25x write premium invisible, the same +blind spot that let the compaction spiral run unnoticed. An upstream patch is +in flight; until it lands (and on any older pin) this mapping is the only +source of the field. + +⚠️ :func:`normalize_usage` is **not idempotent** for the OpenAI family — it +subtracts. Apply it exactly once per usage payload, at the model seam. Do not +add a second call at a site that reads the usage dict. +""" + +import logging +from enum import Enum +from typing import Any, Dict, Mapping, Optional + +logger = logging.getLogger(__name__) + + +class UsageProvider(str, Enum): + """Token-accounting convention a model's usage payload follows. + + ``BEDROCK`` — Converse semantics: the three input buckets are already + disjoint. Also the correct value for any provider that follows the same + convention; normalization is a no-op. + + ``OPENAI`` — Chat Completions *and* the Responses API: ``inputTokens`` is + inclusive of the cache buckets and must have them subtracted out. + """ + + BEDROCK = "bedrock" + OPENAI = "openai" + + +def normalize_usage( + usage: Mapping[str, Any], + provider: UsageProvider, +) -> Dict[str, Any]: + """Return a copy of ``usage`` whose input buckets are disjoint. + + Args: + usage: Converse-shaped usage dict (``inputTokens``, ``outputTokens``, + ``totalTokens``, and optionally ``cacheReadInputTokens`` / + ``cacheWriteInputTokens``). + provider: The convention ``usage`` currently follows. + + Returns: + A new dict. For :attr:`UsageProvider.BEDROCK` it is an unmodified copy. + For :attr:`UsageProvider.OPENAI`, ``inputTokens`` has the cache buckets + subtracted out, clamped at 0. + + Note: + Not idempotent for the OpenAI family — see the module docstring. + """ + normalized: Dict[str, Any] = dict(usage) + + if provider != UsageProvider.OPENAI: + return normalized + + cache_read = normalized.get("cacheReadInputTokens") or 0 + cache_write = normalized.get("cacheWriteInputTokens") or 0 + if not cache_read and not cache_write: + return normalized + + input_tokens = normalized.get("inputTokens") or 0 + # Clamp: a provider bug that reports cache buckets larger than the + # inclusive total (it has happened upstream) must not produce a negative + # bucket that the calculator would silently credit against the bill. + normalized["inputTokens"] = max(0, input_tokens - cache_read - cache_write) + return normalized + + +def openai_cache_write_tokens(usage_obj: Any) -> Optional[int]: + """Read ``cache_write_tokens`` off a raw OpenAI usage object. + + GPT-5.6 reports it at ``usage.input_tokens_details.cache_write_tokens``. + The OpenAI SDK's models permit extra fields, so on an SDK pin that predates + the field it still arrives as a passthrough attribute. The top level is + checked as well because some OpenAI-compatible gateways hoist it there. + + Args: + usage_obj: The provider usage object (``ResponseUsage`` or compatible). + + Returns: + The token count, or ``None`` when the field is absent or not an int. + ``False`` / ``bool`` values are rejected — ``bool`` is an ``int`` + subclass and would otherwise coerce to 0/1. + """ + if usage_obj is None: + return None + + details = getattr(usage_obj, "input_tokens_details", None) + for source in (details, usage_obj): + if source is None: + continue + value = getattr(source, "cache_write_tokens", None) + if isinstance(value, int) and not isinstance(value, bool): + return value + + return None + + +# Strands formats every provider chunk through one method per model class, but +# the two OpenAI-family classes disagree on its name: OpenAIModel exposes a +# public `format_chunk`, OpenAIResponsesModel a private `_format_chunk`. +_CHUNK_FORMATTER_NAMES = ("_format_chunk", "format_chunk") + +# Subclasses are memoized so repeated model construction reuses one type — +# keeps `isinstance` stable and avoids leaking a class per agent build. +_NORMALIZED_CLASSES: Dict[type, type] = {} + + +def _normalize_metadata_chunk(event: Mapping[str, Any], chunk: Any) -> Any: + """Rewrite a formatted ``metadata`` chunk into Converse usage semantics. + + Args: + event: The raw Strands chunk event; ``event["data"]`` is the provider + usage object, the only place ``cache_write_tokens`` survives. + chunk: The ``StreamEvent`` the base model produced for that event. + + Returns: + ``chunk``, mutated in place when it carried a usage payload. + """ + if not isinstance(chunk, dict): + return chunk + + metadata = chunk.get("metadata") + if not isinstance(metadata, dict): + return chunk + + usage = metadata.get("usage") + if not isinstance(usage, dict): + return chunk + + # Recover the field Strands drops, before disjointness is computed — the + # written tokens are part of the inclusive `input_tokens` and have to come + # out of it too, or they are billed at input + 1.25x write. + cache_write = openai_cache_write_tokens(event.get("data")) + if cache_write: + usage["cacheWriteInputTokens"] = cache_write + + usage.update(normalize_usage(usage, UsageProvider.OPENAI)) + return chunk + + +def usage_normalized(base_cls: Any) -> Any: + """Return a subclass of a Strands OpenAI-family model that reports disjoint usage. + + Args: + base_cls: ``OpenAIModel`` or ``OpenAIResponsesModel`` (or a subclass). + + Returns: + A memoized subclass whose chunk formatter normalizes usage. Anything + that is not a class is returned unchanged — ``unittest.mock.patch`` + replaces a class with a non-type, and production always passes a real + class. + + Raises: + TypeError: If the class exposes neither chunk-formatter name. Failing + loudly is deliberate: a silent fallthrough would double-bill every + OpenAI-family token with no symptom other than the bill. + """ + if not isinstance(base_cls, type): + return base_cls + + cached = _NORMALIZED_CLASSES.get(base_cls) + if cached is not None: + return cached + + formatter_name = next( + (name for name in _CHUNK_FORMATTER_NAMES if hasattr(base_cls, name)), + None, + ) + if formatter_name is None: + raise TypeError( + f"{base_cls.__name__} exposes neither " + f"{' nor '.join(_CHUNK_FORMATTER_NAMES)}; the Strands chunk-" + "formatting seam moved. OpenAI token usage cannot be normalized — " + "update apis/shared/models/usage_normalization.py." + ) + + def _formatter(self: Any, event: Dict[str, Any], **kwargs: Any) -> Any: + chunk = getattr(super(subclass, self), formatter_name)(event, **kwargs) + return _normalize_metadata_chunk(event, chunk) + + _formatter.__name__ = formatter_name + _formatter.__qualname__ = f"UsageNormalized{base_cls.__name__}.{formatter_name}" + _formatter.__doc__ = ( + "Format a chunk, then restore Bedrock-Converse token-bucket semantics." + ) + + subclass = type( + f"UsageNormalized{base_cls.__name__}", + (base_cls,), + { + formatter_name: _formatter, + "__doc__": ( + f"{base_cls.__name__} that reports disjoint token buckets.\n\n" + "See apis/shared/models/usage_normalization.py — OpenAI's " + "inclusive `input_tokens` is double-billed by our cost paths " + "otherwise, and Strands drops `cache_write_tokens` entirely." + ), + }, + ) + + _NORMALIZED_CLASSES[base_cls] = subclass + logger.debug("Installed usage normalization on %s", base_cls.__name__) + return subclass diff --git a/backend/src/apis/shared/oauth/models.py b/backend/src/apis/shared/oauth/models.py index 7809e09cc..825840604 100644 --- a/backend/src/apis/shared/oauth/models.py +++ b/backend/src/apis/shared/oauth/models.py @@ -137,8 +137,8 @@ class OAuthProvider: # and an export target (e.g. a combined-scope Drive connector). Validated # in the admin route for the same reason as above. export_target_adapter_id: Optional[str] = None - created_at: str = field(default_factory=lambda: utc_now_iso()) - updated_at: str = field(default_factory=lambda: utc_now_iso()) + created_at: str = field(default_factory=utc_now_iso) + updated_at: str = field(default_factory=utc_now_iso) @property def scopes_hash(self) -> str: diff --git a/backend/src/apis/shared/observability/__init__.py b/backend/src/apis/shared/observability/__init__.py index 958329746..47a753819 100644 --- a/backend/src/apis/shared/observability/__init__.py +++ b/backend/src/apis/shared/observability/__init__.py @@ -2,6 +2,9 @@ from apis.shared.observability.prompt_cache import ( CACHE_TTL_SECONDS, + DEFAULT_CACHE_TTL_SECONDS, + OPENAI_RESPONSES_CACHE_TTL_SECONDS, + cache_ttl_seconds_for, PARTIAL_MISS_WRITE_READ_RATIO, PROMPT_CACHE_OBSERVABILITY_ENABLED_ENV, prompt_cache_observability_enabled, @@ -18,6 +21,9 @@ __all__ = [ "CACHE_TTL_SECONDS", + "DEFAULT_CACHE_TTL_SECONDS", + "OPENAI_RESPONSES_CACHE_TTL_SECONDS", + "cache_ttl_seconds_for", "PARTIAL_MISS_WRITE_READ_RATIO", "PROMPT_CACHE_OBSERVABILITY_ENABLED_ENV", "prompt_cache_observability_enabled", diff --git a/backend/src/apis/shared/observability/prompt_cache.py b/backend/src/apis/shared/observability/prompt_cache.py index 993261503..4def66ec3 100644 --- a/backend/src/apis/shared/observability/prompt_cache.py +++ b/backend/src/apis/shared/observability/prompt_cache.py @@ -50,10 +50,66 @@ def prompt_cache_observability_enabled() -> bool: """ return os.environ.get(PROMPT_CACHE_OBSERVABILITY_ENABLED_ENV, "").lower() != "false" -# Bedrock prompt-cache TTL (sliding, seconds). A gap between consecutive -# model calls larger than this means the cache entry legitimately expired — -# the re-write was unavoidable. +# Prompt-cache TTL (sliding, seconds). A gap between consecutive model calls +# larger than this means the cache entry legitimately expired — the re-write +# was unavoidable. +# +# The TTL is a property of the MODEL, not of this module. Bedrock/Anthropic +# prompt caching is a ~5-minute sliding window; the OpenAI Responses API on +# `bedrock-runtime` holds entries for 30 minutes. Treating every model as +# 5 minutes is wrong by 6x for GPT-5.6, and wrong in the direction that +# HIDES waste: a gap inside the real TTL gets called `miss_ttl_expired` +# (unavoidable) instead of `miss_avoidable`, and `partial_miss` — whose gate +# is `gap <= ttl` — silently degrades to `hit`. Both suppress `wastedUsd`, +# which is the metric that exists to catch exactly this. +# +# `CACHE_TTL_SECONDS` is retained as the default (and for importers) but +# callers that know the model should pass `ttl_seconds` from +# :func:`cache_ttl_seconds_for` instead. CACHE_TTL_SECONDS = 300 +DEFAULT_CACHE_TTL_SECONDS = CACHE_TTL_SECONDS + +# OpenAI Responses API on bedrock-runtime: entries live 30 minutes. +# Corroborated by the Price List API's own SKU naming for these models — +# the cache-write usage types are `...-cache-write-tokens-30m-...`. +OPENAI_RESPONSES_CACHE_TTL_SECONDS = 1800 + +# Providers whose models use the OpenAI Responses 30-minute TTL. +# +# Deliberately NOT the whole OpenAI family. `mantle` serves `openai.gpt-5.4` +# with implicit-only caching whose retention AWS does not document as 30m, +# and guessing there would re-introduce the same class of error in the other +# direction (over-reporting waste). Only the model whose TTL is documented +# gets the longer window. +_OPENAI_RESPONSES_TTL_PROVIDERS = frozenset({"bedrock-responses"}) + + +def cache_ttl_seconds_for( + provider: Optional[str] = None, + model_id: Optional[str] = None, +) -> int: + """Prompt-cache TTL in seconds for the model that served a call. + + Args: + provider: The model's registered provider (``bedrock``, ``mantle``, + ``bedrock-responses``, ...). The authoritative signal. + model_id: Model id, used only as a fallback when the provider is + absent on older metadata rows written before the field existed. + + Returns: + The TTL to measure this call's gap against. Falls back to + :data:`DEFAULT_CACHE_TTL_SECONDS` for anything unrecognized — + under-reporting waste rather than inventing it. + """ + if provider and provider.lower() in _OPENAI_RESPONSES_TTL_PROVIDERS: + return OPENAI_RESPONSES_CACHE_TTL_SECONDS + if not provider and model_id: + # Historical rows: infer from the inference-profile-prefixed id the + # bedrock-runtime transport requires (us./global. + openai.gpt-5.6). + normalized = model_id.lower() + if "openai.gpt-5.6" in normalized: + return OPENAI_RESPONSES_CACHE_TTL_SECONDS + return DEFAULT_CACHE_TTL_SECONDS # How many times larger than the cache *read* a cache *write* has to be before # a nonzero read stops meaning "the prefix was cached" and starts meaning "a @@ -125,6 +181,7 @@ def classify_cache_status( previous_call_exists: bool, gap_seconds: Optional[float], previous_cached_prefix_tokens: Optional[int] = None, + ttl_seconds: int = DEFAULT_CACHE_TTL_SECONDS, ) -> CacheStatus: """Classify one model call's cache outcome. @@ -138,6 +195,11 @@ def classify_cache_status( token total, or None when unknown. Zero means the previous call was uncached (e.g. prompt below the model's minimum cacheable prefix), so no cache entry existed for this call to read. + ttl_seconds: The serving model's prompt-cache TTL. Defaults to the + Bedrock/Anthropic 5-minute window; callers that know the model + should pass :func:`cache_ttl_seconds_for`. A TTL shorter than the + model's real one silently suppresses both ``partial_miss`` and + ``miss_avoidable``, and with them ``wastedUsd``. """ if cache_read_tokens > 0: # A read proves *something* was cached — but not that the prefix was. @@ -155,7 +217,7 @@ def classify_cache_status( previous_call_exists and cache_write_tokens > PARTIAL_MISS_WRITE_READ_RATIO * cache_read_tokens and gap_seconds is not None - and gap_seconds <= CACHE_TTL_SECONDS + and gap_seconds <= ttl_seconds ): return CacheStatus.PARTIAL_MISS return CacheStatus.HIT @@ -169,7 +231,7 @@ def classify_cache_status( # (typically the first prompt to cross the minimum cacheable length), # not a miss of any kind. return CacheStatus.FIRST_WRITE - if gap_seconds is None or gap_seconds > CACHE_TTL_SECONDS: + if gap_seconds is None or gap_seconds > ttl_seconds: return CacheStatus.MISS_TTL_EXPIRED return CacheStatus.MISS_AVOIDABLE diff --git a/backend/src/apis/shared/rbac/admin_scopes.py b/backend/src/apis/shared/rbac/admin_scopes.py index a5b79215e..d2ba36356 100644 --- a/backend/src/apis/shared/rbac/admin_scopes.py +++ b/backend/src/apis/shared/rbac/admin_scopes.py @@ -139,6 +139,13 @@ class AdminScope: group=GROUP_CUSTOMIZATION, description="Manage the custom links shown in the user menu.", ), + AdminScope( + id="admin.announcements", + label="Announcements", + group=GROUP_CUSTOMIZATION, + description="Author and publish feature announcements shown to all users.", + delegable=True, + ), # ── Non-delegable ──────────────────────────────────────────────────────── # Present in the registry so they are named, documented, and covered by the # route-coverage test — but rejected by validation if anyone tries to grant diff --git a/backend/src/apis/shared/scheduled_prompts/service.py b/backend/src/apis/shared/scheduled_prompts/service.py index d33112507..aae84b568 100644 --- a/backend/src/apis/shared/scheduled_prompts/service.py +++ b/backend/src/apis/shared/scheduled_prompts/service.py @@ -55,8 +55,6 @@ def __repr__(self) -> str: # pragma: no cover - debug aid only DEFAULT_MAX_SCHEDULES_PER_USER = 20 -_WEEKDAY_CADENCES = {"weekday"} # Monday-Friday - class ScheduledPromptLimitExceeded(Exception): """Raised when a user already has the maximum number of schedules.""" diff --git a/backend/src/apis/shared/sessions/metadata.py b/backend/src/apis/shared/sessions/metadata.py index 61f745252..93ae58864 100644 --- a/backend/src/apis/shared/sessions/metadata.py +++ b/backend/src/apis/shared/sessions/metadata.py @@ -405,6 +405,7 @@ def _derive_cache_observability( from apis.shared.observability import ( CacheStatus, + cache_ttl_seconds_for, classify_cache_status, compute_wasted_usd, prompt_cache_observability_enabled, @@ -497,12 +498,24 @@ def _cached_prefix_of(row: Optional[Dict[str, Any]]) -> Optional[int]: classify_gap = None prev_cached_prefix = _cached_prefix_of(prev_row) + # The TTL is the serving model's, not a module constant: Bedrock is a + # ~5-minute sliding window, the OpenAI Responses API on bedrock-runtime + # holds entries for 30 minutes. Using 5 minutes for the latter calls a + # live entry expired, which downgrades `partial_miss` to `hit` and + # `miss_avoidable` to `miss_ttl_expired` — both zeroing `wastedUsd`. + model_info = message_metadata.model_info + ttl_seconds = cache_ttl_seconds_for( + provider=getattr(model_info, "provider", None), + model_id=getattr(model_info, "model_id", None), + ) + status = classify_cache_status( cache_read_tokens=cache_read, cache_write_tokens=cache_write, previous_call_exists=prev_row is not None, gap_seconds=classify_gap, previous_cached_prefix_tokens=prev_cached_prefix, + ttl_seconds=ttl_seconds, ) wasted_usd = compute_wasted_usd( cache_status=status, diff --git a/backend/src/apis/shared/sessions/models.py b/backend/src/apis/shared/sessions/models.py index 8bd4f0634..47235c332 100644 --- a/backend/src/apis/shared/sessions/models.py +++ b/backend/src/apis/shared/sessions/models.py @@ -10,6 +10,8 @@ from pydantic import BaseModel, ConfigDict, Field +from apis.shared.sessions.session_lease import STEER_QUEUE_MAX_CHARS + class VisualDisplayState(BaseModel): """Display state for a single promoted visual (inline tool result)""" @@ -369,6 +371,50 @@ class SessionInterruptRequest(BaseModel): ) +class SessionSteerRequest(BaseModel): + """Request body for a mid-turn steer (POST /sessions/{id}/steer). + + A follow-up the user typed while a response was still streaming. The + client mints `entry_id` so the whole round trip is idempotent: it is the + id the SPA holds on the queued composer entry, the id the runtime clears + once the injection is committed to history, and the id the + `steering_applied` SSE event names back. See docs/specs/mid-turn-steering.md. + + `text` is the user's words verbatim — the same string that would have been + sent as a normal turn had the queue flushed at end of turn instead. + """ + + text: str = Field( + min_length=1, + max_length=STEER_QUEUE_MAX_CHARS, + description="The follow-up to inject at the running turn's next tool boundary", + ) + entry_id: str = Field( + min_length=1, + max_length=128, + alias="entryId", + description="Client-minted id for this queue entry; echoed back on steering_applied", + ) + + model_config = ConfigDict(populate_by_name=True) + + +class SessionSteerResponse(BaseModel): + """Result of arming a mid-turn steer. + + `queued=False` is not an error: it means the turn ended between the user + typing and this request landing, and the SPA should send the text as a + normal turn instead (which is exactly what its end-of-turn flush already + does). The endpoint answers 200 either way so the SPA never has to + distinguish a lost race from a failure. + """ + + queued: bool = Field(description="Whether the entry was armed against a live turn") + entry_id: str = Field(alias="entryId", description="The client-minted entry id") + + model_config = ConfigDict(populate_by_name=True) + + class SessionMetadataResponse(BaseModel): """Response containing session metadata""" diff --git a/backend/src/apis/shared/sessions/session_lease.py b/backend/src/apis/shared/sessions/session_lease.py index ff76e9354..e8ac28535 100644 --- a/backend/src/apis/shared/sessions/session_lease.py +++ b/backend/src/apis/shared/sessions/session_lease.py @@ -135,10 +135,17 @@ async def acquire_session_lease( # lease item, so a prior turn's cancel request can't bleed into this # one. (Owner-scoping already protects — the new owner token won't match # the old cancelRequestedFor — but clearing keeps the row honest.) + # + # The steering inbox is cleared for a stronger reason than tidiness. + # Owner-scoping hides a stale queue from ``peek_steer_queue``, but + # ``seed_steer_queue`` stamps ``steerFor`` to OUR owner — which would + # make a previous turn's leftovers suddenly visible and inject them + # into this turn. Clearing here is what makes seeding safe. "UpdateExpression": ( "SET leaseOwner = :owner, leaseExpiresAt = :exp, " "#ttl = :ttl, updatedAt = :updated " - "REMOVE cancelRequestedFor, cancelRequestedAt" + "REMOVE cancelRequestedFor, cancelRequestedAt, " + "steerFor, steerQueue, steerRequestedAt" ), "ExpressionAttributeNames": {"#ttl": "ttl"}, "ExpressionAttributeValues": { @@ -351,3 +358,346 @@ async def request_session_cancel(session_id: str, user_id: str) -> bool: return False logger.warning("Session cancel arm failed for %s: %s", session_id, e) return False + + +# --------------------------------------------------------------------------- +# Mid-turn steering inbox +# --------------------------------------------------------------------------- +# +# The same lease item doubles as a one-turn inbox for mid-turn steering +# (docs/specs/mid-turn-steering.md). A follow-up typed while a turn is +# streaming is appended to ``steerQueue`` and stamped with ``steerFor = +# ``; the container running the turn peeks the queue at +# each tool boundary and injects the text into the tool-result message. +# +# Riding the lease row rather than a new item buys three properties for free: +# +# * **Owner-scoping.** Exactly the ``cancelRequestedFor`` property — a steer +# names the owner that was live when it was armed, so if the turn ended and +# another started, the new turn ignores it. +# * **No GC.** ``release_session_lease`` deletes the whole row at turn end, so +# an unconsumed inbox cannot outlive its turn. +# * **No new table, no new key derivation.** Same deterministic key. +# +# Consumption is commit-on-append, never commit-on-read: ``AfterToolsEvent`` +# fires from a ``finally`` and so also fires on the interrupt path, where the +# mutated message is discarded. A hook that cleared on read would destroy the +# user's words whenever a steer landed on the same tool batch as an OAuth +# consent. So the hook peeks, and clears only once the message is confirmed in +# history. + +# Cap on the inbox so a pathological client cannot grow the lease row toward +# the 400 KB item limit. Composer text is small and the row is deleted per +# turn, so these are guards, not budgets. +STEER_QUEUE_MAX_ENTRIES = 5 +STEER_QUEUE_MAX_CHARS = 8000 + + +class SteerQueueFullError(Exception): + """Raised when a steer would exceed the inbox's entry or size cap. + + The caller (``POST /sessions/{id}/steer``) maps this to HTTP 429; the SPA + leaves the entry queued for the end-of-turn flush. + """ + + +def _steer_entries(item: Optional[dict], owner: Optional[str] = None) -> list: + """Return the inbox entries on a lease item, owner-scoped when asked. + + Returns ``[]`` for a missing item, a missing/foreign ``steerFor``, or a + malformed queue — every read path treats "no entries" as the safe answer. + """ + if not item: + return [] + if owner is not None and item.get("steerFor") != owner: + return [] + queue = item.get("steerQueue") + if not isinstance(queue, list): + return [] + return [e for e in queue if isinstance(e, dict) and e.get("id") and e.get("text")] + + +async def request_session_steer( + session_id: str, + user_id: str, + *, + text: str, + entry_id: str, +) -> bool: + """Queue a follow-up for injection into the turn holding this session's lease. + + Called from app-api (any container), mirroring ``request_session_cancel``: + read the lease's current ``leaseOwner``, then conditionally append to the + inbox naming that same owner. The condition is the whole safety property — + if the turn ended between the read and the write, the append is rejected + and the caller falls back to sending the text as a normal turn. + + Returns ``True`` if the entry was queued against an active lease. ``False`` + when there is no active turn (no lease item), the turn ended mid-flight, or + the guard is inactive (table unconfigured) — all of which mean "nothing + running to steer", and all of which the SPA handles the same way. + + Raises: + SteerQueueFullError: the inbox is at its entry or character cap. + """ + table = _table() + if table is None: + return False + + from botocore.exceptions import ClientError + + try: + resp = table.get_item( + Key={"PK": f"USER#{user_id}", "SK": f"LEASE#{session_id}"} + ) + except ClientError as e: + logger.warning("Session steer lookup failed for %s: %s", session_id, e) + return False + + item = resp.get("Item") + owner = item.get("leaseOwner") if item else None + if not owner: + # No lease → no turn is streaming server-side for this session. + return False + + existing = _steer_entries(item, owner) + if len(existing) >= STEER_QUEUE_MAX_ENTRIES: + raise SteerQueueFullError(session_id) + if sum(len(str(e.get("text", ""))) for e in existing) + len(text) > STEER_QUEUE_MAX_CHARS: + raise SteerQueueFullError(session_id) + + entry = { + "id": entry_id, + "text": text, + "at": datetime.now(timezone.utc).isoformat(), + } + try: + table.update_item( + Key={"PK": f"USER#{user_id}", "SK": f"LEASE#{session_id}"}, + UpdateExpression=( + "SET steerFor = :owner, steerRequestedAt = :ts, " + "steerQueue = list_append(if_not_exists(steerQueue, :empty), :entry)" + ), + # Only arm if that same owner still holds the lease — otherwise the + # turn already ended/rotated and there is nothing to steer. + ConditionExpression="leaseOwner = :owner", + ExpressionAttributeValues={ + ":owner": owner, + ":ts": entry["at"], + ":empty": [], + ":entry": [entry], + }, + ) + logger.info("Queued steer for session %s (owner=%s)", session_id, owner) + return True + except ClientError as e: + if e.response.get("Error", {}).get("Code") == "ConditionalCheckFailedException": + # The turn ended or rotated between the read and the write. The + # correct outcome, not an error: the caller sends a normal turn. + return False + logger.warning("Session steer arm failed for %s: %s", session_id, e) + return False + + +async def seed_steer_queue( + lease: Optional[SessionLease], + entries: list, +) -> int: + """Install carried-over follow-ups on a turn that is just starting. + + The resume path's reason for existing (docs/specs/mid-turn-steering.md, + "Paused turns"). A turn paused for OAuth consent or tool approval has no + running loop to steer, and its lease — inbox and all — is released when the + paused stream closes. The follow-ups the user typed meanwhile are still in + their composer, so the resume request carries them and they are seeded here + against the *resumed* turn's lease. + + Deliberately reusing the inbox rather than prepending to the resume prompt: + that prompt is Strands' interrupt-response list, and text in it would stop + it being recognised as a resume at all. Seeding means one injection path and + one ack path for every steer, however it arrived. + + ``SET``, not ``list_append``: this runs at turn start on a lease we just + acquired, so there is nothing legitimate to append to. Returns the number of + entries installed, or 0 when there is nothing to do or the write failed — + best-effort, because a lost seed degrades to the composer's end-of-turn + flush rather than losing the text. + """ + if lease is None or not entries: + return 0 + table = _table() + if table is None: + return 0 + + from botocore.exceptions import ClientError + + now = datetime.now(timezone.utc).isoformat() + normalized = [] + total_chars = 0 + for entry in entries[:STEER_QUEUE_MAX_ENTRIES]: + text = str(entry.get("text", "")) + entry_id = str(entry.get("id", "")) + if not text or not entry_id: + continue + total_chars += len(text) + if total_chars > STEER_QUEUE_MAX_CHARS: + break + normalized.append({"id": entry_id, "text": text, "at": now}) + + if not normalized: + return 0 + + try: + table.update_item( + Key={"PK": lease.pk, "SK": lease.sk}, + UpdateExpression=( + "SET steerFor = :owner, steerRequestedAt = :ts, steerQueue = :entries" + ), + ConditionExpression="leaseOwner = :owner", + ExpressionAttributeValues={ + ":owner": lease.owner, + ":ts": now, + ":entries": normalized, + }, + ) + logger.info( + "Seeded %d carried-over steer(s) for session %s", + len(normalized), + lease.session_id, + ) + return len(normalized) + except ClientError as e: + if e.response.get("Error", {}).get("Code") == "ConditionalCheckFailedException": + return 0 + logger.warning("Steer seed failed for %s: %s", lease.session_id, e) + return 0 + + +async def peek_steer_queue(lease: Optional[SessionLease]) -> list: + """Read the inbox entries armed for *our* lease, without consuming them. + + Called from the steering hook at each tool boundary. Deliberately a peek: + the entry is cleared only once the message carrying it is confirmed in + history (``clear_steer_entry``), so an injection discarded by the interrupt + path is re-delivered rather than lost. + + Best-effort in the safe direction: an unconfigured table, a missing row, a + foreign ``steerFor``, or any DynamoDB error all return ``[]``. + """ + if lease is None: + return [] + table = _table() + if table is None: + return [] + + from botocore.exceptions import ClientError + + try: + resp = table.get_item(Key={"PK": lease.pk, "SK": lease.sk}) + except ClientError as e: + logger.warning("Steer queue read failed for %s: %s", lease.session_id, e) + return [] + + return _steer_entries(resp.get("Item"), lease.owner) + + +def _remove_entry_by_id( + table, + pk: str, + sk: str, + entry_id: str, + *, + owner: Optional[str] = None, +) -> bool: + """Conditionally remove one inbox entry by id. Idempotent, best-effort. + + DynamoDB removes list elements by index, so we read to find the entry's + position and then guard the write on that index still holding that id (and, + when given, on the lease still being ours). A concurrent removal that + shifted the list fails the condition rather than deleting the wrong entry — + a re-delivery is recoverable, deleting someone's words is not. + """ + from botocore.exceptions import ClientError + + try: + resp = table.get_item(Key={"PK": pk, "SK": sk}) + except ClientError as e: + logger.warning("Steer entry lookup failed: %s", e) + return False + + item = resp.get("Item") + if not item: + return False + if owner is not None and item.get("leaseOwner") != owner: + return False + + queue = item.get("steerQueue") + if not isinstance(queue, list): + return False + index = next( + ( + i + for i, e in enumerate(queue) + if isinstance(e, dict) and e.get("id") == entry_id + ), + None, + ) + if index is None: + # Already cleared — the caller's intent is satisfied. + return False + + values = {":id": entry_id} + condition = f"steerQueue[{index}].id = :id" + if owner is not None: + condition += " AND leaseOwner = :owner" + values[":owner"] = owner + + try: + table.update_item( + Key={"PK": pk, "SK": sk}, + UpdateExpression=f"REMOVE steerQueue[{index}]", + ConditionExpression=condition, + ExpressionAttributeValues=values, + ) + return True + except ClientError as e: + if e.response.get("Error", {}).get("Code") == "ConditionalCheckFailedException": + # The list shifted under us, or the lease rotated. Leaving the entry + # in place is the safe failure: worst case it is re-injected once. + return False + logger.warning("Steer entry clear failed: %s", e) + return False + + +async def clear_steer_entry(lease: Optional[SessionLease], entry_id: str) -> bool: + """Consume one inbox entry, once its injection is committed to history. + + The second half of commit-on-append: called from the steering hook's + ``MessageAddedEvent`` handler, which is the first point at which the + injected text is really in the conversation. Owner-scoped and conditional + on the entry id, so a re-delivery after a lost ack is idempotent rather + than duplicated. + """ + if lease is None: + return False + table = _table() + if table is None: + return False + return _remove_entry_by_id(table, lease.pk, lease.sk, entry_id, owner=lease.owner) + + +async def remove_steer_entry(session_id: str, user_id: str, entry_id: str) -> bool: + """Withdraw a queued steer on the user's behalf (composer entry removed). + + The client-facing counterpart of ``clear_steer_entry``: no lease owner in + hand, and none needed — the user is deleting their own words from their own + session's inbox, and the ``USER#`` partition already scopes that. Returns + ``False`` for an unknown id or an ended turn; the caller answers 204 either + way, since the user's intent is satisfied in both cases. + """ + table = _table() + if table is None: + return False + return _remove_entry_by_id( + table, f"USER#{user_id}", f"LEASE#{session_id}", entry_id + ) diff --git a/backend/src/apis/shared/skills/repository.py b/backend/src/apis/shared/skills/repository.py index 66bb9d688..ed1e2b1ec 100644 --- a/backend/src/apis/shared/skills/repository.py +++ b/backend/src/apis/shared/skills/repository.py @@ -23,7 +23,7 @@ from boto3.dynamodb.conditions import Key from botocore.exceptions import ClientError -from .models import SYSTEM_OWNER_ID, SkillDefinition, SkillStatus, UserSkillPreference +from .models import SkillDefinition, SkillStatus, UserSkillPreference logger = logging.getLogger(__name__) diff --git a/backend/src/apis/shared/user_menu_links/models.py b/backend/src/apis/shared/user_menu_links/models.py index b6eaf5834..eb2e027c9 100644 --- a/backend/src/apis/shared/user_menu_links/models.py +++ b/backend/src/apis/shared/user_menu_links/models.py @@ -34,6 +34,12 @@ def _validate_http_url(value: Optional[str]) -> Optional[str]: return value +# Public handle on the same check. ``announcements`` validates its ``ctaUrl`` +# for exactly this reason, and one implementation is better than two that can +# drift apart. +validate_http_url = _validate_http_url + + @dataclass class UserMenuLink: """Admin-managed user-menu link stored in DynamoDB.""" diff --git a/backend/src/apis/shared/users/repository.py b/backend/src/apis/shared/users/repository.py index 2f375f077..88b0149bf 100644 --- a/backend/src/apis/shared/users/repository.py +++ b/backend/src/apis/shared/users/repository.py @@ -267,6 +267,41 @@ async def list_users_by_status( logger.error(f"Error listing users by status {status}: {e}") return [], None + async def count_active_users(self) -> Optional[int]: + """How many users are active, via a COUNT query on StatusLoginIndex. + + ``Select="COUNT"`` returns only a tally, so nothing is transferred per + user — but DynamoDB still pages, hence the loop. Callers use this as a + denominator for "roughly how many people is this aimed at"; it moves as + people join and sign in, so treat it as an estimate. + + **Cannot be filtered by role.** That index is projected ``INCLUDE`` + with userId/email/name/emailDomain and not ``roles``, so a filter on + roles has nothing to evaluate against. Returns None if the repository + is disabled or the query fails, which means "unknown", never zero. + """ + if not self._enabled: + return None + + try: + total = 0 + kwargs: dict = { + "IndexName": "StatusLoginIndex", + "KeyConditionExpression": "GSI3PK = :pk", + "ExpressionAttributeValues": {":pk": "STATUS#active"}, + "Select": "COUNT", + } + while True: + response = self.table.query(**kwargs) + total += int(response.get("Count", 0)) + last_key = response.get("LastEvaluatedKey") + if not last_key: + return total + kwargs["ExclusiveStartKey"] = last_key + except ClientError as e: + logger.error(f"Error counting active users: {e}") + return None + # ========== Helper Methods ========== def _profile_to_item(self, profile: UserProfile) -> dict: diff --git a/backend/src/lambdas/scheduled_runs_dispatcher/requirements.txt b/backend/src/lambdas/scheduled_runs_dispatcher/requirements.txt index 76825bc57..7d1d18e39 100644 --- a/backend/src/lambdas/scheduled_runs_dispatcher/requirements.txt +++ b/backend/src/lambdas/scheduled_runs_dispatcher/requirements.txt @@ -9,5 +9,5 @@ bedrock-agentcore==1.21.0 # apis.shared.sessions_bff, whose cookie.py needs cryptography (AESGCM) and # cache.py needs cachetools (TTLCache). The dispatcher itself does not import # these, but the shared image must satisfy the worker's import chain. -cryptography==48.0.1 +cryptography==50.0.1 cachetools==6.2.4 diff --git a/backend/src/lambdas/scheduled_runs_worker/requirements.txt b/backend/src/lambdas/scheduled_runs_worker/requirements.txt index 55bd8ee96..ffd05d98c 100644 --- a/backend/src/lambdas/scheduled_runs_worker/requirements.txt +++ b/backend/src/lambdas/scheduled_runs_worker/requirements.txt @@ -8,5 +8,5 @@ bedrock-agentcore==1.21.0 # whose cookie.py needs cryptography (AESGCM) and cache.py needs cachetools # (TTLCache). Kept in sync with the dispatcher requirements.txt, which is the # file Dockerfile.scheduled-runs actually installs for the shared image. -cryptography==48.0.1 +cryptography==50.0.1 cachetools==6.2.4 diff --git a/backend/tests/agents/builtin_tools/artifacts/test_artifact_tools.py b/backend/tests/agents/builtin_tools/artifacts/test_artifact_tools.py index 152e1b324..1a4697485 100644 --- a/backend/tests/agents/builtin_tools/artifacts/test_artifact_tools.py +++ b/backend/tests/agents/builtin_tools/artifacts/test_artifact_tools.py @@ -123,6 +123,38 @@ def test_update_increments_and_preserves_old(aws) -> None: assert head["title"] == "T" # carried forward +def test_head_rows_carry_user_index_keys_on_both_write_paths(aws) -> None: + """GSI2PK/GSI2SK are stamped ahead of the index that will consume them. + + No index exists yet (the fixture table declares only SessionIndex), so + nothing queries these — but a sparse GSI only ever contains rows that + already carry its key attributes. A write path that stops stamping them + produces rows permanently invisible to the future UserArtifactsIndex, + and does so silently. Both paths are asserted because `update` re-puts + HEAD wholesale: dropping the attributes there would strip them from + every artifact that is ever edited. + """ + ddb, _ = aws + aid, _ = service.create_artifact_record(USER, SESSION, "T", DOC, "") + + head = _item(ddb, aid, "HEAD") + assert head["GSI2PK"] == f"USER#{USER}" + # Sorts newest-first within the user's partition, so it must track + # updated_at rather than creation time. + assert head["GSI2SK"] == f"ARTIFACT#{head['updated_at']}#{aid}" + + # Sparse by design: one indexed row per artifact, not one per version. + assert "GSI2PK" not in _item(ddb, aid, "V#00001") + + service.update_artifact_record(USER, aid, "

v2

", None, None) + + updated = _item(ddb, aid, "HEAD") + assert updated["GSI2PK"] == f"USER#{USER}" + assert updated["GSI2SK"] == f"ARTIFACT#{updated['updated_at']}#{aid}" + assert updated["GSI2SK"] > head["GSI2SK"] + assert "GSI2PK" not in _item(ddb, aid, "V#00002") + + def test_update_unknown_artifact_raises(aws) -> None: with pytest.raises(service.ArtifactNotFoundError): service.update_artifact_record(USER, "nope", DOC, None, None) diff --git a/backend/tests/agents/main_agent/core/test_agent_factory.py b/backend/tests/agents/main_agent/core/test_agent_factory.py index 186093777..ba3b08839 100644 --- a/backend/tests/agents/main_agent/core/test_agent_factory.py +++ b/backend/tests/agents/main_agent/core/test_agent_factory.py @@ -165,6 +165,95 @@ def test_mantle_region_override_pins_inference_region( assert mock_build.call_args.kwargs["region"] == "us-east-1" +# --------------------------------------------------------------------------- +# bedrock-responses provider creates an Agent with an OpenAI Responses model on +# the bedrock-runtime endpoint — the only Bedrock path that caches for GPT-5.6. +# --------------------------------------------------------------------------- +class TestCreateAgentBedrockResponses: + """Delegation contract for the second OpenAI-compatible Bedrock surface. + + Construction behavior (base URL, per-request token mint, usage + normalization) is covered directly on the builder in + ``tests/shared/test_bedrock_responses.py``. + """ + + @patch("agents.main_agent.core.agent_factory.Agent") + @patch("agents.main_agent.core.agent_factory.build_bedrock_responses_model") + def test_delegates_to_the_shared_builder(self, mock_build, mock_agent_cls, monkeypatch): + from agents.main_agent.core.agent_factory import AgentFactory + + monkeypatch.setenv("AWS_REGION", "us-west-2") + mock_model_instance = MagicMock() + mock_build.return_value = mock_model_instance + + config = ModelConfig( + model_id="us.openai.gpt-5.6-sol", + provider=ModelProvider.BEDROCK_RESPONSES, + ) + AgentFactory.create_agent(model_config=config, **_COMMON_KWARGS) + + mock_build.assert_called_once() + call_kwargs = mock_build.call_args.kwargs + assert call_kwargs["model_id"] == "us.openai.gpt-5.6-sol" + assert call_kwargs["region"] == "us-west-2" + assert mock_agent_cls.call_args.kwargs["model"] is mock_model_instance + + @patch("agents.main_agent.core.agent_factory.Agent") + @patch("agents.main_agent.core.agent_factory.build_bedrock_responses_model") + def test_region_override_pins_the_endpoint(self, mock_build, mock_agent_cls, monkeypatch): + from agents.main_agent.core.agent_factory import AgentFactory + + monkeypatch.setenv("AWS_REGION", "us-west-2") + config = ModelConfig( + model_id="global.openai.gpt-5.6-sol", + provider=ModelProvider.BEDROCK_RESPONSES, + mantle_region="us-east-1", + ) + AgentFactory.create_agent(model_config=config, **_COMMON_KWARGS) + + assert mock_build.call_args.kwargs["region"] == "us-east-1" + + @patch("agents.main_agent.core.agent_factory.Agent") + @patch("agents.main_agent.core.agent_factory.build_bedrock_responses_model") + def test_never_routes_through_the_mantle_builder( + self, mock_build, mock_agent_cls, monkeypatch + ): + """The two surfaces must not cross: Mantle's config hardcodes its host.""" + from agents.main_agent.core.agent_factory import AgentFactory + + monkeypatch.setenv("AWS_REGION", "us-west-2") + config = ModelConfig( + model_id="us.openai.gpt-5.6-sol", + provider=ModelProvider.BEDROCK_RESPONSES, + ) + with patch("agents.main_agent.core.agent_factory.build_mantle_model") as mantle: + AgentFactory.create_agent(model_config=config, **_COMMON_KWARGS) + + mantle.assert_not_called() + mock_build.assert_called_once() + + @patch("agents.main_agent.core.agent_factory.Agent") + @patch("agents.main_agent.core.agent_factory.build_bedrock_responses_model") + def test_inference_params_translate_to_responses_native_names( + self, mock_build, mock_agent_cls, monkeypatch + ): + """`max_tokens` is `max_output_tokens` on the Responses API.""" + from agents.main_agent.core.agent_factory import AgentFactory + + monkeypatch.setenv("AWS_REGION", "us-west-2") + config = ModelConfig( + model_id="us.openai.gpt-5.6-sol", + provider=ModelProvider.BEDROCK_RESPONSES, + inference_params={"max_tokens": 2048, "temperature": 0.3}, + ) + AgentFactory.create_agent(model_config=config, **_COMMON_KWARGS) + + params = mock_build.call_args.kwargs["params"] + assert params["max_output_tokens"] == 2048 + assert params["temperature"] == 0.3 + assert "max_tokens" not in params + + # --------------------------------------------------------------------------- # Req 4.3 — Gemini provider with API key creates Agent with GeminiModel # --------------------------------------------------------------------------- diff --git a/backend/tests/agents/main_agent/core/test_bedrock_cache_points.py b/backend/tests/agents/main_agent/core/test_bedrock_cache_points.py index 4295cd3ac..ad8255168 100644 --- a/backend/tests/agents/main_agent/core/test_bedrock_cache_points.py +++ b/backend/tests/agents/main_agent/core/test_bedrock_cache_points.py @@ -215,3 +215,101 @@ def test_caching_disabled_yields_zero_cache_points(self, monkeypatch): system_prompt_content=[{"text": "You are a helpful assistant."}], ) assert _count_cache_points(request) == 0 + + +class TestSteeringInjectionDoesNotDisturbCachePoints: + """A mid-turn steering injection rides the tool-result message. + + Mid-turn steering (docs/specs/mid-turn-steering.md) appends the user's + words as a ``{"text": ...}`` block to the same user-role message that + carries the tool results, so a steered turn's history ends on a *mixed* + ``toolResult`` + ``text`` message. The cost claim in the spec rests on that + injection being append-only against the cached prefix: it must land inside + the segment the ``strategy="auto"`` message point already covers, behind + both static points, so the next call still reads the stable prefix from + cache rather than rewriting it. + + These lock the placement. A regression here is a prompt-cache **cost** bug + — the class this repo's cost tenet exists to catch — not a correctness one, + so it would not surface in any behavioural test. + """ + + @pytest.fixture + def model(self, monkeypatch): + monkeypatch.setenv("AWS_REGION", "us-west-2") + from agents.main_agent.core.bedrock_count_tokens import CountTokensBedrockModel + + config = ModelConfig(model_id=CLAUDE_MODEL_ID, caching_enabled=True) + return CountTokensBedrockModel(**config.to_bedrock_config()) + + @staticmethod + def _messages(steered: bool): + result_content = [ + { + "toolResult": { + "toolUseId": "t1", + "content": [{"text": "thread body"}], + "status": "success", + } + } + ] + if steered: + result_content.append( + {"text": "\nuse the other file\n"} + ) + return [ + {"role": "user", "content": [{"text": "first turn"}]}, + { + "role": "assistant", + "content": [ + {"toolUse": {"toolUseId": "t1", "name": "get_thread", "input": {}}} + ], + }, + {"role": "user", "content": result_content}, + ] + + def _format(self, model, steered: bool): + return model.format_request( + self._messages(steered), + [ + { + "name": "get_thread", + "description": "Fetch a thread", + "inputSchema": {"json": {"type": "object", "properties": {}}}, + } + ], + system_prompt_content=[ + {"text": "You are a helpful assistant."}, + {"cachePoint": {"type": "default"}}, + ], + ) + + def test_still_exactly_three_cache_points(self, model): + request = self._format(model, steered=True) + assert _count_cache_points(request) == 3, json.dumps( + request, default=str, indent=2 + ) + + def test_the_injection_sits_behind_the_message_cache_point(self, model): + """Append-only: the text lands before the trailing point, so every + block ahead of that point is byte-identical to the unsteered turn.""" + request = self._format(model, steered=True) + last_user = [m for m in request["messages"] if m["role"] == "user"][-1] + + assert last_user["content"][-1] == {"cachePoint": {"type": "default"}} + assert "toolResult" in last_user["content"][0] + assert last_user["content"][1]["text"].startswith("") + + def test_the_static_points_are_untouched(self, model): + """The tools and system points are what the ~28k-token prefix rides on. + + A steering injection that shifted either would rewrite that prefix at + the cache-write premium on every steered turn. + """ + steered = self._format(model, steered=True) + plain = self._format(model, steered=False) + + assert steered["toolConfig"] == plain["toolConfig"] + assert steered["system"] == plain["system"] + # Everything before the mixed message is identical too. + assert steered["messages"][:-1] == plain["messages"][:-1] diff --git a/backend/tests/agents/main_agent/session/test_steering_hook.py b/backend/tests/agents/main_agent/session/test_steering_hook.py new file mode 100644 index 000000000..5b324b528 --- /dev/null +++ b/backend/tests/agents/main_agent/session/test_steering_hook.py @@ -0,0 +1,303 @@ +"""Tests for SteeringHook — mid-turn steering injection at a tool boundary. + +See docs/specs/mid-turn-steering.md. The properties under test, in order of +how expensive they are to get wrong: + +1. **Commit-on-append.** ``AfterToolsEvent`` fires from a ``finally`` and so + also fires on the interrupt path, where the mutated message is discarded. + The hook must NOT consume the inbox on read, or a steer that lands on the + same tool batch as an OAuth consent silently destroys the user's words. +2. **The SDK contract.** ``HookEvent.__setattr__`` is write-guarded; mutating + the message dict in place is not blocked but is not sanctioned either. The + contract test here is the canary for a ``strands-agents`` bump. +3. Fail-soft everywhere else: flag off, no lease, empty batch, failed read. +""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest +from strands.hooks import AfterToolsEvent, MessageAddedEvent + +from agents.main_agent.session.hooks.steering import ( + STEER_CLOSE_TAG, + STEER_OPEN_TAG, + SteeringHook, +) +from apis.shared.sessions.session_lease import SessionLease + + +@pytest.fixture +def lease(): + return SessionLease(session_id="s1", user_id="u1", owner="owner-1") + + +@pytest.fixture +def session_manager(lease): + manager = MagicMock() + manager.turn_lease = lease + return manager + + +def _tool_result_message(count: int = 1): + return { + "role": "user", + "content": [ + {"toolResult": {"toolUseId": f"t{i}", "content": [{"text": "ok"}]}} + for i in range(count) + ], + } + + +def _after_tools(message): + return AfterToolsEvent(agent=MagicMock(), message=message, invocation_state={}) + + +def _patch_lease(monkeypatch, *, peek=None, clear=None): + import apis.shared.sessions.session_lease as mod + + monkeypatch.setattr(mod, "peek_steer_queue", AsyncMock(return_value=peek or [])) + monkeypatch.setattr(mod, "clear_steer_entry", clear or AsyncMock(return_value=True)) + return mod + + +class TestInjection: + @pytest.mark.asyncio + async def test_appends_a_wrapped_text_block_to_the_tool_results( + self, monkeypatch, session_manager + ): + _patch_lease(monkeypatch, peek=[{"id": "e1", "text": "use the other file"}]) + hook = SteeringHook(session_manager) + message = _tool_result_message() + + await hook.inject_pending_steering(_after_tools(message)) + + assert len(message["content"]) == 2 + # The tool results are untouched and still lead the message: the + # injection is append-only against the cached prefix. + assert "toolResult" in message["content"][0] + text = message["content"][1]["text"] + assert text.startswith(STEER_OPEN_TAG) + assert text.endswith(STEER_CLOSE_TAG) + assert "use the other file" in text + + @pytest.mark.asyncio + async def test_multiple_entries_ride_one_block_in_arrival_order( + self, monkeypatch, session_manager + ): + _patch_lease( + monkeypatch, + peek=[{"id": "e1", "text": "first"}, {"id": "e2", "text": "second"}], + ) + hook = SteeringHook(session_manager) + message = _tool_result_message() + + await hook.inject_pending_steering(_after_tools(message)) + + text = message["content"][1]["text"] + assert text.index("first") < text.index("second") + + @pytest.mark.asyncio + async def test_noop_on_an_empty_tool_batch(self, monkeypatch, session_manager): + peek = AsyncMock(return_value=[{"id": "e1", "text": "hi"}]) + import apis.shared.sessions.session_lease as mod + + monkeypatch.setattr(mod, "peek_steer_queue", peek) + hook = SteeringHook(session_manager) + # Cancelled before any tool ran: no message will be appended, so there + # is nothing for the injection to ride. + message = {"role": "user", "content": []} + + await hook.inject_pending_steering(_after_tools(message)) + + assert message["content"] == [] + peek.assert_not_awaited() + + @pytest.mark.asyncio + async def test_noop_without_a_turn_lease(self, monkeypatch): + peek = AsyncMock(return_value=[{"id": "e1", "text": "hi"}]) + import apis.shared.sessions.session_lease as mod + + monkeypatch.setattr(mod, "peek_steer_queue", peek) + manager = MagicMock() + manager.turn_lease = None # preview session / local dev + hook = SteeringHook(manager) + message = _tool_result_message() + + await hook.inject_pending_steering(_after_tools(message)) + + assert len(message["content"]) == 1 + peek.assert_not_awaited() + + @pytest.mark.asyncio + async def test_noop_when_the_flag_is_off(self, monkeypatch, session_manager): + monkeypatch.setenv("MID_TURN_STEERING_ENABLED", "false") + peek = AsyncMock(return_value=[{"id": "e1", "text": "hi"}]) + import apis.shared.sessions.session_lease as mod + + monkeypatch.setattr(mod, "peek_steer_queue", peek) + hook = SteeringHook(session_manager) + message = _tool_result_message() + + await hook.inject_pending_steering(_after_tools(message)) + + assert len(message["content"]) == 1 + peek.assert_not_awaited() + + @pytest.mark.asyncio + async def test_a_failed_read_leaves_the_message_untouched( + self, monkeypatch, session_manager + ): + import apis.shared.sessions.session_lease as mod + + monkeypatch.setattr( + mod, "peek_steer_queue", AsyncMock(side_effect=RuntimeError("throttled")) + ) + hook = SteeringHook(session_manager) + message = _tool_result_message() + + # Fail-soft: the follow-up stays queued and flushes at end of turn. + await hook.inject_pending_steering(_after_tools(message)) + + assert len(message["content"]) == 1 + + +class TestCommitOnAppend: + @pytest.mark.asyncio + async def test_injection_alone_does_not_consume_the_inbox( + self, monkeypatch, session_manager + ): + clear = AsyncMock(return_value=True) + _patch_lease(monkeypatch, peek=[{"id": "e1", "text": "hi"}], clear=clear) + hook = SteeringHook(session_manager) + + await hook.inject_pending_steering(_after_tools(_tool_result_message())) + + # This is the interrupt path in miniature: the message the hook mutated + # is never appended, so the entry must survive for re-delivery. + clear.assert_not_awaited() + assert hook.drain_applied() == [] + + @pytest.mark.asyncio + async def test_entry_is_cleared_once_its_message_reaches_history( + self, monkeypatch, session_manager, lease + ): + clear = AsyncMock(return_value=True) + _patch_lease(monkeypatch, peek=[{"id": "e1", "text": "hi"}], clear=clear) + hook = SteeringHook(session_manager) + message = _tool_result_message() + + await hook.inject_pending_steering(_after_tools(message)) + await hook.commit_pending_steering( + MessageAddedEvent(agent=MagicMock(), message=message) + ) + + clear.assert_awaited_once_with(lease, "e1") + assert [e["id"] for e in hook.drain_applied()] == ["e1"] + + @pytest.mark.asyncio + async def test_a_different_message_does_not_ack_the_injection( + self, monkeypatch, session_manager + ): + clear = AsyncMock(return_value=True) + _patch_lease(monkeypatch, peek=[{"id": "e1", "text": "hi"}], clear=clear) + hook = SteeringHook(session_manager) + + await hook.inject_pending_steering(_after_tools(_tool_result_message())) + # Identity, not equality: an equal-looking message added by something + # else must not consume the entry. + await hook.commit_pending_steering( + MessageAddedEvent(agent=MagicMock(), message=_tool_result_message()) + ) + + clear.assert_not_awaited() + + @pytest.mark.asyncio + async def test_commit_is_ignored_without_a_pending_injection( + self, monkeypatch, session_manager + ): + clear = AsyncMock(return_value=True) + _patch_lease(monkeypatch, clear=clear) + hook = SteeringHook(session_manager) + + # Every ordinary message of every ordinary turn takes this path. + await hook.commit_pending_steering( + MessageAddedEvent(agent=MagicMock(), message=_tool_result_message()) + ) + + clear.assert_not_awaited() + + @pytest.mark.asyncio + async def test_commit_acks_only_once(self, monkeypatch, session_manager): + clear = AsyncMock(return_value=True) + _patch_lease(monkeypatch, peek=[{"id": "e1", "text": "hi"}], clear=clear) + hook = SteeringHook(session_manager) + message = _tool_result_message() + + await hook.inject_pending_steering(_after_tools(message)) + event = MessageAddedEvent(agent=MagicMock(), message=message) + await hook.commit_pending_steering(event) + await hook.commit_pending_steering(event) + + assert clear.await_count == 1 + + @pytest.mark.asyncio + async def test_a_failed_clear_is_not_reported_as_applied( + self, monkeypatch, session_manager + ): + _patch_lease( + monkeypatch, + peek=[{"id": "e1", "text": "hi"}], + clear=AsyncMock(side_effect=RuntimeError("throttled")), + ) + hook = SteeringHook(session_manager) + message = _tool_result_message() + + await hook.inject_pending_steering(_after_tools(message)) + await hook.commit_pending_steering( + MessageAddedEvent(agent=MagicMock(), message=message) + ) + + # The entry stays in the inbox and is re-injected at the next boundary; + # the entry id makes the SPA's ack idempotent. Acking a clear that + # never landed would be the unrecoverable direction. + assert hook.drain_applied() == [] + + +class TestSdkContract: + """Canary for a ``strands-agents`` bump. See D2's SDK-boundary caveat. + + ``AfterToolsEvent._can_write`` allows only ``end_turn``, so the injection + works by mutating the message dict in place. If a future SDK version + deep-copies or freezes that message, these fail — and the documented escape + hatch (interrupt/resume, per the spec's Risks) is the fallback design. + """ + + def test_after_tools_event_still_refuses_attribute_writes(self): + event = _after_tools(_tool_result_message()) + with pytest.raises(Exception): + event.message = {"role": "user", "content": []} + + def test_in_place_content_mutation_is_visible_to_the_caller(self): + """The event holds the caller's message object, not a copy. + + This is the load-bearing assumption: ``event_loop`` builds + ``tool_result_message``, hands it to the hook, and then appends *that + same object* — so a block appended here reaches ``agent.messages``. + """ + message = _tool_result_message() + event = _after_tools(message) + + event.message["content"].append({"text": "injected"}) + + assert message["content"][-1] == {"text": "injected"} + assert event.message is message + + def test_message_added_event_carries_the_appended_object(self): + """``_append_messages`` fires MessageAddedEvent with the same object. + + The ack path matches on identity, so a copy here would mean the inbox + entry is never consumed and the text is re-injected every boundary. + """ + message = _tool_result_message() + event = MessageAddedEvent(agent=MagicMock(), message=message) + assert event.message is message diff --git a/backend/tests/agents/main_agent/session/test_steering_interrupt_integration.py b/backend/tests/agents/main_agent/session/test_steering_interrupt_integration.py new file mode 100644 index 000000000..267fe4ca9 --- /dev/null +++ b/backend/tests/agents/main_agent/session/test_steering_interrupt_integration.py @@ -0,0 +1,254 @@ +"""The integration the spec calls "the one that matters". + +`docs/specs/mid-turn-steering.md`, Testing: + + A turn that interrupts on the same tool batch as a steer must leave the + entry in the inbox. ... a mocked interrupt proves nothing here. + +The hazard is silent data loss. `AfterToolsEvent` fires from a ``finally``, so +it fires on the interrupt path too — but there `_stop_for_interrupts` runs and +``agent._append_messages`` is **never reached**, so the message ``SteeringHook`` +just mutated is thrown away. A hook that consumed the inbox when it read it +would therefore destroy the user's words every time a steer happened to land on +the same tool batch as an OAuth consent or an approval prompt. Low frequency, +very hard to reproduce, and unrecoverable. + +So this drives the **real** Strands event loop: a real ``Agent``, real +``@tool`` functions, a real ``BeforeToolCallEvent`` hook raising a real +interrupt, and the real ``SteeringHook``. Only the model and the DynamoDB inbox +are stood in for. Asserting against a mocked interrupt would prove nothing +about `_stop_for_interrupts`, which is the thing that actually discards the +message. + +The batch is deliberately ordered so the ungated tool completes **before** the +gated one pauses: that is the only shape where there is a real injection to +lose. A batch that interrupts before any tool ran carries no tool results, and +the hook declines to inject into it at all (covered here too). +""" + +from typing import Any, AsyncIterable +from unittest.mock import AsyncMock, MagicMock + +import pytest +from strands import Agent, tool +from strands.hooks import BeforeToolCallEvent, HookProvider, HookRegistry +from strands.models.model import Model + +from agents.main_agent.session.hooks.steering import ( + STEER_OPEN_TAG, + SteeringHook, +) +from apis.shared.sessions.session_lease import SessionLease + + +# --------------------------------------------------------------------------- +# Tools: one that completes, one that pauses +# --------------------------------------------------------------------------- + +@tool +def quick_lookup(topic: str) -> str: + """Return a canned fact. Completes normally.""" + return f"fact about {topic}" + + +@tool +def gated_action(payload: str) -> str: + """Never actually runs in these tests — the hook below pauses it first.""" + return f"did {payload}" + + +class _ApprovalHook(HookProvider): + """Pauses `gated_action` with a real Strands interrupt. + + Same shape as the production `MCPExternalApprovalHook`: a + `BeforeToolCallEvent` callback calling `event.interrupt(...)`. Using the + real mechanism is the point — this is what routes the turn through + `_stop_for_interrupts` and discards the mutated tool-result message. + """ + + def register_hooks(self, registry: HookRegistry, **kwargs: Any) -> None: + registry.add_callback(BeforeToolCallEvent, self._gate) + + def _gate(self, event: BeforeToolCallEvent) -> None: + if event.tool_use.get("name") != "gated_action": + return + event.interrupt( + name=f"approval:{event.tool_use['toolUseId']}", + reason={"type": "tool_approval_required"}, + ) + + +# --------------------------------------------------------------------------- +# A model that emits one tool batch, then (if resumed) a final answer +# --------------------------------------------------------------------------- + +class _ScriptedModel(Model): + """Emits a fixed tool-use batch on the first call, text on later calls.""" + + def __init__(self, tool_names: list[str]) -> None: + self._tool_names = tool_names + self.calls = 0 + + def update_config(self, **model_config: Any) -> None: # pragma: no cover + pass + + def get_config(self) -> Any: # pragma: no cover + return {} + + def structured_output(self, *args: Any, **kwargs: Any): # pragma: no cover + raise NotImplementedError + + async def stream(self, *args: Any, **kwargs: Any) -> AsyncIterable[dict]: + self.calls += 1 + if self.calls == 1: + yield {"messageStart": {"role": "assistant"}} + for index, name in enumerate(self._tool_names): + yield { + "contentBlockStart": { + "start": {"toolUse": {"name": name, "toolUseId": f"tu-{index}"}}, + "contentBlockIndex": index, + } + } + yield { + "contentBlockDelta": { + "delta": {"toolUse": {"input": '{"topic": "x", "payload": "y"}'}}, + "contentBlockIndex": index, + } + } + yield {"contentBlockStop": {"contentBlockIndex": index}} + yield {"messageStop": {"stopReason": "tool_use"}} + else: + yield {"messageStart": {"role": "assistant"}} + yield {"contentBlockStart": {"start": {}, "contentBlockIndex": 0}} + yield {"contentBlockDelta": {"delta": {"text": "done"}, "contentBlockIndex": 0}} + yield {"contentBlockStop": {"contentBlockIndex": 0}} + yield {"messageStop": {"stopReason": "end_turn"}} + + +# --------------------------------------------------------------------------- + +@pytest.fixture +def lease(): + return SessionLease(session_id="s1", user_id="u1", owner="owner-1") + + +@pytest.fixture +def inbox(monkeypatch): + """Stand-in for the DynamoDB inbox, recording peeks and clears.""" + import apis.shared.sessions.session_lease as mod + + peek = AsyncMock(return_value=[{"id": "e1", "text": "actually use the other file"}]) + clear = AsyncMock(return_value=True) + monkeypatch.setattr(mod, "peek_steer_queue", peek) + monkeypatch.setattr(mod, "clear_steer_entry", clear) + return MagicMock(peek=peek, clear=clear) + + +def _build_agent(lease, tool_names): + manager = MagicMock() + manager.turn_lease = lease + hook = SteeringHook(manager) + agent = Agent( + model=_ScriptedModel(tool_names), + tools=[quick_lookup, gated_action], + hooks=[_ApprovalHook(), hook], + callback_handler=None, + ) + return agent, hook + + +async def _run(agent) -> None: + async for _ in agent.stream_async("do the thing"): + pass + + +def _all_text(messages) -> str: + return "\n".join( + block.get("text", "") + for message in messages + for block in (message.get("content") or []) + if isinstance(block, dict) + ) + + +class TestSteerOnAnInterruptedBatch: + @pytest.mark.asyncio + async def test_the_entry_survives_a_turn_that_interrupts(self, lease, inbox): + """The property. Injected, discarded, and NOT consumed.""" + agent, hook = _build_agent(lease, ["quick_lookup", "gated_action"]) + + await _run(agent) + + # The hook read the inbox at the tool boundary... + inbox.peek.assert_awaited() + # ...and must NOT have consumed it: the message it mutated was thrown + # away by `_stop_for_interrupts`, so the user's words are still owed. + inbox.clear.assert_not_awaited() + assert hook.drain_applied() == [] + + @pytest.mark.asyncio + async def test_the_injection_is_not_in_history(self, lease, inbox): + """The other half of the same fact, observed from the conversation. + + If this text were in history AND the entry were still queued, the user + would get it twice. Neither-both-nor-neither is the whole contract. + """ + agent, _ = _build_agent(lease, ["quick_lookup", "gated_action"]) + + await _run(agent) + + assert STEER_OPEN_TAG not in _all_text(agent.messages) + + @pytest.mark.asyncio + async def test_the_turn_really_did_pause(self, lease, inbox): + """Guards the test itself. + + If the interrupt stopped firing — an SDK change, a renamed event — every + assertion above would pass for the wrong reason, because a turn that + never pauses also never discards anything. + """ + agent, _ = _build_agent(lease, ["quick_lookup", "gated_action"]) + + await _run(agent) + + assert agent._interrupt_state.activated, "expected a paused turn" + + @pytest.mark.asyncio + async def test_a_batch_that_ran_no_tools_is_not_injected_into(self, lease, inbox): + """The gated tool alone: nothing completed, so there is nothing to ride. + + The hook declines rather than appending a lone text block to a message + that carries no tool results. + """ + agent, hook = _build_agent(lease, ["gated_action"]) + + await _run(agent) + + inbox.clear.assert_not_awaited() + assert STEER_OPEN_TAG not in _all_text(agent.messages) + assert hook.drain_applied() == [] + + +class TestSteerOnACompletedBatch: + @pytest.mark.asyncio + async def test_a_batch_that_completes_consumes_the_entry(self, lease, inbox): + """The contrast case, so the tests above cannot pass by inertia. + + Same hook, same inbox, no interrupt: here the message IS appended, so + `MessageAddedEvent` fires and the entry is consumed exactly once. + """ + manager = MagicMock() + manager.turn_lease = lease + hook = SteeringHook(manager) + agent = Agent( + model=_ScriptedModel(["quick_lookup"]), + tools=[quick_lookup, gated_action], + hooks=[hook], + callback_handler=None, + ) + + await _run(agent) + + inbox.clear.assert_awaited_once_with(lease, "e1") + assert [e["id"] for e in hook.drain_applied()] == ["e1"] + assert STEER_OPEN_TAG in _all_text(agent.messages) diff --git a/backend/tests/agents/main_agent/streaming/test_steering_events.py b/backend/tests/agents/main_agent/streaming/test_steering_events.py new file mode 100644 index 000000000..416f386e5 --- /dev/null +++ b/backend/tests/agents/main_agent/streaming/test_steering_events.py @@ -0,0 +1,186 @@ +"""The `steering_applied` SSE surface and the per-turn lease stamp. + +Mid-turn steering (docs/specs/mid-turn-steering.md) has two coordinator-level +jobs, and both are per-turn state on objects the agent cache reuses: + +1. **Stamp this turn's lease** onto the session manager, so ``SteeringHook`` + reads the right inbox at each tool boundary — and stamp it *unconditionally*, + including to None, so a lease left by a previous turn on a cached agent is + never read against a row a later turn now owns. Same discipline, same + reason, as ``reset_cancellation_state``. +2. **Drain the hook's confirmed injections** and emit one `steering_applied` + frame each, always ahead of `done` — the drain runs *before* each event is + yielded, so an injection confirmed on the turn's final tool batch is not + stranded behind the terminal frame. + +Driven through the real ``stream_response`` (as the compaction-emit suite +does), stubbing only ``agent.stream_async`` and the session manager. +""" + +import json +from typing import Any, AsyncIterator, Dict, List, Optional + +import pytest + +from agents.main_agent.streaming.stream_coordinator import StreamCoordinator +from apis.shared.sessions.session_lease import SessionLease + + +class _FakeAgent: + def __init__(self, raw_events: Optional[List[Dict[str, Any]]] = None) -> None: + self.messages = [{"role": "user", "content": [{"text": "hi"}]}] + self._raw_events = raw_events or [] + + def stream_async(self, prompt: Any) -> AsyncIterator[Dict[str, Any]]: + async def _gen() -> AsyncIterator[Dict[str, Any]]: + for ev in self._raw_events: + yield ev + + return _gen() + + +class _SessionManager: + """Only the seams stream_response touches; `turn_lease` starts stale.""" + + def __init__(self) -> None: + self.cancelled = False + self.turn_lease = SessionLease( + session_id="s1", user_id="u1", owner="a-previous-turn" + ) + + async def update_after_turn(self, input_tokens, current_messages=None): + return None + + +class _Hook: + def __init__(self, applied: Optional[List[dict]] = None) -> None: + self._applied = list(applied or []) + self.drains = 0 + + def drain_applied(self) -> List[dict]: + self.drains += 1 + applied, self._applied = self._applied, [] + return applied + + +class _Wrapper: + def __init__(self, hook) -> None: + self.steering_hook = hook + + +async def _collect(agent, session_manager, wrapper=None, turn_lease=None) -> List[str]: + coordinator = StreamCoordinator() + frames: List[str] = [] + async for sse in coordinator.stream_response( + agent=agent, + prompt="hi", + session_manager=session_manager, + session_id="sess-1", + user_id="user-1", + main_agent_wrapper=wrapper, + turn_lease=turn_lease, + ): + frames.append(sse) + return frames + + +def _steering_frames(frames: List[str]) -> List[dict]: + prefix = "event: steering_applied\ndata: " + return [ + json.loads(f[len(prefix) :].strip()) + for f in frames + if f.startswith(prefix) + ] + + +class TestLeaseStamp: + @pytest.mark.asyncio + async def test_this_turns_lease_replaces_the_previous_one(self): + lease = SessionLease(session_id="s1", user_id="u1", owner="this-turn") + sm = _SessionManager() + + await _collect(_FakeAgent(), sm, turn_lease=lease) + + assert sm.turn_lease is lease + + @pytest.mark.asyncio + async def test_a_turn_without_a_lease_clears_the_stale_one(self): + """Preview sessions and local dev run with no lease. + + Leaving the previous turn's handle in place would point the steering + hook at a row a later turn owns — the sticky-state shape #741/#751 + keep producing. + """ + sm = _SessionManager() + + await _collect(_FakeAgent(), sm, turn_lease=None) + + assert sm.turn_lease is None + + +class TestSteeringApplied: + @pytest.mark.asyncio + async def test_emits_one_frame_per_confirmed_injection(self): + hook = _Hook([{"id": "e1", "text": "use the other file"}]) + frames = await _collect(_FakeAgent(), _SessionManager(), _Wrapper(hook)) + + payloads = _steering_frames(frames) + assert payloads == [ + { + "type": "steering_applied", + "sessionId": "sess-1", + "entryId": "e1", + "text": "use the other file", + } + ] + + @pytest.mark.asyncio + async def test_emits_nothing_when_nothing_was_injected(self): + """The overwhelming majority of turns. The drain must stay silent.""" + hook = _Hook([]) + frames = await _collect(_FakeAgent(), _SessionManager(), _Wrapper(hook)) + + assert _steering_frames(frames) == [] + assert hook.drains > 0 # drained, just empty + + @pytest.mark.asyncio + async def test_frame_lands_before_done(self): + """The stranding case: nothing follows the injection but `done`. + + The agent here yields no events at all, so `done` is the only frame + the drain can precede. Draining after the yield instead would put the + ack past the terminal event, where the SPA's state gating drops it and + the user's follow-up stays queued forever. + """ + hook = _Hook([{"id": "e1", "text": "hi"}]) + frames = await _collect(_FakeAgent(), _SessionManager(), _Wrapper(hook)) + + steer_at = next( + i for i, f in enumerate(frames) if f.startswith("event: steering_applied\n") + ) + done_at = next(i for i, f in enumerate(frames) if f.startswith("event: done\n")) + # The SPA gates events on the stream state; anything after `done` is + # dropped unless explicitly allowlisted, and this event is not. + assert steer_at < done_at + + @pytest.mark.asyncio + async def test_a_wrapper_without_a_hook_is_inert(self): + """Voice and every test double take this path.""" + frames = await _collect(_FakeAgent(), _SessionManager(), object()) + assert _steering_frames(frames) == [] + + @pytest.mark.asyncio + async def test_no_wrapper_at_all_is_inert(self): + frames = await _collect(_FakeAgent(), _SessionManager(), None) + assert _steering_frames(frames) == [] + + @pytest.mark.asyncio + async def test_a_failing_drain_never_breaks_the_stream(self): + class _Exploding: + def drain_applied(self): + raise RuntimeError("boom") + + frames = await _collect(_FakeAgent(), _SessionManager(), _Wrapper(_Exploding())) + + assert _steering_frames(frames) == [] + assert any(f.startswith("event: done\n") for f in frames) diff --git a/backend/tests/agents/main_agent/streaming/test_stream_response_signature.py b/backend/tests/agents/main_agent/streaming/test_stream_response_signature.py index d628a1398..540a42f9d 100644 --- a/backend/tests/agents/main_agent/streaming/test_stream_response_signature.py +++ b/backend/tests/agents/main_agent/streaming/test_stream_response_signature.py @@ -109,6 +109,7 @@ async def test_every_kwarg_chat_agent_forwards_is_accepted(): citations=[{"source": "doc-1"}], original_message="a message", turn_agent_id="ast-canvas", + turn_lease=object(), ): pass diff --git a/backend/tests/apis/app_api/artifacts/test_artifact_library.py b/backend/tests/apis/app_api/artifacts/test_artifact_library.py new file mode 100644 index 000000000..e7b651c40 --- /dev/null +++ b/backend/tests/apis/app_api/artifacts/test_artifact_library.py @@ -0,0 +1,288 @@ +"""Tests for the app-api user-wide artifact library endpoint. + +`GET /artifacts/library` differs from the session list next door in +cardinality and in scoping: one row per *artifact* (not per version), +across *every* session (not one), served by a single base-table Query on +`PK=USER#{uid}` with no index involved. + +Ownership here is enforced by the partition key rather than re-checked +per row, which is the substantive difference from `list_for_session` — +that one reads a GSI partitioned by session and so has to filter. The +scoping test below exists to prove the key is actually doing that job. +""" + +from __future__ import annotations + +import boto3 +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from moto import mock_aws + +from apis.app_api.artifacts import service as artifact_service +from apis.app_api.artifacts.routes import router as artifacts_router +from apis.app_api.artifacts.service import ( + ArtifactListService, + ArtifactQueryError, + RenderTokenConfigError, + get_artifact_list_service, +) +from apis.shared.auth import User, get_current_user_from_session + +TABLE = "test-user-artifacts" +REGION = "us-east-1" +USER_ID = "user-123" +OTHER_USER = "user-456" + + +@pytest.fixture(autouse=True) +def _reset_caches() -> None: + artifact_service._reset_caches_for_tests() + + +@pytest.fixture +def client(monkeypatch: pytest.MonkeyPatch): + with mock_aws(): + monkeypatch.setenv("AWS_REGION", REGION) + boto3.client("dynamodb", region_name=REGION).create_table( + TableName=TABLE, + KeySchema=[ + {"AttributeName": "PK", "KeyType": "HASH"}, + {"AttributeName": "SK", "KeyType": "RANGE"}, + ], + AttributeDefinitions=[ + {"AttributeName": "PK", "AttributeType": "S"}, + {"AttributeName": "SK", "AttributeType": "S"}, + ], + BillingMode="PAY_PER_REQUEST", + ) + monkeypatch.setenv("DYNAMODB_ARTIFACTS_TABLE_NAME", TABLE) + + app = FastAPI() + app.include_router(artifacts_router) + app.dependency_overrides[get_current_user_from_session] = lambda: User( + email="u@x.com", user_id=USER_ID, name="U", roles=[] + ) + yield TestClient(app), boto3.resource("dynamodb", region_name=REGION) + + +def _put_artifact( + ddb, + *, + artifact: str, + user_id: str = USER_ID, + session_id: str = "sess-1", + title: str = "Doc", + updated_at: str | None = "2026-05-15T10:00:00+00:00", + content_type: str = "text/markdown", + versions: int = 1, +) -> None: + """A HEAD row plus its version rows, mirroring the writer. + + `updated_at=None` models a row written before the attribute existed. + The version rows matter: the library must not return them, and the + Query pays to read them, so a fixture without them would not + exercise the filter at all. + """ + table = ddb.Table(TABLE) + common = { + "storage": "s3", + "content_type": content_type, + "artifact_id": artifact, + "user_id": user_id, + "session_id": session_id, + "title": title, + "created_at": "2026-05-01T09:00:00+00:00", + } + for version in range(1, versions + 1): + table.put_item( + Item={ + **common, + "PK": f"USER#{user_id}", + "SK": f"ARTIFACT#{artifact}#V#{version:05d}", + "version": version, + "content_key": f"{user_id}/{artifact}/v{version}/index.html", + "updated_at": "2026-05-01T09:00:00+00:00", + } + ) + head = { + **common, + "PK": f"USER#{user_id}", + "SK": f"ARTIFACT#{artifact}#HEAD", + "version": versions, + "content_key": f"{user_id}/{artifact}/v{versions}/index.html", + "GSI1PK": f"SESSION#{session_id}", + } + if updated_at is not None: + head["updated_at"] = updated_at + head["GSI1SK"] = f"ARTIFACT#{updated_at}#{artifact}" + head["GSI2PK"] = f"USER#{user_id}" + head["GSI2SK"] = f"ARTIFACT#{updated_at}#{artifact}" + table.put_item(Item=head) + + +def test_returns_one_row_per_artifact_newest_first(client) -> None: + c, ddb = client + _put_artifact(ddb, artifact="a1", updated_at="2026-05-10T10:00:00+00:00") + _put_artifact( + ddb, artifact="a2", updated_at="2026-06-01T10:00:00+00:00", versions=4 + ) + _put_artifact(ddb, artifact="a3", updated_at="2026-05-20T10:00:00+00:00") + + res = c.get("/artifacts/library") + assert res.status_code == 200 + rows = res.json()["artifacts"] + + # One per artifact — a2 has four versions and still appears once. + assert [r["artifact_id"] for r in rows] == ["a2", "a3", "a1"] + assert rows[0]["version"] == 4 + + +def test_spans_every_session(client) -> None: + """The point of the library: artifacts outlive the chat that made + them, and the user's whole history is one partition.""" + c, ddb = client + _put_artifact( + ddb, artifact="a1", session_id="sess-1", + updated_at="2026-05-10T10:00:00+00:00", + ) + _put_artifact( + ddb, artifact="a2", session_id="sess-2", + updated_at="2026-05-11T10:00:00+00:00", + ) + + rows = c.get("/artifacts/library").json()["artifacts"] + assert {r["session_id"] for r in rows} == {"sess-1", "sess-2"} + + +def test_scopes_to_the_authenticated_user(client) -> None: + """Ownership rides the partition key. There is no request parameter + that could widen this, which is why the endpoint takes none.""" + c, ddb = client + _put_artifact(ddb, artifact="mine") + _put_artifact(ddb, artifact="theirs", user_id=OTHER_USER) + + rows = c.get("/artifacts/library").json()["artifacts"] + assert [r["artifact_id"] for r in rows] == ["mine"] + + +def test_undated_legacy_rows_are_returned_and_sort_last(client) -> None: + """A row predating `updated_at` must still be listed — dropping a + user's oldest artifacts would be worse than showing them undated — + but an empty sort key must not float it to the top.""" + c, ddb = client + _put_artifact(ddb, artifact="old", updated_at=None) + _put_artifact(ddb, artifact="new", updated_at="2026-05-10T10:00:00+00:00") + + rows = c.get("/artifacts/library").json()["artifacts"] + assert [r["artifact_id"] for r in rows] == ["new", "old"] + assert rows[1]["updated_at"] == "" + + +def test_carries_the_fields_the_library_renders(client) -> None: + c, ddb = client + _put_artifact( + ddb, artifact="a1", title="Budget model", + content_type="text/csv", session_id="sess-7", + ) + + row = c.get("/artifacts/library").json()["artifacts"][0] + assert row == { + "artifact_id": "a1", + "version": 1, + "title": "Budget model", + "content_type": "text/csv", + "created_at": "2026-05-01T09:00:00+00:00", + "updated_at": "2026-05-15T10:00:00+00:00", + "session_id": "sess-7", + } + + +def test_empty_library_is_an_empty_list(client) -> None: + c, _ = client + res = c.get("/artifacts/library") + assert res.status_code == 200 + assert res.json()["artifacts"] == [] + + +def test_query_failure_is_retryable_503(client) -> None: + c, _ = client + + class Failing(ArtifactListService): + def list_for_user(self, *, user_id: str): + raise ArtifactQueryError("boom") + + c.app.dependency_overrides[get_artifact_list_service] = Failing + assert c.get("/artifacts/library").status_code == 503 + + +def test_misconfiguration_is_500(client) -> None: + c, _ = client + + class Misconfigured(ArtifactListService): + def list_for_user(self, *, user_id: str): + raise RenderTokenConfigError("no table") + + c.app.dependency_overrides[get_artifact_list_service] = Misconfigured + assert c.get("/artifacts/library").status_code == 500 + + +def test_library_route_is_not_shadowed_by_the_artifact_id_route(client) -> None: + """`/artifacts/library` sits in the same space as + `/artifacts/{artifact_id}/content`. The literal must win rather than + be read as an artifact id.""" + c, ddb = client + _put_artifact(ddb, artifact="a1") + + res = c.get("/artifacts/library") + assert res.status_code == 200 + assert "artifacts" in res.json() + + +def test_paginates_a_partition_larger_than_one_page(client) -> None: + """The Query loop must drain `LastEvaluatedKey`. Asserted with a + stub rather than 1MB of fixture rows, since moto pages on real byte + size and a realistic partition is far under the limit.""" + calls: list[dict] = [] + + class Paged: + def query(self, **kwargs): + calls.append(kwargs) + if "ExclusiveStartKey" not in kwargs: + return { + "Items": [ + { + "PK": f"USER#{USER_ID}", + "SK": "ARTIFACT#a1#HEAD", + "artifact_id": "a1", + "version": 1, + "title": "One", + "content_type": "text/markdown", + "created_at": "2026-05-01T09:00:00+00:00", + "updated_at": "2026-05-01T09:00:00+00:00", + "session_id": "s1", + } + ], + "LastEvaluatedKey": {"PK": "x", "SK": "y"}, + } + return { + "Items": [ + { + "PK": f"USER#{USER_ID}", + "SK": "ARTIFACT#a2#HEAD", + "artifact_id": "a2", + "version": 1, + "title": "Two", + "content_type": "text/markdown", + "created_at": "2026-05-02T09:00:00+00:00", + "updated_at": "2026-05-02T09:00:00+00:00", + "session_id": "s2", + } + ] + } + + artifact_service._ddb_table = Paged() + rows = ArtifactListService().list_for_user(user_id=USER_ID) + + assert len(calls) == 2 + assert [r["artifact_id"] for r in rows] == ["a2", "a1"] diff --git a/backend/tests/apis/app_api/artifacts/test_artifact_lifecycle.py b/backend/tests/apis/app_api/artifacts/test_artifact_lifecycle.py new file mode 100644 index 000000000..8108c6ea2 --- /dev/null +++ b/backend/tests/apis/app_api/artifacts/test_artifact_lifecycle.py @@ -0,0 +1,499 @@ +"""Tests for artifact rename and delete (`PATCH`/`DELETE /artifacts/{id}`). + +Three things here are worth more than coverage: + +* **Rename must not touch `version` or `updated_at`.** `version` carries + the writer's optimistic lock and `updated_at` is embedded in the + HEAD row's GSI sort keys, which only the writer maintains. Both are + asserted explicitly rather than left to a round-trip of the response + body, because a regression on either is silent and expensive. +* **Delete must cascade to shares.** A surviving lookup row is a live + link to an artifact that no longer exists. +* **Ownership is enforced by the partition key**, so the scoping tests + assert the other user's rows are untouched, not merely that the call + 4-0-4s. +""" + +from __future__ import annotations + +from unittest.mock import patch + +import boto3 +import pytest +from botocore.exceptions import ClientError +from fastapi import FastAPI +from fastapi.testclient import TestClient +from moto import mock_aws + +from apis.app_api.artifacts import service as artifact_service +from apis.app_api.artifacts.routes import router as artifacts_router +from apis.app_api.artifacts.service import ( + ArtifactLifecycleService, + ArtifactQueryError, + get_artifact_lifecycle_service, +) +from apis.shared.auth import User, get_current_user_from_session + +TABLE = "test-user-artifacts" +BUCKET = "test-artifacts-bucket" +REGION = "us-east-1" +USER_ID = "user-123" +OTHER_USER = "user-456" + + +@pytest.fixture(autouse=True) +def _reset_caches() -> None: + artifact_service._reset_caches_for_tests() + + +@pytest.fixture +def client(monkeypatch: pytest.MonkeyPatch): + with mock_aws(): + monkeypatch.setenv("AWS_REGION", REGION) + + boto3.client("dynamodb", region_name=REGION).create_table( + TableName=TABLE, + KeySchema=[ + {"AttributeName": "PK", "KeyType": "HASH"}, + {"AttributeName": "SK", "KeyType": "RANGE"}, + ], + AttributeDefinitions=[ + {"AttributeName": "PK", "AttributeType": "S"}, + {"AttributeName": "SK", "AttributeType": "S"}, + ], + BillingMode="PAY_PER_REQUEST", + ) + s3 = boto3.client("s3", region_name=REGION) + s3.create_bucket(Bucket=BUCKET) + + monkeypatch.setenv("DYNAMODB_ARTIFACTS_TABLE_NAME", TABLE) + monkeypatch.setenv("S3_ARTIFACTS_BUCKET_NAME", BUCKET) + + app = FastAPI() + app.include_router(artifacts_router) + app.dependency_overrides[get_current_user_from_session] = ( + lambda: User( + email="u@x.com", user_id=USER_ID, name="U", roles=[] + ) + ) + yield ( + TestClient(app), + boto3.resource("dynamodb", region_name=REGION), + s3, + ) + + +# --------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------- + + +def _seed_artifact( + ddb, + s3, + *, + user_id: str = USER_ID, + artifact: str = "art-1", + versions: int = 2, + title: str = "Original title", + session_id: str = "sess-1", +) -> None: + """Write `versions` version rows + a HEAD row, with S3 objects.""" + table = ddb.Table(TABLE) + for version in range(1, versions + 1): + key = f"{user_id}/{artifact}/v{version}/index.html" + s3.put_object(Bucket=BUCKET, Key=key, Body=b"

hi

") + table.put_item( + Item={ + "PK": f"USER#{user_id}", + "SK": f"ARTIFACT#{artifact}#V#{version:05d}", + "storage": "s3", + "content_key": key, + "content_type": "text/html; charset=utf-8", + "version": version, + "artifact_id": artifact, + "user_id": user_id, + "session_id": session_id, + "title": title, + "created_at": "2026-01-01T00:00:00+00:00", + "updated_at": f"2026-01-0{version}T00:00:00+00:00", + } + ) + head_updated = f"2026-01-0{versions}T00:00:00+00:00" + table.put_item( + Item={ + "PK": f"USER#{user_id}", + "SK": f"ARTIFACT#{artifact}#HEAD", + "storage": "s3", + "content_key": f"{user_id}/{artifact}/v{versions}/index.html", + "content_type": "text/html; charset=utf-8", + "version": versions, + "artifact_id": artifact, + "user_id": user_id, + "session_id": session_id, + "title": title, + "created_at": "2026-01-01T00:00:00+00:00", + "updated_at": head_updated, + "GSI1PK": f"SESSION#{session_id}", + "GSI1SK": f"ARTIFACT#{head_updated}#{artifact}", + "GSI2PK": f"USER#{user_id}", + "GSI2SK": f"ARTIFACT#{head_updated}#{artifact}", + } + ) + + +def _seed_share( + ddb, + *, + owner_id: str = USER_ID, + artifact: str = "art-1", + version: int = 1, + share_id: str = "share-1", +) -> None: + """Both rows of one share, exactly as `_write_share_rows` writes them.""" + table = ddb.Table(TABLE) + attrs = { + "share_id": share_id, + "artifact_id": artifact, + "version": version, + "owner_id": owner_id, + "owner_email": "u@x.com", + "access_level": "public", + "title": "Original title", + "content_type": "text/html; charset=utf-8", + "created_at": "2026-01-01T00:00:00+00:00", + } + table.put_item( + Item={ + **attrs, + "PK": f"USER#{owner_id}", + "SK": f"SHARE#{artifact}#V#{version:05d}#{share_id}", + } + ) + table.put_item(Item={**attrs, "PK": f"SHARE#{share_id}", "SK": "META"}) + + +def _row(ddb, pk: str, sk: str) -> dict | None: + return ddb.Table(TABLE).get_item(Key={"PK": pk, "SK": sk}).get("Item") + + +def _tags(s3, key: str) -> dict: + resp = s3.get_object_tagging(Bucket=BUCKET, Key=key) + return {t["Key"]: t["Value"] for t in resp["TagSet"]} + + +# --------------------------------------------------------------------- +# Rename +# --------------------------------------------------------------------- + + +def test_rename_updates_head_and_every_version_row(client): + """The library reads HEAD, the session list reads version rows. If a + rename only reached HEAD, the same artifact would show two different + names in two places in the app.""" + api, ddb, s3 = client + _seed_artifact(ddb, s3, versions=3) + + resp = api.patch("/artifacts/art-1", json={"title": "Renamed"}) + + assert resp.status_code == 200 + assert resp.json()["title"] == "Renamed" + assert _row(ddb, f"USER#{USER_ID}", "ARTIFACT#art-1#HEAD")["title"] == ( + "Renamed" + ) + for version in (1, 2, 3): + row = _row( + ddb, f"USER#{USER_ID}", f"ARTIFACT#art-1#V#{version:05d}" + ) + assert row["title"] == "Renamed" + + +def test_rename_cascades_the_title_onto_share_rows(client): + """A recipient must not be left looking at the name the artifact had + on the day it was shared. + + Share rows denormalize `title` so the recipient header needs no + second read, and nothing kept them current: the owner saw the new + name on every surface they own while every recipient kept seeing the + old one, with neither party able to notice the difference.""" + api, ddb, s3 = client + _seed_artifact(ddb, s3, versions=2) + _seed_share(ddb, version=1, share_id="share-1") + _seed_share(ddb, version=2, share_id="share-2") + + assert api.patch( + "/artifacts/art-1", json={"title": "Renamed"} + ).status_code == 200 + + for share_id, version in (("share-1", 1), ("share-2", 2)): + # Both rows, always together — the owner's view of a share and + # the one the recipient path resolves must never disagree. + owner_row = _row( + ddb, + f"USER#{USER_ID}", + f"SHARE#art-1#V#{version:05d}#{share_id}", + ) + lookup_row = _row(ddb, f"SHARE#{share_id}", "META") + assert owner_row["title"] == "Renamed" + assert lookup_row["title"] == "Renamed" + + +def test_rename_leaves_shares_of_other_artifacts_alone(client): + api, ddb, s3 = client + _seed_artifact(ddb, s3, versions=1) + _seed_share(ddb, artifact="art-2", share_id="other-share") + + assert api.patch( + "/artifacts/art-1", json={"title": "Renamed"} + ).status_code == 200 + + assert _row(ddb, "SHARE#other-share", "META")["title"] == ( + "Original title" + ) + + +def test_rename_succeeds_even_if_the_share_cascade_fails( + client, monkeypatch +): + """The rows that decide what the OWNER sees are written first, so a + share that cannot be retitled is exactly the old behaviour rather + than a failed rename. Reporting failure here would be worse: the + rename the caller asked for has already happened.""" + api, ddb, s3 = client + _seed_artifact(ddb, s3, versions=1) + _seed_share(ddb) + + def boom(*_args, **_kwargs): + raise RuntimeError("dynamo is down") + + monkeypatch.setattr( + artifact_service.ArtifactShareService, + "_shares_for_artifact", + staticmethod(boom), + ) + + resp = api.patch("/artifacts/art-1", json={"title": "Renamed"}) + + assert resp.status_code == 200 + assert _row(ddb, f"USER#{USER_ID}", "ARTIFACT#art-1#HEAD")["title"] == ( + "Renamed" + ) + + +def test_rename_does_not_touch_version_or_updated_at(client): + """`version` is the writer's optimistic-lock attribute and + `updated_at` is baked into HEAD's GSI sort keys. A rename that moved + either would race concurrent agent updates, or split the library's + ordering from the session index's.""" + api, ddb, s3 = client + _seed_artifact(ddb, s3, versions=2) + before = _row(ddb, f"USER#{USER_ID}", "ARTIFACT#art-1#HEAD") + + api.patch("/artifacts/art-1", json={"title": "Renamed"}) + + after = _row(ddb, f"USER#{USER_ID}", "ARTIFACT#art-1#HEAD") + assert after["version"] == before["version"] + assert after["updated_at"] == before["updated_at"] + assert after["GSI1SK"] == before["GSI1SK"] + assert after["GSI2SK"] == before["GSI2SK"] + # ...and the rename is still recorded, just on an attribute nothing + # sorts on. + assert after["renamed_at"] + + +def test_rename_trims_surrounding_whitespace(client): + api, ddb, s3 = client + _seed_artifact(ddb, s3, versions=1) + + resp = api.patch("/artifacts/art-1", json={"title": " Spaced "}) + + assert resp.status_code == 200 + assert resp.json()["title"] == "Spaced" + + +def test_rename_rejects_a_whitespace_only_title(client): + """Passes the model's `min_length=1` but is empty once trimmed, so + the service is the layer that has to catch it.""" + api, ddb, s3 = client + _seed_artifact(ddb, s3, versions=1) + + resp = api.patch("/artifacts/art-1", json={"title": " "}) + + assert resp.status_code == 400 + assert _row(ddb, f"USER#{USER_ID}", "ARTIFACT#art-1#HEAD")["title"] == ( + "Original title" + ) + + +def test_rename_rejects_an_empty_title(client): + api, ddb, s3 = client + _seed_artifact(ddb, s3, versions=1) + + assert api.patch("/artifacts/art-1", json={"title": ""}).status_code == 422 + + +def test_rename_rejects_an_overlong_title(client): + api, ddb, s3 = client + _seed_artifact(ddb, s3, versions=1) + + resp = api.patch( + "/artifacts/art-1", + json={"title": "x" * (artifact_service.MAX_ARTIFACT_TITLE_LENGTH + 1)}, + ) + + assert resp.status_code == 422 + + +def test_rename_cannot_reach_another_users_artifact(client): + """The lookup key is built from the session, so someone else's id is + an indistinguishable 404 — and their row must be untouched.""" + api, ddb, s3 = client + _seed_artifact(ddb, s3, user_id=OTHER_USER, artifact="art-9") + + resp = api.patch("/artifacts/art-9", json={"title": "Hijacked"}) + + assert resp.status_code == 404 + assert _row(ddb, f"USER#{OTHER_USER}", "ARTIFACT#art-9#HEAD")["title"] == ( + "Original title" + ) + + +def test_rename_of_an_unknown_artifact_is_404(client): + api, _ddb, _s3 = client + assert api.patch("/artifacts/nope", json={"title": "x"}).status_code == 404 + + +# --------------------------------------------------------------------- +# Delete +# --------------------------------------------------------------------- + + +def test_delete_removes_head_and_every_version_row(client): + """Not just the HEAD pointer: prior versions are independently + addressable (the panel's version picker mints a token per version), + so leaving them would leave the artifact live and unlistable.""" + api, ddb, s3 = client + _seed_artifact(ddb, s3, versions=3) + + resp = api.delete("/artifacts/art-1") + + assert resp.status_code == 204 + assert _row(ddb, f"USER#{USER_ID}", "ARTIFACT#art-1#HEAD") is None + for version in (1, 2, 3): + assert ( + _row(ddb, f"USER#{USER_ID}", f"ARTIFACT#art-1#V#{version:05d}") + is None + ) + + +def test_delete_tags_every_object_for_lifecycle_expiry(client): + """The bucket's `expire-soft-deleted` rule filters on this exact tag. + It is the only handle left on the object once the row holding its + `content_key` is gone.""" + api, ddb, s3 = client + _seed_artifact(ddb, s3, versions=2) + + api.delete("/artifacts/art-1") + + for version in (1, 2): + key = f"{USER_ID}/art-1/v{version}/index.html" + assert _tags(s3, key) == {"lifecycle-class": "deleted"} + + +def test_delete_revokes_every_share_of_the_artifact(client): + """Shares are per-version, so the cascade sweeps the whole prefix — + otherwise a link handed out for v1 outlives what it points at.""" + api, ddb, s3 = client + _seed_artifact(ddb, s3, versions=2) + _seed_share(ddb, version=1, share_id="share-1") + _seed_share(ddb, version=2, share_id="share-2") + + api.delete("/artifacts/art-1") + + for share_id, version in (("share-1", 1), ("share-2", 2)): + assert _row(ddb, f"SHARE#{share_id}", "META") is None + assert ( + _row( + ddb, + f"USER#{USER_ID}", + f"SHARE#art-1#V#{version:05d}#{share_id}", + ) + is None + ) + + +def test_delete_leaves_shares_of_other_artifacts_alone(client): + api, ddb, s3 = client + _seed_artifact(ddb, s3, artifact="art-1", versions=1) + _seed_artifact(ddb, s3, artifact="art-2", versions=1) + _seed_share(ddb, artifact="art-2", version=1, share_id="keep-me") + + api.delete("/artifacts/art-1") + + assert _row(ddb, "SHARE#keep-me", "META") is not None + assert _row(ddb, f"USER#{USER_ID}", "ARTIFACT#art-2#HEAD") is not None + + +def test_delete_cannot_reach_another_users_artifact(client): + api, ddb, s3 = client + _seed_artifact(ddb, s3, user_id=OTHER_USER, artifact="art-9") + + resp = api.delete("/artifacts/art-9") + + assert resp.status_code == 404 + assert _row(ddb, f"USER#{OTHER_USER}", "ARTIFACT#art-9#HEAD") is not None + assert ( + _row(ddb, f"USER#{OTHER_USER}", "ARTIFACT#art-9#V#00001") is not None + ) + + +def test_delete_of_an_unknown_artifact_is_404(client): + api, _ddb, _s3 = client + assert api.delete("/artifacts/nope").status_code == 404 + + +def test_delete_completes_when_object_tagging_fails(client): + """A failed tag costs unreclaimed bytes. Aborting over it would cost + the user a delete that visibly did nothing, which is worse — the + object is already unreachable once its row is gone.""" + api, ddb, s3 = client + _seed_artifact(ddb, s3, versions=1) + + failure = ClientError( + {"Error": {"Code": "AccessDenied", "Message": "nope"}}, + "PutObjectTagging", + ) + with patch.object( + artifact_service, "_s3" + ) as fake_s3: + fake_s3.return_value.put_object_tagging.side_effect = failure + resp = api.delete("/artifacts/art-1") + + assert resp.status_code == 204 + assert _row(ddb, f"USER#{USER_ID}", "ARTIFACT#art-1#HEAD") is None + + +def test_delete_stops_before_touching_rows_when_shares_cannot_be_listed( + client, +): + """Enumeration runs first precisely so a failure here is a clean + no-op — the alternative is deleting an artifact while blind to the + live links pointing at it.""" + api, ddb, s3 = client + _seed_artifact(ddb, s3, versions=2) + + service = ArtifactLifecycleService() + with patch.object( + service._shares, + "revoke_for_artifact", + side_effect=ArtifactQueryError("boom"), + ): + api.app.dependency_overrides[get_artifact_lifecycle_service] = ( + lambda: service + ) + resp = api.delete("/artifacts/art-1") + api.app.dependency_overrides.pop(get_artifact_lifecycle_service) + + assert resp.status_code == 503 + assert _row(ddb, f"USER#{USER_ID}", "ARTIFACT#art-1#HEAD") is not None + assert _row(ddb, f"USER#{USER_ID}", "ARTIFACT#art-1#V#00001") is not None diff --git a/backend/tests/apis/app_api/artifacts/test_artifact_share_inbox.py b/backend/tests/apis/app_api/artifacts/test_artifact_share_inbox.py new file mode 100644 index 000000000..47a350c0d --- /dev/null +++ b/backend/tests/apis/app_api/artifacts/test_artifact_share_inbox.py @@ -0,0 +1,634 @@ +"""Tests for the recipient share inbox ("Shared with you"). + +Three things are under test here, and they are not the same thing: + +* the **fan-out rows** — written unconditionally by every share write, + torn down by every teardown path; +* the **inbox read** — which never trusts those rows, resolving each one + through the share lookup row before showing or counting it; +* the **flag** — which gates the read and must never gate the write. + +The last of those is the one worth breaking a test over: if the writes +were ever put behind the flag, enabling it would surface an inbox +missing every share created while it was off. +""" + +from __future__ import annotations + +import base64 + +import boto3 +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from moto import mock_aws + +from apis.app_api.artifacts import service as token_service +from apis.app_api.artifacts.shares import ( + artifact_shares_router, + shared_artifacts_router, +) +from apis.shared.auth import User, get_current_user_from_session + +TABLE = "test-user-artifacts" +REGION = "us-east-1" +OWNER_ID = "owner-1" +OWNER_EMAIL = "owner@x.com" +FRIEND_ID = "friend-1" +FRIEND_EMAIL = "friend@x.com" + + +@pytest.fixture(autouse=True) +def _reset_caches() -> None: + token_service._reset_caches_for_tests() + + +@pytest.fixture(autouse=True) +def _inbox_on(monkeypatch: pytest.MonkeyPatch) -> None: + """Most tests here exercise the surface, so default it on. The tests + that care about the flag set it themselves.""" + monkeypatch.setenv("ARTIFACT_SHARE_INBOX_ENABLED", "true") + + +def _owner() -> User: + return User(email=OWNER_EMAIL, user_id=OWNER_ID, name="Owner", roles=[]) + + +def _friend(email: str = FRIEND_EMAIL) -> User: + return User(email=email, user_id=FRIEND_ID, name="Friend", roles=[]) + + +@pytest.fixture +def env(monkeypatch: pytest.MonkeyPatch): + with mock_aws(): + monkeypatch.setenv("AWS_REGION", REGION) + boto3.client("dynamodb", region_name=REGION).create_table( + TableName=TABLE, + KeySchema=[ + {"AttributeName": "PK", "KeyType": "HASH"}, + {"AttributeName": "SK", "KeyType": "RANGE"}, + ], + AttributeDefinitions=[ + {"AttributeName": "PK", "AttributeType": "S"}, + {"AttributeName": "SK", "AttributeType": "S"}, + {"AttributeName": "GSI1PK", "AttributeType": "S"}, + {"AttributeName": "GSI1SK", "AttributeType": "S"}, + ], + BillingMode="PAY_PER_REQUEST", + GlobalSecondaryIndexes=[ + { + "IndexName": "SessionIndex", + "KeySchema": [ + {"AttributeName": "GSI1PK", "KeyType": "HASH"}, + {"AttributeName": "GSI1SK", "KeyType": "RANGE"}, + ], + "Projection": {"ProjectionType": "ALL"}, + } + ], + ) + monkeypatch.setenv("DYNAMODB_ARTIFACTS_TABLE_NAME", TABLE) + monkeypatch.setenv("ARTIFACTS_ORIGIN", "https://a.test.example.com") + + def make_client(user: User | None = None) -> TestClient: + app = FastAPI() + app.include_router(artifact_shares_router) + app.include_router(shared_artifacts_router) + app.dependency_overrides[get_current_user_from_session] = ( + lambda: user or _owner() + ) + return TestClient(app) + + yield make_client, boto3.resource("dynamodb", region_name=REGION) + + +def _put_version( + ddb, + *, + artifact: str = "art-1", + version: int = 1, + title: str = "My Chart", + session_id: str = "sess-1", +) -> None: + ddb.Table(TABLE).put_item( + Item={ + "PK": f"USER#{OWNER_ID}", + "SK": f"ARTIFACT#{artifact}#V#{version:05d}", + "artifact_id": artifact, + "user_id": OWNER_ID, + "version": version, + "storage": "s3", + "content_key": f"{OWNER_ID}/{artifact}/v{version}/index.html", + "content_type": "text/html; charset=utf-8", + "title": title, + "session_id": session_id, + } + ) + + +def _share_with( + tc: TestClient, emails: list[str], *, artifact: str = "art-1" +) -> dict: + resp = tc.post( + f"/artifacts/{artifact}/shares", + json={ + "version": 1, + "accessLevel": "specific", + "allowedEmails": emails, + }, + ) + assert resp.status_code == 201, resp.text + return resp.json() + + +def _recipient_rows(ddb, email: str) -> list[dict]: + resp = ddb.Table(TABLE).query( + KeyConditionExpression=boto3.dynamodb.conditions.Key("PK").eq( + f"SHARED_WITH#{email}" + ) + ) + return resp.get("Items", []) + + +def _inbox(tc: TestClient, **params) -> dict: + resp = tc.get("/shared-artifacts", params=params) + assert resp.status_code == 200, resp.text + return resp.json() + + +# ------------------------------------------------------------------ +# Fan-out rows +# ------------------------------------------------------------------ + + +def test_share_fans_out_one_row_per_recipient(env) -> None: + make_client, ddb = env + _put_version(ddb) + _share_with(make_client(), [FRIEND_EMAIL, "other@x.com"]) + + assert len(_recipient_rows(ddb, FRIEND_EMAIL)) == 1 + assert len(_recipient_rows(ddb, "other@x.com")) == 1 + + +def test_fan_out_row_is_a_pointer_carrying_no_title(env) -> None: + """The row must not denormalize display fields. + + Share rows carry a title; copying it per recipient would multiply + every future staleness bug by the size of the allowlist, and would + make a rename cost one write per recipient instead of one per + share.""" + make_client, ddb = env + _put_version(ddb, title="Quarterly Deck") + _share_with(make_client(), [FRIEND_EMAIL]) + + row = _recipient_rows(ddb, FRIEND_EMAIL)[0] + assert "title" not in row + assert "content_type" not in row + assert row["share_id"] + assert row["owner_id"] == OWNER_ID + + +def test_fan_out_normalizes_the_email_to_lower_case(env) -> None: + """Addresses are stored as typed and lowercased only at compare time, + so the partition key has to fold explicitly. Without this, sharing to + a capitalised address returns an empty inbox to the person it was + shared with — a wrong answer that looks exactly like "nobody has + shared anything with you".""" + make_client, ddb = env + _put_version(ddb) + _share_with(make_client(), ["Friend@X.com"]) + + assert len(_recipient_rows(ddb, FRIEND_EMAIL)) == 1 + + body = _inbox(make_client(_friend())) + assert len(body["artifacts"]) == 1 + + +def test_owner_is_not_fanned_out_to_themselves(env) -> None: + """`_resolve_allowed_emails` deliberately keeps the owner on the + allowlist, so the fan-out has to filter them back out — otherwise + sharing your own artifact files it under "shared with you".""" + make_client, ddb = env + _put_version(ddb) + _share_with(make_client(), [FRIEND_EMAIL]) + + assert _recipient_rows(ddb, OWNER_EMAIL) == [] + assert _inbox(make_client())["artifacts"] == [] + + +def test_public_shares_are_not_fanned_out(env) -> None: + """"Public" means any authenticated tenant user — there is no + recipient list to fan out to, and an inbox listing every public share + in the tenant is a different feature.""" + make_client, ddb = env + _put_version(ddb) + resp = make_client().post( + "/artifacts/art-1/shares", + json={"version": 1, "accessLevel": "public"}, + ) + assert resp.status_code == 201 + + assert _recipient_rows(ddb, FRIEND_EMAIL) == [] + assert _inbox(make_client(_friend()))["artifacts"] == [] + + +def test_update_diffs_the_allowlist_rather_than_rewriting_it(env) -> None: + make_client, ddb = env + _put_version(ddb) + share = _share_with(make_client(), [FRIEND_EMAIL, "dropped@x.com"]) + + resp = make_client().patch( + f"/artifacts/shares/{share['shareId']}", + json={ + "accessLevel": "specific", + "allowedEmails": [FRIEND_EMAIL, "added@x.com"], + }, + ) + assert resp.status_code == 200, resp.text + + assert len(_recipient_rows(ddb, FRIEND_EMAIL)) == 1 # kept, not duped + assert len(_recipient_rows(ddb, "added@x.com")) == 1 + assert _recipient_rows(ddb, "dropped@x.com") == [] + + +def test_switching_a_share_to_public_clears_every_inbox(env) -> None: + make_client, ddb = env + _put_version(ddb) + share = _share_with(make_client(), [FRIEND_EMAIL]) + + resp = make_client().patch( + f"/artifacts/shares/{share['shareId']}", + json={"accessLevel": "public"}, + ) + assert resp.status_code == 200, resp.text + + # Still reachable by link, no longer discoverable. + assert _recipient_rows(ddb, FRIEND_EMAIL) == [] + assert ( + make_client(_friend()).get( + f"/shared-artifacts/{share['shareId']}" + ).status_code + == 200 + ) + + +def test_revoke_removes_the_fan_out_rows(env) -> None: + make_client, ddb = env + _put_version(ddb) + share = _share_with(make_client(), [FRIEND_EMAIL]) + + assert make_client().delete( + f"/artifacts/shares/{share['shareId']}" + ).status_code == 204 + + assert _recipient_rows(ddb, FRIEND_EMAIL) == [] + assert _inbox(make_client(_friend()))["artifacts"] == [] + + +# ------------------------------------------------------------------ +# The read — never trusts the pointer +# ------------------------------------------------------------------ + + +def test_inbox_lists_shares_received(env) -> None: + make_client, ddb = env + _put_version(ddb, title="Quarterly Deck") + share = _share_with(make_client(), [FRIEND_EMAIL]) + + body = _inbox(make_client(_friend())) + assert len(body["artifacts"]) == 1 + row = body["artifacts"][0] + assert row["shareId"] == share["shareId"] + assert row["title"] == "Quarterly Deck" + assert row["ownerEmail"] == OWNER_EMAIL + assert row["shareUrl"] == f"/shared-artifact/{share['shareId']}" + assert body["nextCursor"] is None + + +def test_inbox_never_leaks_the_rest_of_the_allowlist(env) -> None: + """The recipient shape must not carry `allowedEmails` or the owner's + internal ids — a recipient learns who shared with them, not who else + it was shared with.""" + make_client, ddb = env + _put_version(ddb) + _share_with(make_client(), [FRIEND_EMAIL, "someone.else@x.com"]) + + row = _inbox(make_client(_friend()))["artifacts"][0] + assert "allowedEmails" not in row + assert "ownerId" not in row + assert "artifactId" not in row + assert "someone.else@x.com" not in str(row) + + +def test_a_stranded_pointer_lists_nothing(env) -> None: + """A fan-out row whose share is gone must resolve to nothing. + + This is what makes best-effort fan-out safe: teardown that fails + halfway, or a crash between the two passes, can leave a pointer + behind, and it must never become a permanent tombstone in somebody's + inbox.""" + make_client, ddb = env + _put_version(ddb) + share = _share_with(make_client(), [FRIEND_EMAIL]) + + # Kill the share the way a half-finished revoke would, leaving the + # pointer in place. + ddb.Table(TABLE).delete_item( + Key={"PK": f"SHARE#{share['shareId']}", "SK": "META"} + ) + assert len(_recipient_rows(ddb, FRIEND_EMAIL)) == 1 + + assert _inbox(make_client(_friend()))["artifacts"] == [] + + +def test_a_pointer_whose_allowlist_dropped_you_lists_nothing(env) -> None: + """Access is re-checked per row against the live share, so the inbox + and the recipient page can never disagree about who may see what.""" + make_client, ddb = env + _put_version(ddb) + share = _share_with(make_client(), [FRIEND_EMAIL]) + + # Rewrite the allowlist behind the fan-out row's back, as a partially + # failed update would. + table = ddb.Table(TABLE) + for key in ( + {"PK": f"SHARE#{share['shareId']}", "SK": "META"}, + { + "PK": f"USER#{OWNER_ID}", + "SK": f"SHARE#art-1#V#00001#{share['shareId']}", + }, + ): + table.update_item( + Key=key, + UpdateExpression="SET allowed_emails = :e", + ExpressionAttributeValues={":e": [OWNER_EMAIL]}, + ) + + assert _inbox(make_client(_friend()))["artifacts"] == [] + + +def test_your_own_share_never_appears_in_your_inbox(env) -> None: + make_client, ddb = env + _put_version(ddb) + # Fan-out row planted directly, so this tests the read's guard rather + # than the write's. + share = _share_with(make_client(), [FRIEND_EMAIL]) + ddb.Table(TABLE).put_item( + Item={ + "PK": f"SHARED_WITH#{OWNER_EMAIL}", + "SK": f"SHARE#2026-01-01T00:00:00+00:00#{share['shareId']}", + "share_id": share["shareId"], + "owner_id": OWNER_ID, + "owner_email": OWNER_EMAIL, + "shared_at": "2026-01-01T00:00:00+00:00", + } + ) + + assert _inbox(make_client())["artifacts"] == [] + + +def test_inbox_is_scoped_to_the_caller(env) -> None: + make_client, ddb = env + _put_version(ddb) + _share_with(make_client(), [FRIEND_EMAIL]) + + stranger = User( + email="stranger@x.com", user_id="s-1", name="S", roles=[] + ) + assert _inbox(make_client(stranger))["artifacts"] == [] + + +def test_a_viewer_with_no_email_gets_an_empty_inbox(env) -> None: + make_client, ddb = env + _put_version(ddb) + _share_with(make_client(), [FRIEND_EMAIL]) + + nameless = User(email="", user_id="n-1", name="N", roles=[]) + body = _inbox(make_client(nameless)) + assert body["artifacts"] == [] + assert body["nextCursor"] is None + + +# ------------------------------------------------------------------ +# Pagination +# ------------------------------------------------------------------ + + +def test_inbox_pages_newest_first(env) -> None: + make_client, ddb = env + for n in range(1, 4): + _put_version(ddb, artifact=f"art-{n}", title=f"Doc {n}") + _share_with(make_client(), [FRIEND_EMAIL], artifact=f"art-{n}") + + friend = make_client(_friend()) + first = _inbox(friend, limit=2) + assert len(first["artifacts"]) == 2 + assert first["nextCursor"] + + second = _inbox(friend, limit=2, cursor=first["nextCursor"]) + seen = [a["shareId"] for a in first["artifacts"] + second["artifacts"]] + assert len(set(seen)) == 3 # every share exactly once, no overlap + + +def test_a_cursor_cannot_reach_another_partition(env) -> None: + """The cursor carries the sort key only; the partition is rebuilt from + the session. A cursor forged to name someone else's partition must + page the caller's own inbox, not theirs.""" + make_client, ddb = env + _put_version(ddb) + _share_with(make_client(), [FRIEND_EMAIL]) + + forged = base64.urlsafe_b64encode( + f"SHARED_WITH#{FRIEND_EMAIL}".encode() + ).decode() + stranger = User( + email="stranger@x.com", user_id="s-1", name="S", roles=[] + ) + assert _inbox(make_client(stranger), cursor=forged)["artifacts"] == [] + + +def test_a_malformed_cursor_restarts_rather_than_erroring(env) -> None: + make_client, ddb = env + _put_version(ddb) + _share_with(make_client(), [FRIEND_EMAIL]) + + body = _inbox(make_client(_friend()), cursor="not-base64!!") + assert len(body["artifacts"]) == 1 + + +# ------------------------------------------------------------------ +# The flag +# ------------------------------------------------------------------ + + +def test_inbox_404s_while_the_flag_is_off( + env, monkeypatch: pytest.MonkeyPatch +) -> None: + make_client, ddb = env + monkeypatch.delenv("ARTIFACT_SHARE_INBOX_ENABLED", raising=False) + _put_version(ddb) + _share_with(make_client(), [FRIEND_EMAIL]) + + assert make_client(_friend()).get("/shared-artifacts").status_code == 404 + + +def test_fan_out_rows_are_written_while_the_flag_is_off( + env, monkeypatch: pytest.MonkeyPatch +) -> None: + """The load-bearing test for the whole flag design. + + If the writes were gated too, turning the flag on would reveal an + inbox missing every share created while it was off — a wrong answer + rather than an empty one, and one with no backfill to fix it. So a + share created dark must still be discoverable the moment the flag + flips.""" + make_client, ddb = env + monkeypatch.setenv("ARTIFACT_SHARE_INBOX_ENABLED", "false") + _put_version(ddb) + _share_with(make_client(), [FRIEND_EMAIL]) + + assert len(_recipient_rows(ddb, FRIEND_EMAIL)) == 1 + + monkeypatch.setenv("ARTIFACT_SHARE_INBOX_ENABLED", "true") + assert len(_inbox(make_client(_friend()))["artifacts"]) == 1 + + +def test_only_the_literal_true_enables_the_inbox( + env, monkeypatch: pytest.MonkeyPatch +) -> None: + """Opt-in, not a kill switch. An unset GitHub Actions variable + forwards an empty string, which must resolve to off rather than + revealing the surface by accident.""" + make_client, ddb = env + _put_version(ddb) + _share_with(make_client(), [FRIEND_EMAIL]) + friend = make_client(_friend()) + + for value in ("", " ", "false", "1", "yes", "TRUE!"): + monkeypatch.setenv("ARTIFACT_SHARE_INBOX_ENABLED", value) + assert friend.get("/shared-artifacts").status_code == 404, value + + for value in ("true", "TRUE", " True "): + monkeypatch.setenv("ARTIFACT_SHARE_INBOX_ENABLED", value) + assert friend.get("/shared-artifacts").status_code == 200, value + + +# ------------------------------------------------------------------ +# Teardown ordering and the IAM surface +# ------------------------------------------------------------------ + + +class _RecordingTable: + """Delegates to the real table, recording the DynamoDB API used and, + for deletes, which key space was hit.""" + + def __init__(self, inner, calls: list): + self._inner = inner + self._calls = calls + + def __getattr__(self, name): + attr = getattr(self._inner, name) + if callable(attr): + + def _recorded(*args, **kwargs): + self._calls.append((name, None)) + return attr(*args, **kwargs) + + return _recorded + return attr + + def delete_item(self, Key): # noqa: N803 — boto3 kwarg name + pk = Key["PK"] + if pk.startswith("SHARED_WITH#"): + space = "recipient" + elif pk.startswith("SHARE#"): + space = "lookup" + else: + space = "owner" + self._calls.append(("delete_item", space)) + return self._inner.delete_item(Key=Key) + + +def _record(monkeypatch) -> list: + calls: list = [] + real = token_service._table() + monkeypatch.setattr( + token_service, "_table", lambda: _RecordingTable(real, calls) + ) + return calls + + +def test_revoke_deletes_recipient_rows_before_the_lookup_row( + env, monkeypatch: pytest.MonkeyPatch +) -> None: + """Discovery dies before reachability. + + A crash between the two then leaves a live share nobody can find, + which is inert. The reverse order leaves a dead share sitting in + somebody's inbox. Both orders look identical when nothing fails, so + assert the order rather than the end state.""" + make_client, ddb = env + _put_version(ddb) + share = _share_with(make_client(), [FRIEND_EMAIL]) + + calls = _record(monkeypatch) + assert ( + make_client().delete( + f"/artifacts/shares/{share['shareId']}" + ).status_code + == 204 + ) + + # The owner and lookup rows go together in the transaction that + # follows, reached via `table.meta.client` — which this recorder + # cannot see — so `delete_item` here is exactly the fan-out pass, and + # its presence proves the fan-out ran before that transaction. + spaces = [space for name, space in calls if name == "delete_item"] + assert spaces == ["recipient"], spaces + # End state, so the ordering assertion above cannot pass on a revoke + # that did nothing else. + assert _recipient_rows(ddb, FRIEND_EMAIL) == [] + assert ( + ddb.Table(TABLE) + .get_item(Key={"PK": f"SHARE#{share['shareId']}", "SK": "META"}) + .get("Item") + is None + ) + + +def test_inbox_read_uses_only_iam_granted_dynamodb_actions( + env, monkeypatch: pytest.MonkeyPatch +) -> None: + """Same guard as the delete cascade's, for the read path. + + The app-api task role holds GetItem/PutItem/UpdateItem/DeleteItem/ + Query on this table and nothing else. `BatchGetItem` is its own IAM + action and is NOT covered by those, so resolving inbox rows with a + batch read would fail closed in a deployed environment while passing + every moto test — exactly how `BatchWriteItem` shipped broken in the + session-delete cascade. Pinning the surface is the only way a unit + test can catch it.""" + make_client, ddb = env + _put_version(ddb) + _share_with(make_client(), [FRIEND_EMAIL]) + + calls = _record(monkeypatch) + _inbox(make_client(_friend())) + + used = {name for name, _ in calls} + assert "batch_get_item" not in used, used + assert used <= {"query", "get_item"}, used + + +def test_fan_out_write_uses_only_iam_granted_dynamodb_actions( + env, monkeypatch: pytest.MonkeyPatch +) -> None: + """And the write path. `BatchWriteItem` is the trap here.""" + make_client, ddb = env + _put_version(ddb) + + calls = _record(monkeypatch) + _share_with(make_client(), [FRIEND_EMAIL, "other@x.com"]) + + used = {name for name, _ in calls} + assert "batch_writer" not in used, used + assert "batch_write_item" not in used, used diff --git a/backend/tests/apis/app_api/artifacts/test_artifact_shares.py b/backend/tests/apis/app_api/artifacts/test_artifact_shares.py new file mode 100644 index 000000000..d918b641b --- /dev/null +++ b/backend/tests/apis/app_api/artifacts/test_artifact_shares.py @@ -0,0 +1,780 @@ +"""Tests for artifact-share CRUD (owner side). + +Covers the two-row transactional write, partition-scoped listing, owner +enforcement on mutation, and revocation. The security-critical mint path +lives in `test_shared_render_token.py`. +""" + +from __future__ import annotations + +import boto3 +import pytest +from botocore.exceptions import ClientError +from fastapi import FastAPI +from fastapi.testclient import TestClient +from moto import mock_aws + +from apis.app_api.artifacts import service as token_service +from apis.app_api.artifacts.shares import ( + artifact_shares_router, + shared_artifacts_router, +) +from apis.shared.auth import User, get_current_user_from_session + +TABLE = "test-user-artifacts" +REGION = "us-east-1" +OWNER_ID = "owner-1" +OWNER_EMAIL = "owner@x.com" + + +@pytest.fixture(autouse=True) +def _reset_caches() -> None: + token_service._reset_caches_for_tests() + + +def _owner() -> User: + return User( + email=OWNER_EMAIL, user_id=OWNER_ID, name="Owner", roles=[] + ) + + +@pytest.fixture +def env(monkeypatch: pytest.MonkeyPatch): + """A mocked artifacts table plus an app mounting both share routers. + + Yields (make_client, ddb) so a test can re-bind the authenticated + user without rebuilding the table. + """ + with mock_aws(): + monkeypatch.setenv("AWS_REGION", REGION) + ddb = boto3.client("dynamodb", region_name=REGION) + ddb.create_table( + TableName=TABLE, + KeySchema=[ + {"AttributeName": "PK", "KeyType": "HASH"}, + {"AttributeName": "SK", "KeyType": "RANGE"}, + ], + AttributeDefinitions=[ + {"AttributeName": "PK", "AttributeType": "S"}, + {"AttributeName": "SK", "AttributeType": "S"}, + {"AttributeName": "GSI1PK", "AttributeType": "S"}, + {"AttributeName": "GSI1SK", "AttributeType": "S"}, + ], + BillingMode="PAY_PER_REQUEST", + # The session-delete cascade walks SessionIndex to find the + # session's artifacts, so the fixture mirrors the real table. + GlobalSecondaryIndexes=[ + { + "IndexName": "SessionIndex", + "KeySchema": [ + {"AttributeName": "GSI1PK", "KeyType": "HASH"}, + {"AttributeName": "GSI1SK", "KeyType": "RANGE"}, + ], + "Projection": {"ProjectionType": "ALL"}, + } + ], + ) + monkeypatch.setenv("DYNAMODB_ARTIFACTS_TABLE_NAME", TABLE) + monkeypatch.setenv("ARTIFACTS_ORIGIN", "https://a.test.example.com") + + def make_client(user: User | None = None) -> TestClient: + app = FastAPI() + app.include_router(artifact_shares_router) + app.include_router(shared_artifacts_router) + app.dependency_overrides[get_current_user_from_session] = ( + lambda: user or _owner() + ) + return TestClient(app) + + yield make_client, boto3.resource("dynamodb", region_name=REGION) + + +def _put_version( + ddb, + *, + user_id: str = OWNER_ID, + artifact: str = "art-1", + version: int = 1, + title: str = "My Chart", + session_id: str = "sess-1", +) -> None: + ddb.Table(TABLE).put_item( + Item={ + "PK": f"USER#{user_id}", + "SK": f"ARTIFACT#{artifact}#V#{version:05d}", + "artifact_id": artifact, + "user_id": user_id, + "version": version, + "storage": "s3", + "content_key": f"{user_id}/{artifact}/v{version}/index.html", + "content_type": "text/html; charset=utf-8", + "title": title, + "session_id": session_id, + } + ) + + +def _create_share(tc: TestClient, *, artifact="art-1", **body) -> dict: + payload = {"version": 1, "accessLevel": "public"} + payload.update(body) + resp = tc.post(f"/artifacts/{artifact}/shares", json=payload) + assert resp.status_code == 201, resp.text + return resp.json() + + +# ------------------------------------------------------------------ +# Create +# ------------------------------------------------------------------ + + +def test_create_writes_both_rows_transactionally(env) -> None: + """Both the owner row and the share-lookup row must exist after a + single create — the owner row is what makes listing possible, the + lookup row is what makes the recipient path possible.""" + make_client, ddb = env + _put_version(ddb) + share = _create_share(make_client()) + + share_id = share["shareId"] + table = ddb.Table(TABLE) + + owner_row = table.get_item( + Key={ + "PK": f"USER#{OWNER_ID}", + "SK": f"SHARE#art-1#V#00001#{share_id}", + } + ).get("Item") + lookup_row = table.get_item( + Key={"PK": f"SHARE#{share_id}", "SK": "META"} + ).get("Item") + + assert owner_row is not None + assert lookup_row is not None + # Identical attribute sets — only the keys differ. If these drift, + # the owner's view of who can see a share stops matching what the + # recipient path actually enforces. + assert {k: v for k, v in owner_row.items() if k not in ("PK", "SK")} == { + k: v for k, v in lookup_row.items() if k not in ("PK", "SK") + } + assert lookup_row["owner_id"] == OWNER_ID + assert lookup_row["artifact_id"] == "art-1" + assert int(lookup_row["version"]) == 1 + + +def test_create_denormalizes_title_and_content_type(env) -> None: + make_client, ddb = env + _put_version(ddb, title="Quarterly Deck") + share = _create_share(make_client()) + assert share["title"] == "Quarterly Deck" + assert share["contentType"] == "text/html; charset=utf-8" + assert share["shareUrl"] == f"/shared-artifact/{share['shareId']}" + + +def test_create_for_unknown_version_is_404(env) -> None: + make_client, _ = env + resp = make_client().post( + "/artifacts/art-1/shares", json={"version": 1, "accessLevel": "public"} + ) + assert resp.status_code == 404 + + +def test_cannot_share_another_users_artifact(env) -> None: + """Ownership scoping: the version lookup builds its PK from the + session user, so someone else's artifact is an indistinguishable + 404 rather than a shareable target.""" + make_client, ddb = env + _put_version(ddb, user_id="someone-else") + resp = make_client().post( + "/artifacts/art-1/shares", json={"version": 1, "accessLevel": "public"} + ) + assert resp.status_code == 404 + + +def test_specific_requires_allowed_emails(env) -> None: + make_client, ddb = env + _put_version(ddb) + resp = make_client().post( + "/artifacts/art-1/shares", + json={"version": 1, "accessLevel": "specific"}, + ) + assert resp.status_code == 422 + + +def test_owner_email_is_kept_on_the_allowlist(env) -> None: + make_client, ddb = env + _put_version(ddb) + share = _create_share( + make_client(), + accessLevel="specific", + allowedEmails=["friend@x.com"], + ) + assert OWNER_EMAIL in share["allowedEmails"] + assert "friend@x.com" in share["allowedEmails"] + + +def test_public_share_carries_no_allowlist(env) -> None: + make_client, ddb = env + _put_version(ddb) + share = _create_share(make_client()) + assert share["allowedEmails"] is None + + +def test_version_must_be_positive(env) -> None: + make_client, ddb = env + _put_version(ddb) + resp = make_client().post( + "/artifacts/art-1/shares", json={"version": 0, "accessLevel": "public"} + ) + assert resp.status_code == 422 + + +# ------------------------------------------------------------------ +# List +# ------------------------------------------------------------------ + + +def test_list_is_scoped_to_the_artifact_and_the_owner(env) -> None: + make_client, ddb = env + _put_version(ddb, artifact="art-1") + _put_version(ddb, artifact="art-2") + tc = make_client() + a1 = _create_share(tc, artifact="art-1") + _create_share(tc, artifact="art-2") + + listed = tc.get("/artifacts/art-1/shares").json()["shares"] + assert [s["shareId"] for s in listed] == [a1["shareId"]] + + +def test_list_does_not_leak_another_owners_shares(env) -> None: + make_client, ddb = env + _put_version(ddb, artifact="art-1") + _create_share(make_client(), artifact="art-1") + + other = User(email="other@x.com", user_id="other-1", name="O", roles=[]) + listed = make_client(other).get("/artifacts/art-1/shares").json() + assert listed["shares"] == [] + + +def test_list_for_unshared_artifact_is_empty_not_404(env) -> None: + """An empty list reveals nothing about whether the artifact exists.""" + make_client, _ = env + resp = make_client().get("/artifacts/does-not-exist/shares") + assert resp.status_code == 200 + assert resp.json()["shares"] == [] + + +def test_multiple_versions_of_one_artifact_list_together(env) -> None: + make_client, ddb = env + _put_version(ddb, version=1) + _put_version(ddb, version=2) + tc = make_client() + _create_share(tc, version=1) + _create_share(tc, version=2) + + listed = tc.get("/artifacts/art-1/shares").json()["shares"] + assert sorted(s["version"] for s in listed) == [1, 2] + + +# ------------------------------------------------------------------ +# Update +# ------------------------------------------------------------------ + + +def test_update_changes_access_level_on_both_rows(env) -> None: + make_client, ddb = env + _put_version(ddb) + tc = make_client() + share = _create_share( + tc, accessLevel="specific", allowedEmails=["friend@x.com"] + ) + share_id = share["shareId"] + + resp = tc.patch( + f"/artifacts/shares/{share_id}", json={"accessLevel": "public"} + ) + assert resp.status_code == 200 + assert resp.json()["accessLevel"] == "public" + # Switching to public clears the stale allowlist rather than leaving + # a list that no longer gates anything. + assert resp.json()["allowedEmails"] is None + + table = ddb.Table(TABLE) + for key in ( + {"PK": f"USER#{OWNER_ID}", "SK": f"SHARE#art-1#V#00001#{share_id}"}, + {"PK": f"SHARE#{share_id}", "SK": "META"}, + ): + row = table.get_item(Key=key)["Item"] + assert row["access_level"] == "public" + assert "allowed_emails" not in row + + +def test_update_replaces_the_allowlist(env) -> None: + make_client, ddb = env + _put_version(ddb) + tc = make_client() + share = _create_share( + tc, accessLevel="specific", allowedEmails=["a@x.com"] + ) + resp = tc.patch( + f"/artifacts/shares/{share['shareId']}", + json={"accessLevel": "specific", "allowedEmails": ["b@x.com"]}, + ) + assert resp.status_code == 200 + emails = resp.json()["allowedEmails"] + assert "b@x.com" in emails + assert "a@x.com" not in emails + assert OWNER_EMAIL in emails + + +def test_update_by_non_owner_is_403(env) -> None: + make_client, ddb = env + _put_version(ddb) + share = _create_share(make_client()) + + other = User(email="other@x.com", user_id="other-1", name="O", roles=[]) + resp = make_client(other).patch( + f"/artifacts/shares/{share['shareId']}", json={"accessLevel": "public"} + ) + assert resp.status_code == 403 + + +def test_update_unknown_share_is_404(env) -> None: + make_client, _ = env + resp = make_client().patch( + "/artifacts/shares/nope", json={"accessLevel": "public"} + ) + assert resp.status_code == 404 + + +# ------------------------------------------------------------------ +# Revoke +# ------------------------------------------------------------------ + + +def test_revoke_deletes_both_rows(env) -> None: + make_client, ddb = env + _put_version(ddb) + tc = make_client() + share = _create_share(tc) + share_id = share["shareId"] + + assert tc.delete(f"/artifacts/shares/{share_id}").status_code == 204 + + table = ddb.Table(TABLE) + assert "Item" not in table.get_item( + Key={"PK": f"SHARE#{share_id}", "SK": "META"} + ) + assert "Item" not in table.get_item( + Key={ + "PK": f"USER#{OWNER_ID}", + "SK": f"SHARE#art-1#V#00001#{share_id}", + } + ) + + +def test_revoked_share_404s_for_the_recipient(env) -> None: + make_client, ddb = env + _put_version(ddb) + tc = make_client() + share = _create_share(tc) + tc.delete(f"/artifacts/shares/{share['shareId']}") + + viewer = User(email="v@x.com", user_id="viewer-1", name="V", roles=[]) + resp = make_client(viewer).get(f"/shared-artifacts/{share['shareId']}") + assert resp.status_code == 404 + + +def test_revoke_by_non_owner_is_403_and_leaves_the_share_live(env) -> None: + make_client, ddb = env + _put_version(ddb) + tc = make_client() + share = _create_share(tc) + + other = User(email="other@x.com", user_id="other-1", name="O", roles=[]) + assert ( + make_client(other) + .delete(f"/artifacts/shares/{share['shareId']}") + .status_code + == 403 + ) + assert ( + tc.get(f"/shared-artifacts/{share['shareId']}").status_code == 200 + ) + + +def test_revoke_unknown_share_is_404(env) -> None: + make_client, _ = env + assert make_client().delete("/artifacts/shares/nope").status_code == 404 + + +# ------------------------------------------------------------------ +# Recipient metadata +# ------------------------------------------------------------------ + + +def test_recipient_metadata_shape_never_carries_content(env) -> None: + make_client, ddb = env + _put_version(ddb, title="Shared Chart") + share = _create_share(make_client()) + + viewer = User(email="v@x.com", user_id="viewer-1", name="V", roles=[]) + body = make_client(viewer).get( + f"/shared-artifacts/{share['shareId']}" + ).json() + + assert body["title"] == "Shared Chart" + assert body["ownerEmail"] == OWNER_EMAIL + assert body["version"] == 1 + assert body["canDownload"] is True + # Content never travels on this route, and neither do the owner's + # internal ids or the rest of the allowlist. + for leaked in ("content", "ownerId", "artifactId", "allowedEmails"): + assert leaked not in body + + +def test_recipient_metadata_denies_a_disallowed_viewer(env) -> None: + make_client, ddb = env + _put_version(ddb) + share = _create_share( + make_client(), accessLevel="specific", allowedEmails=["friend@x.com"] + ) + + stranger = User( + email="stranger@x.com", user_id="stranger-1", name="S", roles=[] + ) + resp = make_client(stranger).get(f"/shared-artifacts/{share['shareId']}") + assert resp.status_code == 403 + + +# ------------------------------------------------------------------ +# Auth +# ------------------------------------------------------------------ + + +@pytest.mark.parametrize( + "method,path,body", + [ + ("post", "/artifacts/art-1/shares", {"version": 1, "accessLevel": "public"}), + ("get", "/artifacts/art-1/shares", None), + ("patch", "/artifacts/shares/s1", {"accessLevel": "public"}), + ("delete", "/artifacts/shares/s1", None), + ("get", "/shared-artifacts/s1", None), + ("post", "/shared-artifacts/s1/render-token", None), + ], +) +def test_every_route_requires_a_session(method, path, body) -> None: + """No dependency override and no session cookie → blocked by the + session dependency before any share logic runs. There is no + anonymous path to a shared artifact; "public" means any + *authenticated* tenant user.""" + app = FastAPI() + app.include_router(artifact_shares_router) + app.include_router(shared_artifacts_router) + tc = TestClient(app) + kwargs = {"json": body} if body is not None else {} + assert getattr(tc, method)(path, **kwargs).status_code == 401 + + +# ------------------------------------------------------------------ +# Session-delete cascade +# ------------------------------------------------------------------ +# +# When a conversation is deleted its artifacts outlive it, so their +# share links have to be revoked or they keep working forever. Runs as a +# background task after the 204, so it must never raise. + + +from apis.app_api.artifacts.service import ArtifactShareService # noqa: E402 + + +def _put_head( + ddb, + *, + user_id: str = OWNER_ID, + artifact: str = "art-1", + session_id: str = "sess-1", +) -> None: + """The HEAD row carrying GSI1PK — only HEAD rows are on SessionIndex.""" + ddb.Table(TABLE).put_item( + Item={ + "PK": f"USER#{user_id}", + "SK": f"ARTIFACT#{artifact}#HEAD", + "GSI1PK": f"SESSION#{session_id}", + "GSI1SK": f"ARTIFACT#2026-09-04#{artifact}", + "artifact_id": artifact, + "user_id": user_id, + "session_id": session_id, + "version": 1, + "title": "T", + "content_type": "text/html; charset=utf-8", + } + ) + + +def _cascade(session_id: str = "sess-1", owner: str = OWNER_ID) -> int: + return ArtifactShareService().delete_for_session(session_id, owner) + + +def test_cascade_revokes_both_rows_of_every_share(env) -> None: + make_client, ddb = env + _put_version(ddb) + _put_head(ddb) + tc = make_client() + a = _create_share(tc) + b = _create_share(tc) + + assert _cascade() == 2 + + table = ddb.Table(TABLE) + for share in (a, b): + sid = share["shareId"] + assert "Item" not in table.get_item( + Key={"PK": f"SHARE#{sid}", "SK": "META"} + ) + assert "Item" not in table.get_item( + Key={ + "PK": f"USER#{OWNER_ID}", + "SK": f"SHARE#art-1#V#00001#{sid}", + } + ) + + +def test_cascade_kills_the_recipient_path(env) -> None: + """The lookup row is the capability, so a cascaded share must stop + resolving — that is the whole point of the cleanup.""" + make_client, ddb = env + _put_version(ddb) + _put_head(ddb) + share = _create_share(make_client()) + + viewer = User(email="v@x.com", user_id="viewer-1", name="V", roles=[]) + assert ( + make_client(viewer) + .get(f"/shared-artifacts/{share['shareId']}") + .status_code + == 200 + ) + + _cascade() + + assert ( + make_client(viewer) + .get(f"/shared-artifacts/{share['shareId']}") + .status_code + == 404 + ) + + +def test_cascade_covers_every_artifact_and_version_in_the_session(env) -> None: + make_client, ddb = env + for artifact in ("art-1", "art-2"): + _put_head(ddb, artifact=artifact) + _put_version(ddb, artifact=artifact, version=1) + _put_version(ddb, artifact=artifact, version=2) + tc = make_client() + _create_share(tc, artifact="art-1", version=1) + _create_share(tc, artifact="art-1", version=2) + _create_share(tc, artifact="art-2", version=1) + + assert _cascade() == 3 + assert tc.get("/artifacts/art-1/shares").json()["shares"] == [] + assert tc.get("/artifacts/art-2/shares").json()["shares"] == [] + + +def test_cascade_leaves_other_sessions_alone(env) -> None: + make_client, ddb = env + _put_head(ddb, artifact="art-1", session_id="sess-1") + _put_head(ddb, artifact="art-2", session_id="sess-2") + _put_version(ddb, artifact="art-1") + _put_version(ddb, artifact="art-2") + tc = make_client() + _create_share(tc, artifact="art-1") + keep = _create_share(tc, artifact="art-2") + + assert _cascade("sess-1") == 1 + + remaining = tc.get("/artifacts/art-2/shares").json()["shares"] + assert [s["shareId"] for s in remaining] == [keep["shareId"]] + + +def test_cascade_never_touches_another_owners_shares(env) -> None: + """SessionIndex is not user-partitioned, so a borrowed or colliding + session id must not reach someone else's shares. The owner filter is + the only thing preventing that.""" + make_client, ddb = env + _put_version(ddb) + _put_head(ddb) + share = _create_share(make_client()) + + # Same session id, a different caller. + assert _cascade("sess-1", owner="someone-else") == 0 + assert ( + make_client().get("/artifacts/art-1/shares").json()["shares"][0][ + "shareId" + ] + == share["shareId"] + ) + + +def test_cascade_on_a_session_with_no_artifacts_is_zero(env) -> None: + make_client, _ = env + assert _cascade("sess-empty") == 0 + + +def test_cascade_on_artifacts_with_no_shares_is_zero(env) -> None: + make_client, ddb = env + _put_head(ddb) + _put_version(ddb) + assert _cascade() == 0 + + +def test_cascade_returns_zero_when_artifacts_are_not_configured( + env, monkeypatch +) -> None: + """The session routes are always mounted; artifacts may not be. That + is a normal no-op, not an error.""" + make_client, ddb = env + _put_version(ddb) + _put_head(ddb) + _create_share(make_client()) + token_service._reset_caches_for_tests() + monkeypatch.delenv("DYNAMODB_ARTIFACTS_TABLE_NAME", raising=False) + + assert _cascade() == 0 + + +def test_cascade_swallows_a_backing_store_failure(env, monkeypatch) -> None: + """It runs after the 204 has been sent, so raising would only produce + an unhandled background-task error — the failure mode is an orphan + row, never a blocked delete.""" + make_client, ddb = env + _put_version(ddb) + _put_head(ddb) + _create_share(make_client()) + + def boom(*_args, **_kwargs): + raise RuntimeError("dynamo is down") + + monkeypatch.setattr( + ArtifactShareService, "_shares_for_session", staticmethod(boom) + ) + assert _cascade() == 0 + + +class _RecordingTable: + """Delegates to the real table, recording which DynamoDB API each + call uses and which key space it targets.""" + + def __init__(self, inner, calls: list): + self._inner = inner + self._calls = calls + + def __getattr__(self, name): + # Anything not intercepted below (query, get_item, …) passes + # through — but record the API name so a test can assert on the + # surface actually used. + attr = getattr(self._inner, name) + if callable(attr): + def _recorded(*args, **kwargs): + self._calls.append((name, None)) + return attr(*args, **kwargs) + + return _recorded + return attr + + def delete_item(self, Key): # noqa: N803 — boto3 kwarg name + space = "lookup" if Key["PK"].startswith("SHARE#") else "owner" + self._calls.append(("delete_item", space)) + return self._inner.delete_item(Key=Key) + + +def _record_cascade(monkeypatch) -> list: + """Run the cascade against a table that records its API calls.""" + calls: list = [] + real = token_service._table() + monkeypatch.setattr( + token_service, "_table", lambda: _RecordingTable(real, calls) + ) + return calls + + +def test_cascade_deletes_the_lookup_row_before_the_owner_row( + env, monkeypatch +) -> None: + """Ordering is the safety property. The lookup row is what the + recipient path resolves, so it goes first: a half-finished cascade + then leaves an inert owner row rather than a live share its owner can + no longer see to revoke. Both orders look identical when nothing + fails, so assert the order and not just the end state.""" + make_client, ddb = env + _put_version(ddb) + _put_head(ddb) + _create_share(make_client()) + + calls = _record_cascade(monkeypatch) + assert _cascade() == 1 + + deletes = [space for name, space in calls if name == "delete_item"] + assert deletes == ["lookup", "owner"], deletes + + +def test_cascade_uses_only_iam_granted_dynamodb_actions( + env, monkeypatch +) -> None: + """Regression guard for a real dev-environment failure. + + The app-api task role is granted GetItem/PutItem/UpdateItem/ + DeleteItem/Query on the artifacts table and nothing else. The rest of + this feature writes via `transact_write_items`, which DynamoDB + authorizes against those *underlying* item actions — but + `BatchWriteItem` is its own IAM action and is NOT covered by them, so + `table.batch_writer()` fails closed with AccessDenied in a deployed + environment while passing every moto test (moto does not enforce + IAM). + + Pinning the API surface is the only way a unit test can catch that. + """ + make_client, ddb = env + _put_version(ddb) + _put_head(ddb) + _create_share(make_client()) + + calls = _record_cascade(monkeypatch) + _cascade() + + used = {name for name, _ in calls} + assert "batch_writer" not in used, used + assert used <= {"query", "delete_item"}, used + + +def test_cascade_continues_after_one_row_fails(env, monkeypatch) -> None: + """One unlucky row must not strand the rest — every share left behind + is a link that keeps resolving.""" + make_client, ddb = env + _put_version(ddb) + _put_head(ddb) + tc = make_client() + _create_share(tc) + _create_share(tc) + + real = token_service._table() + seen = {"n": 0} + + class _FlakyTable: + def __getattr__(self, name): + return getattr(real, name) + + def delete_item(self, Key): # noqa: N803 + seen["n"] += 1 + if seen["n"] == 1: + raise ClientError( + {"Error": {"Code": "ProvisionedThroughputExceeded"}}, + "DeleteItem", + ) + return real.delete_item(Key=Key) + + monkeypatch.setattr(token_service, "_table", lambda: _FlakyTable()) + + # One lookup delete failed, the other succeeded — and the cascade + # reports what it actually revoked rather than what it attempted. + assert _cascade() == 1 diff --git a/backend/tests/apis/app_api/artifacts/test_shared_render_token.py b/backend/tests/apis/app_api/artifacts/test_shared_render_token.py new file mode 100644 index 000000000..3814d8596 --- /dev/null +++ b/backend/tests/apis/app_api/artifacts/test_shared_render_token.py @@ -0,0 +1,588 @@ +"""The security core of artifact sharing. + +The share-scoped mint hands a viewer a short-lived credential addressed +to the *owner's* DynamoDB partition. The render Lambda performs no +ownership comparison of its own — it never sees the viewer — so the ACL +check in `mint_for_share` is the only thing between "sharing" and "read +any artifact by id". These tests exist to hold that boundary. + +`test_render_lambda_accepts_the_new_claims` is what makes "no Lambda +change required" a verified fact rather than a reading of the handler: +it feeds a token carrying the new `vwr`/`shr` claims straight through +the currently-deployed verifier. +""" + +from __future__ import annotations + +import boto3 +import jwt +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from moto import mock_aws + +from apis.app_api.artifacts import service as token_service +from apis.app_api.artifacts.shares import ( + artifact_shares_router, + shared_artifacts_router, +) +from apis.shared.auth import User, get_current_user_from_session +from lambdas.artifact_render import handler as render_lambda + +KEY = "test-render-key-44-chars-of-entropy-aaaaaaaa" +SECRET_NAME = "test-artifact-render-token-key" +TABLE = "test-user-artifacts" +ORIGIN = "https://artifacts.test.example.com" +REGION = "us-east-1" + +OWNER_ID = "owner-1" +OWNER_EMAIL = "owner@x.com" +VIEWER_ID = "viewer-1" +VIEWER_EMAIL = "viewer@x.com" + + +@pytest.fixture(autouse=True) +def _reset_caches(monkeypatch: pytest.MonkeyPatch) -> None: + token_service._reset_caches_for_tests() + # The verifier caches its own signing key separately. + monkeypatch.setattr(render_lambda, "_cached_signing_key", None) + + +def _user(user_id: str, email: str) -> User: + return User(email=email, user_id=user_id, name=user_id, roles=[]) + + +OWNER = _user(OWNER_ID, OWNER_EMAIL) +VIEWER = _user(VIEWER_ID, VIEWER_EMAIL) + + +@pytest.fixture +def env(monkeypatch: pytest.MonkeyPatch): + with mock_aws(): + monkeypatch.setenv("AWS_REGION", REGION) + sm = boto3.client("secretsmanager", region_name=REGION) + arn = sm.create_secret(Name=SECRET_NAME, SecretString=KEY)["ARN"] + + ddb = boto3.client("dynamodb", region_name=REGION) + ddb.create_table( + TableName=TABLE, + KeySchema=[ + {"AttributeName": "PK", "KeyType": "HASH"}, + {"AttributeName": "SK", "KeyType": "RANGE"}, + ], + AttributeDefinitions=[ + {"AttributeName": "PK", "AttributeType": "S"}, + {"AttributeName": "SK", "AttributeType": "S"}, + ], + BillingMode="PAY_PER_REQUEST", + ) + + monkeypatch.setenv("ARTIFACTS_RENDER_TOKEN_SECRET_ARN", arn) + monkeypatch.setenv("DYNAMODB_ARTIFACTS_TABLE_NAME", TABLE) + monkeypatch.setenv("ARTIFACTS_ORIGIN", ORIGIN) + + def make_client(user: User) -> TestClient: + app = FastAPI() + app.include_router(artifact_shares_router) + app.include_router(shared_artifacts_router) + app.dependency_overrides[get_current_user_from_session] = ( + lambda: user + ) + return TestClient(app) + + yield make_client, boto3.resource("dynamodb", region_name=REGION) + + +def _put_version( + ddb, *, user_id: str = OWNER_ID, artifact="art-1", version=1 +) -> None: + ddb.Table(TABLE).put_item( + Item={ + "PK": f"USER#{user_id}", + "SK": f"ARTIFACT#{artifact}#V#{version:05d}", + "artifact_id": artifact, + "user_id": user_id, + "version": version, + "storage": "s3", + "content_key": f"{user_id}/{artifact}/v{version}/index.html", + "content_type": "text/html; charset=utf-8", + "title": "Shared Artifact", + "session_id": "sess-1", + } + ) + + +def _share(make_client, *, access_level="public", allowed=None, version=1) -> str: + body: dict = {"version": version, "accessLevel": access_level} + if allowed is not None: + body["allowedEmails"] = allowed + resp = make_client(OWNER).post("/artifacts/art-1/shares", json=body) + assert resp.status_code == 201, resp.text + return resp.json()["shareId"] + + +def _token_from_url(url: str) -> str: + assert url.startswith(f"{ORIGIN}/?t=") + return url.split("?t=", 1)[1] + + +def _decode(url: str) -> dict: + return jwt.decode( + _token_from_url(url), + KEY, + algorithms=["HS256"], + audience="artifact-render", + ) + + +def _mint(make_client, user: User, share_id: str): + return make_client(user).post(f"/shared-artifacts/{share_id}/render-token") + + +# ------------------------------------------------------------------ +# The claim contract +# ------------------------------------------------------------------ + + +def test_minted_claims_address_the_owner_and_record_the_viewer(env) -> None: + """`sub` is the OWNER, not the viewer. + + That is deliberate and load-bearing: `sub` is the DynamoDB partition + key the render Lambda builds (PK = USER#{sub}), an ADDRESS rather + than an identity assertion. Setting it to the viewer would point the + Lambda at the viewer's own partition and the shared artifact would + simply 404. The real viewer identity travels in `vwr`, and the grant + it was issued under in `shr`, so a render log attributes the view to + the person who actually looked instead of crediting it to the owner. + + If this test ever fails because someone "fixed" `sub` to be the + viewer, the fix is to revert that change — not to update this test. + """ + make_client, ddb = env + _put_version(ddb) + share_id = _share(make_client) + + resp = _mint(make_client, VIEWER, share_id) + assert resp.status_code == 200 + claims = _decode(resp.json()["url"]) + + assert claims["sub"] == OWNER_ID + assert claims["vwr"] == VIEWER_ID + assert claims["shr"] == share_id + assert claims["sub"] != VIEWER_ID + + assert claims["iss"] == "app-api" + assert claims["aud"] == "artifact-render" + assert claims["aid"] == "art-1" + assert claims["ver"] == 1 + assert claims["exp"] - claims["iat"] == 120 + assert resp.json()["expires_at"].endswith("+00:00") + + +def test_owner_minting_their_own_share_is_still_attributed_to_them(env) -> None: + make_client, ddb = env + _put_version(ddb) + share_id = _share(make_client) + + claims = _decode(_mint(make_client, OWNER, share_id).json()["url"]) + assert claims["sub"] == OWNER_ID + assert claims["vwr"] == OWNER_ID + + +def test_render_lambda_accepts_the_new_claims(env, monkeypatch) -> None: + """The currently-deployed verifier must accept `vwr`/`shr`. + + This is what turns "PR-1 needs no Lambda change" into a verified + fact: `_verify_token` validates a fixed claim list and has no extras + rejection, so a token carrying the two new claims verifies against + the handler exactly as it ships today. If a future verifier change + ever adds strict claim checking, this test fails first — before a + deploy silently breaks every shared artifact. + """ + make_client, ddb = env + _put_version(ddb) + share_id = _share(make_client) + monkeypatch.setattr(render_lambda, "_cached_signing_key", KEY) + + token = _token_from_url(_mint(make_client, VIEWER, share_id).json()["url"]) + + verified = render_lambda._verify_token(token) + assert verified["sub"] == OWNER_ID + assert verified["aid"] == "art-1" + assert verified["ver"] == 1 + assert verified["vwr"] == VIEWER_ID + assert verified["shr"] == share_id + + +def test_shared_token_resolves_to_the_owners_partition( + env, monkeypatch +) -> None: + """End-to-end proof that `sub` is an address: the Lambda's own + record lookup, driven by the minted claims, must land on the owner's + row. A viewer-valued `sub` would miss it entirely.""" + make_client, ddb = env + _put_version(ddb) + share_id = _share(make_client) + claims = _decode(_mint(make_client, VIEWER, share_id).json()["url"]) + + # The Lambda pins its table name at module load from its own env var. + monkeypatch.setattr(render_lambda, "_ARTIFACTS_TABLE", TABLE) + monkeypatch.setattr(render_lambda, "_ddb_table", None) + + record = render_lambda._get_version_record( + claims["sub"], claims["aid"], claims["ver"] + ) + assert record["user_id"] == OWNER_ID + assert record["content_key"].startswith(f"{OWNER_ID}/") + + +# ------------------------------------------------------------------ +# The ACL boundary +# ------------------------------------------------------------------ + + +def test_public_admits_an_arbitrary_authenticated_user(env) -> None: + make_client, ddb = env + _put_version(ddb) + share_id = _share(make_client, access_level="public") + + stranger = _user("stranger-1", "stranger@x.com") + resp = _mint(make_client, stranger, share_id) + assert resp.status_code == 200 + assert _decode(resp.json()["url"])["vwr"] == "stranger-1" + + +def test_specific_admits_only_allowlisted_emails(env) -> None: + make_client, ddb = env + _put_version(ddb) + share_id = _share( + make_client, access_level="specific", allowed=[VIEWER_EMAIL] + ) + + assert _mint(make_client, VIEWER, share_id).status_code == 200 + + stranger = _user("stranger-1", "stranger@x.com") + assert _mint(make_client, stranger, share_id).status_code == 403 + + +@pytest.mark.parametrize( + "allowed_email,viewer_email", + [ + ("Viewer@X.com", "viewer@x.com"), + ("viewer@x.com", "VIEWER@X.COM"), + ("ViEwEr@x.CoM", "vIeWeR@X.com"), + ], +) +def test_specific_matches_emails_case_insensitively( + env, allowed_email, viewer_email +) -> None: + """Entra hands back whatever casing the directory holds, so a + case-sensitive compare would lock out legitimately-invited people.""" + make_client, ddb = env + _put_version(ddb) + share_id = _share( + make_client, access_level="specific", allowed=[allowed_email] + ) + + viewer = _user("viewer-cased", viewer_email) + assert _mint(make_client, viewer, share_id).status_code == 200 + + +def test_specific_denies_a_near_miss_email(env) -> None: + make_client, ddb = env + _put_version(ddb) + share_id = _share( + make_client, access_level="specific", allowed=["viewer@x.com"] + ) + + for near_miss in ( + "viewer@x.com.evil.com", + "notviewer@x.com", + "viewer@y.com", + " viewer@x.com", + ): + impostor = _user("impostor", near_miss) + assert _mint(make_client, impostor, share_id).status_code == 403 + + +def test_owner_always_passes_a_specific_share(env) -> None: + make_client, ddb = env + _put_version(ddb) + share_id = _share( + make_client, access_level="specific", allowed=["someone-else@x.com"] + ) + assert _mint(make_client, OWNER, share_id).status_code == 200 + + +def test_revoked_share_mints_nothing(env) -> None: + make_client, ddb = env + _put_version(ddb) + share_id = _share(make_client) + assert _mint(make_client, VIEWER, share_id).status_code == 200 + + make_client(OWNER).delete(f"/artifacts/shares/{share_id}") + + resp = _mint(make_client, VIEWER, share_id) + assert resp.status_code == 404 + assert "url" not in resp.json() + + +def test_downgrade_to_specific_locks_out_a_former_public_viewer(env) -> None: + """Revocation is not the only control: narrowing a live share must + take effect on the very next mint.""" + make_client, ddb = env + _put_version(ddb) + share_id = _share(make_client, access_level="public") + assert _mint(make_client, VIEWER, share_id).status_code == 200 + + make_client(OWNER).patch( + f"/artifacts/shares/{share_id}", + json={"accessLevel": "specific", "allowedEmails": ["other@x.com"]}, + ) + assert _mint(make_client, VIEWER, share_id).status_code == 403 + + +def test_unknown_share_id_mints_nothing(env) -> None: + """A share id is not a capability on its own — guessing one gets a + 404, never a token.""" + make_client, ddb = env + _put_version(ddb) + resp = _mint(make_client, VIEWER, "not-a-real-share") + assert resp.status_code == 404 + + +def test_missing_version_row_404s_instead_of_minting(env) -> None: + """A share can outlive the version it points at. Better a clean 404 + than a token that renders the Lambda's error page in the recipient's + iframe.""" + make_client, ddb = env + _put_version(ddb) + share_id = _share(make_client) + ddb.Table(TABLE).delete_item( + Key={"PK": f"USER#{OWNER_ID}", "SK": "ARTIFACT#art-1#V#00001"} + ) + + resp = _mint(make_client, VIEWER, share_id) + assert resp.status_code == 404 + assert "url" not in resp.json() + + +def test_access_check_runs_before_the_version_lookup(env) -> None: + """A denied viewer must not be able to probe whether the owner's + artifact version exists — both cases have to be indistinguishable + from the outside. Here the version row is gone AND the viewer is not + allowed; the answer must be 403 (the ACL), not 404 (the probe).""" + make_client, ddb = env + _put_version(ddb) + share_id = _share( + make_client, access_level="specific", allowed=["friend@x.com"] + ) + ddb.Table(TABLE).delete_item( + Key={"PK": f"USER#{OWNER_ID}", "SK": "ARTIFACT#art-1#V#00001"} + ) + + stranger = _user("stranger-1", "stranger@x.com") + assert _mint(make_client, stranger, share_id).status_code == 403 + + +def test_share_pins_the_version_it_was_created_for(env) -> None: + """A share is pinned to one immutable version. A newer version of + the same artifact must not change what the recipient sees.""" + make_client, ddb = env + _put_version(ddb, version=1) + share_id = _share(make_client, version=1) + _put_version(ddb, version=2) + + claims = _decode(_mint(make_client, VIEWER, share_id).json()["url"]) + assert claims["ver"] == 1 + + +# ------------------------------------------------------------------ +# Fail-closed config +# ------------------------------------------------------------------ + + +def test_missing_origin_is_500_and_mints_nothing(env, monkeypatch) -> None: + """Origin resolves before any DDB call or ACL check, so a broken + artifacts deploy fails closed rather than handing back a usable + token embedded in a relative, unloadable URL.""" + make_client, ddb = env + _put_version(ddb) + share_id = _share(make_client) + monkeypatch.delenv("ARTIFACTS_ORIGIN", raising=False) + + resp = _mint(make_client, VIEWER, share_id) + assert resp.status_code == 500 + assert "url" not in resp.json() + + +def test_token_is_never_logged(env, caplog) -> None: + """The token is a bearer credential carried in a URL. Log lines on + this path must carry identifiers only.""" + make_client, ddb = env + _put_version(ddb) + share_id = _share(make_client) + + with caplog.at_level("INFO"): + url = _mint(make_client, VIEWER, share_id).json()["url"] + + token = _token_from_url(url) + logged = "\n".join(record.getMessage() for record in caplog.records) + assert token not in logged + assert url not in logged + # Identifiers are expected — that is the point of `vwr`/`shr`. + assert share_id in logged + + +# ------------------------------------------------------------------ +# Shared content (recipient code view) +# ------------------------------------------------------------------ +# +# `GET /shared-artifacts/{id}/content` resolves the OWNER's S3 object +# after the share ACL admits the viewer. ArtifactContentService performs +# no access control of its own, so these tests are the boundary. + + +import boto3 as _boto3 # noqa: E402 (grouped with the content tests below) + +BUCKET = "test-artifacts-content" +BODY = "shared artifact source" + + +def _put_content( + *, user_id: str = OWNER_ID, artifact="art-1", version=1, body: str = BODY +) -> None: + """Write the S3 object the version row's content_key points at.""" + s3 = _boto3.client("s3", region_name=REGION) + try: + # us-east-1 rejects an explicit LocationConstraint. + s3.create_bucket(Bucket=BUCKET) + except s3.exceptions.BucketAlreadyOwnedByYou: + pass + s3.put_object( + Bucket=BUCKET, + Key=f"{user_id}/{artifact}/v{version}/index.html", + Body=body.encode("utf-8"), + ContentType="text/html; charset=utf-8", + ) + + +def _content(make_client, user: User, share_id: str): + return make_client(user).get(f"/shared-artifacts/{share_id}/content") + + +def test_recipient_reads_the_owners_content(env, monkeypatch) -> None: + """The whole point: a viewer who is not the owner gets the owner's + bytes, because the route resolves the owner from the share row after + the ACL admits them.""" + make_client, ddb = env + monkeypatch.setenv("S3_ARTIFACTS_BUCKET_NAME", BUCKET) + _put_version(ddb) + _put_content() + share_id = _share(make_client) + + resp = _content(make_client, VIEWER, share_id) + assert resp.status_code == 200 + body = resp.json() + assert body["content"] == BODY + assert body["version"] == 1 + + +def test_shared_content_denies_a_disallowed_viewer(env, monkeypatch) -> None: + make_client, ddb = env + monkeypatch.setenv("S3_ARTIFACTS_BUCKET_NAME", BUCKET) + _put_version(ddb) + _put_content() + share_id = _share( + make_client, access_level="specific", allowed=["friend@x.com"] + ) + + stranger = _user("stranger-1", "stranger@x.com") + resp = _content(make_client, stranger, share_id) + assert resp.status_code == 403 + assert "content" not in resp.json() + + +def test_shared_content_404s_after_revoke(env, monkeypatch) -> None: + make_client, ddb = env + monkeypatch.setenv("S3_ARTIFACTS_BUCKET_NAME", BUCKET) + _put_version(ddb) + _put_content() + share_id = _share(make_client) + assert _content(make_client, VIEWER, share_id).status_code == 200 + + make_client(OWNER).delete(f"/artifacts/shares/{share_id}") + + resp = _content(make_client, VIEWER, share_id) + assert resp.status_code == 404 + assert "content" not in resp.json() + + +def test_shared_content_404s_for_an_unknown_share(env, monkeypatch) -> None: + make_client, ddb = env + monkeypatch.setenv("S3_ARTIFACTS_BUCKET_NAME", BUCKET) + _put_version(ddb) + _put_content() + assert _content(make_client, VIEWER, "nope").status_code == 404 + + +def test_shared_content_404s_when_the_version_row_is_gone( + env, monkeypatch +) -> None: + make_client, ddb = env + monkeypatch.setenv("S3_ARTIFACTS_BUCKET_NAME", BUCKET) + _put_version(ddb) + _put_content() + share_id = _share(make_client) + ddb.Table(TABLE).delete_item( + Key={"PK": f"USER#{OWNER_ID}", "SK": "ARTIFACT#art-1#V#00001"} + ) + assert _content(make_client, VIEWER, share_id).status_code == 404 + + +def test_shared_content_serves_the_pinned_version_only( + env, monkeypatch +) -> None: + """A newer version must not leak through a share pinned to an older + one — the share row's version is what addresses the object.""" + make_client, ddb = env + monkeypatch.setenv("S3_ARTIFACTS_BUCKET_NAME", BUCKET) + _put_version(ddb, version=1) + _put_content(version=1, body="V1 BODY") + share_id = _share(make_client, version=1) + + _put_version(ddb, version=2) + _put_content(version=2, body="V2 BODY") + + body = _content(make_client, VIEWER, share_id).json() + assert body["content"] == "V1 BODY" + assert body["version"] == 1 + + +def test_shared_content_413s_an_oversized_artifact(env, monkeypatch) -> None: + """Recipients get the same steer-to-download signal owners do.""" + make_client, ddb = env + monkeypatch.setenv("S3_ARTIFACTS_BUCKET_NAME", BUCKET) + _put_version(ddb) + _put_content(body="x" * (2 * 1024 * 1024 + 10)) + share_id = _share(make_client) + + assert _content(make_client, VIEWER, share_id).status_code == 413 + + +def test_owner_content_route_is_unchanged_for_a_recipient( + env, monkeypatch +) -> None: + """The owner route must stay self-scoped: it builds its key from the + session user, so a recipient asking it for the owner's artifact gets + a 404 no matter what share they hold. If this ever starts returning + 200, the owner route has been made share-aware and the two access + models have been conflated.""" + make_client, ddb = env + monkeypatch.setenv("S3_ARTIFACTS_BUCKET_NAME", BUCKET) + _put_version(ddb) + _put_content() + _share(make_client) + + resp = make_client(VIEWER).get("/artifacts/art-1/content?version=1") + assert resp.status_code == 404 diff --git a/backend/tests/apis/app_api/shares/test_shared_conversation_artifacts.py b/backend/tests/apis/app_api/shares/test_shared_conversation_artifacts.py new file mode 100644 index 000000000..62771b7aa --- /dev/null +++ b/backend/tests/apis/app_api/shares/test_shared_conversation_artifacts.py @@ -0,0 +1,488 @@ +"""Tests for artifacts inside a shared conversation. + +Sharing a conversation shares the artifacts it produced. The mechanism +is that the **conversation share is the grant** — there is no parallel +artifact-share record — and the snapshot pins the versions. + +Two things carry the weight here: + +* `resolve_shared_artifact` is the *whole* access boundary. The mint it + feeds does no checking of its own, and the token's `sub` is a + DynamoDB partition address, so a gap here is "read any artifact by + id" against the owner's partition. +* The snapshot's artifact list is the allowlist. A recipient must not be + able to name an artifact the share does not carry. +""" + +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import boto3 +import pytest +from moto import mock_aws + +from apis.app_api.shares.models import CreateShareRequest +from apis.app_api.shares.service import ( + AccessDeniedError, + ShareNotFoundError, + ShareService, +) +from apis.app_api.shares.snapshot_store import ShareSnapshotStore +from apis.shared.auth.models import User + +AWS_REGION = "us-east-1" +BUCKET = "test-shared-conversations" + + +@pytest.fixture() +def aws_env(monkeypatch): + monkeypatch.setenv("AWS_DEFAULT_REGION", AWS_REGION) + monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing") + monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing") + monkeypatch.setenv("AWS_SESSION_TOKEN", "testing") + with mock_aws(): + yield + + +@pytest.fixture() +def s3_client(aws_env): + client = boto3.client("s3", region_name=AWS_REGION) + client.create_bucket(Bucket=BUCKET) + return client + + +@pytest.fixture() +def service(s3_client, monkeypatch): + monkeypatch.setenv("SHARED_CONVERSATIONS_TABLE_NAME", "shares-table") + store = ShareSnapshotStore(bucket_name=BUCKET, s3_client=s3_client) + with patch("boto3.resource"): + svc = ShareService(snapshot_store=store) + svc._table = MagicMock() + return svc + + +def _owner() -> User: + return User( + email="owner@example.com", user_id="owner-1", name="Owner", roles=[] + ) + + +def _viewer(email: str = "friend@example.com") -> User: + return User(email=email, user_id="friend-1", name="Friend", roles=[]) + + +def _patch_sources(heads: list[dict] | Exception): + """Patch the three things `create_share` reads.""" + meta = MagicMock() + meta.model_dump.return_value = {"title": "Chat"} + messages = MagicMock( + messages=[ + MagicMock( + model_dump=MagicMock( + return_value={ + "id": "m0", + "role": "user", + "content": [{"type": "text", "text": "hi"}], + "createdAt": "2026-01-01T00:00:00Z", + } + ) + ) + ] + ) + + list_service = MagicMock() + if isinstance(heads, Exception): + list_service.heads_for_session.side_effect = heads + else: + list_service.heads_for_session.return_value = heads + + return ( + patch( + "apis.app_api.shares.service.get_session_metadata", + new=AsyncMock(return_value=meta), + ), + patch( + "apis.app_api.shares.service.get_messages", + new=AsyncMock(return_value=messages), + ), + patch( + "apis.app_api.artifacts.service.get_artifact_list_service", + return_value=list_service, + ), + ) + + +def _head(artifact_id: str = "art-1", version: int = 3) -> dict: + return { + "artifact_id": artifact_id, + "version": version, + "title": "Quarterly Deck", + "content_type": "text/html; charset=utf-8", + "produced_by_message_index": 2, + } + + +async def _create(service, heads, *, access="public", emails=None): + meta_p, msgs_p, arts_p = _patch_sources(heads) + with meta_p, msgs_p, arts_p: + return await service.create_share( + "sess-1", + _owner(), + CreateShareRequest(accessLevel=access, allowedEmails=emails), + ) + + +def _written_item(service) -> dict: + """The DynamoDB item create_share wrote, as the read path sees it.""" + return service._table.put_item.call_args[1]["Item"] + + +def _snapshot_body(service, s3_client) -> dict: + key = _written_item(service)["body_ref"]["bucket_key"] + obj = s3_client.get_object(Bucket=BUCKET, Key=key) + return json.loads(obj["Body"].read()) + + +# ------------------------------------------------------------------ +# Capture +# ------------------------------------------------------------------ + + +class TestSnapshotCapture: + @pytest.mark.asyncio + async def test_pins_each_artifact_at_its_current_version( + self, service, s3_client + ): + await _create(service, [_head(version=3)]) + + artifacts = _snapshot_body(service, s3_client)["artifacts"] + assert len(artifacts) == 1 + # The version at share time, not a moving HEAD pointer: a + # recipient reading a frozen conversation must not be shown an + # artifact the transcript around it never describes. + assert artifacts[0]["version"] == 3 + assert artifacts[0]["artifact_id"] == "art-1" + assert artifacts[0]["produced_by_message_index"] == 2 + + @pytest.mark.asyncio + async def test_a_conversation_with_no_artifacts_shares_fine( + self, service, s3_client + ): + await _create(service, []) + assert _snapshot_body(service, s3_client)["artifacts"] == [] + + @pytest.mark.asyncio + async def test_artifact_failure_does_not_fail_the_share( + self, service, s3_client + ): + """Sharing a conversation must not break because the artifacts + feature is off here, or because its table hiccuped. A share with + no artifacts is what every share was before this existed.""" + await _create(service, RuntimeError("artifacts table is gone")) + + body = _snapshot_body(service, s3_client) + assert body["artifacts"] == [] + # The conversation itself still made it. + assert body["messages"][0]["content"][0]["text"] == "hi" + + +# ------------------------------------------------------------------ +# Read +# ------------------------------------------------------------------ + + +class TestSharedConversationResponse: + @pytest.mark.asyncio + async def test_returns_artifacts_with_the_conversation(self, service): + await _create(service, [_head()]) + service._get_share_item = MagicMock(return_value=_written_item(service)) + + resp = await service.get_shared_conversation( + share_id=_written_item(service)["share_id"], requester=_viewer() + ) + + assert len(resp.artifacts) == 1 + assert resp.artifacts[0].artifact_id == "art-1" + assert resp.artifacts[0].version == 3 + # Anchoring data, so the shared view can place the card under the + # same turn the owner sees it under. + assert resp.artifacts[0].produced_by_message_index == 2 + + @pytest.mark.asyncio + async def test_a_share_predating_artifacts_reads_as_empty( + self, service, s3_client + ): + """Conversation sharing is already in production, so a body with + no `artifacts` key is the common case, not an error. There is no + migration and none is needed.""" + await _create(service, [_head()]) + item = _written_item(service) + + # Overwrite in place, at the key the item already points at, so + # this reads back through the real resolution path rather than a + # second object nothing references. + s3_client.put_object( + Bucket=BUCKET, + Key=item["body_ref"]["bucket_key"], + Body=json.dumps( + {"metadata": {"title": "Chat"}, "messages": []} + ).encode(), + ) + service._get_share_item = MagicMock(return_value=item) + + resp = await service.get_shared_conversation( + share_id=item["share_id"], requester=_viewer() + ) + assert resp.artifacts == [] + + @pytest.mark.asyncio + async def test_a_legacy_inline_share_reads_as_empty(self, service): + """Inline shares predate the S3 offload entirely — and artifacts + by a wider margin. They must still open.""" + item = { + "share_id": "s-legacy", + "session_id": "sess-1", + "owner_id": "owner-1", + "owner_email": "owner@example.com", + "access_level": "public", + "created_at": "2026-01-01T00:00:00+00:00", + "metadata": {"title": "Old Chat"}, + "messages": [], + } + service._get_share_item = MagicMock(return_value=item) + + resp = await service.get_shared_conversation( + share_id="s-legacy", requester=_viewer() + ) + assert resp.artifacts == [] + assert resp.title == "Old Chat" + + +# ------------------------------------------------------------------ +# The access boundary +# ------------------------------------------------------------------ + + +class TestResolveSharedArtifact: + @pytest.mark.asyncio + async def test_returns_the_owner_and_the_pinned_version(self, service): + await _create(service, [_head(version=3)]) + item = _written_item(service) + service._get_share_item = MagicMock(return_value=item) + + owner_id, version = service.resolve_shared_artifact( + share_id=item["share_id"], + artifact_id="art-1", + requester=_viewer(), + ) + + # The owner id is a DynamoDB partition address for the mint, not + # an identity assertion. See mint_for_conversation_share. + assert owner_id == "owner-1" + assert version == 3 + + @pytest.mark.asyncio + async def test_an_artifact_outside_the_snapshot_is_404(self, service): + """The load-bearing test. + + The snapshot list is the allowlist. Without this check, any valid + share id plus a guessed artifact id would read the owner's whole + artifact partition, because `sub` on the minted token is an + address rather than an identity. 404 rather than 403, so it also + reveals nothing about what the owner has.""" + await _create(service, [_head("art-1")]) + item = _written_item(service) + service._get_share_item = MagicMock(return_value=item) + + with pytest.raises(ShareNotFoundError): + service.resolve_shared_artifact( + share_id=item["share_id"], + artifact_id="art-somebody-elses", + requester=_viewer(), + ) + + @pytest.mark.asyncio + async def test_a_viewer_outside_the_allowlist_is_denied(self, service): + await _create( + service, + [_head()], + access="specific", + emails=["invited@example.com"], + ) + item = _written_item(service) + service._get_share_item = MagicMock(return_value=item) + + with pytest.raises(AccessDeniedError): + service.resolve_shared_artifact( + share_id=item["share_id"], + artifact_id="art-1", + requester=_viewer("uninvited@example.com"), + ) + + @pytest.mark.asyncio + async def test_access_follows_the_conversation_share(self, service): + """The point of having no separate artifact-share record. + + Narrowing the conversation's allowlist has to lock the artifacts + down in the same write — with parallel artifact shares this is + where a missed cascade would leave them readable.""" + await _create( + service, + [_head()], + access="specific", + emails=["friend@example.com"], + ) + item = _written_item(service) + service._get_share_item = MagicMock(return_value=item) + + # Allowed while on the list. + assert service.resolve_shared_artifact( + share_id=item["share_id"], + artifact_id="art-1", + requester=_viewer(), + ) + + # Owner edits the allowlist — one write, no cascade. + item["allowed_emails"] = ["someone.else@example.com"] + + with pytest.raises(AccessDeniedError): + service.resolve_shared_artifact( + share_id=item["share_id"], + artifact_id="art-1", + requester=_viewer(), + ) + + @pytest.mark.asyncio + async def test_a_revoked_share_takes_its_artifacts_with_it(self, service): + await _create(service, [_head()]) + item = _written_item(service) + service._get_share_item = MagicMock(return_value=None) + + with pytest.raises(ShareNotFoundError): + service.resolve_shared_artifact( + share_id=item["share_id"], + artifact_id="art-1", + requester=_viewer(), + ) + + @pytest.mark.asyncio + async def test_the_owner_can_resolve_their_own(self, service): + await _create( + service, [_head()], access="specific", emails=["a@example.com"] + ) + item = _written_item(service) + service._get_share_item = MagicMock(return_value=item) + + owner_id, _ = service.resolve_shared_artifact( + share_id=item["share_id"], + artifact_id="art-1", + requester=_owner(), + ) + assert owner_id == "owner-1" + + +# ------------------------------------------------------------------ +# The route +# ------------------------------------------------------------------ + + +class TestMintRoute: + """The HTTP surface, wired to a real ShareService. + + These exist because the boundary is split across two modules: the + grant lives here and the minting lives in `artifacts/service.py`. + Unit tests on either half can both pass while the route wires them + together wrongly. + """ + + @pytest.fixture() + def client(self, service, monkeypatch): + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from apis.app_api.shares import routes as share_routes + from apis.shared.auth import get_current_user_from_session + + monkeypatch.setenv( + "ARTIFACTS_RENDER_TOKEN_SECRET_ARN", "arn:aws:secret:test" + ) + monkeypatch.setenv("ARTIFACTS_ORIGIN", "https://a.test.example.com") + + def make(user: User): + app = FastAPI() + app.include_router(share_routes.shared_view_router) + app.dependency_overrides[get_current_user_from_session] = ( + lambda: user + ) + monkeypatch.setattr( + share_routes, "get_share_service", lambda: service + ) + return TestClient(app) + + return make + + @pytest.mark.asyncio + async def test_mints_for_a_permitted_viewer( + self, service, client, monkeypatch + ): + await _create(service, [_head(version=3)]) + item = _written_item(service) + service._get_share_item = MagicMock(return_value=item) + + seen = {} + + def fake_mint(**kwargs): + seen.update(kwargs) + return "https://a.test.example.com/?t=jwt", 1800000000 + + from apis.app_api.shares import routes as share_routes + + monkeypatch.setattr( + share_routes, + "get_render_token_service", + lambda: MagicMock(mint_for_conversation_share=fake_mint), + ) + + resp = client(_viewer()).post( + f"/shared/{item['share_id']}/artifacts/art-1/render-token" + ) + + assert resp.status_code == 200, resp.text + assert resp.json()["url"].endswith("?t=jwt") + # The route must hand the mint the OWNER (a partition address) + # and the PINNED version — not the viewer, and not HEAD. + assert seen["owner_id"] == "owner-1" + assert seen["version"] == 3 + assert seen["viewer"].user_id == "friend-1" + assert seen["conversation_share_id"] == item["share_id"] + + @pytest.mark.asyncio + async def test_an_artifact_outside_the_snapshot_is_404( + self, service, client + ): + await _create(service, [_head("art-1")]) + item = _written_item(service) + service._get_share_item = MagicMock(return_value=item) + + resp = client(_viewer()).post( + f"/shared/{item['share_id']}/artifacts/art-other/render-token" + ) + assert resp.status_code == 404 + + @pytest.mark.asyncio + async def test_a_viewer_outside_the_allowlist_is_403( + self, service, client + ): + await _create( + service, + [_head()], + access="specific", + emails=["invited@example.com"], + ) + item = _written_item(service) + service._get_share_item = MagicMock(return_value=item) + + resp = client(_viewer("uninvited@example.com")).post( + f"/shared/{item['share_id']}/artifacts/art-1/render-token" + ) + assert resp.status_code == 403 diff --git a/backend/tests/apis/inference_api/test_carried_steering.py b/backend/tests/apis/inference_api/test_carried_steering.py new file mode 100644 index 000000000..43e9b8b0b --- /dev/null +++ b/backend/tests/apis/inference_api/test_carried_steering.py @@ -0,0 +1,48 @@ +"""Carrying a queued follow-up through the resume path. + +Mid-turn steering's paused-turn case (docs/specs/mid-turn-steering.md). A turn +paused for OAuth consent or tool approval has no running loop to steer, and the +pause releases its lease — inbox and all — when the stream closes. The +follow-ups the user queued meanwhile ride the resume request and are seeded +onto the resumed turn's lease, where the ordinary ``SteeringHook`` injects them +at its first tool boundary. + +This covers the payload's shape at the API edge. The seeding itself — and the +load-bearing acquire→seed order, since acquire REMOVEs the inbox — lives in +``tests/shared/test_session_lease.py``. +""" + +import pytest +from pydantic import ValidationError + +from apis.inference_api.chat.models import InvocationRequest +from apis.shared.sessions.session_lease import STEER_QUEUE_MAX_CHARS + + +class TestRequestShape: + def test_carries_entries_with_their_client_minted_ids(self): + request = InvocationRequest( + session_id="s1", + steering=[{"id": "e1", "text": "use the other file"}], + ) + # The id is the one the composer holds and `steering_applied` names + # back — carrying a fresh id would make the ack unmatchable. + assert [(e.id, e.text) for e in request.steering] == [ + ("e1", "use the other file") + ] + + def test_absent_by_default(self): + assert InvocationRequest(session_id="s1").steering is None + + def test_rejects_an_empty_entry(self): + with pytest.raises(ValidationError): + InvocationRequest(session_id="s1", steering=[{"id": "e1", "text": ""}]) + with pytest.raises(ValidationError): + InvocationRequest(session_id="s1", steering=[{"id": "", "text": "hi"}]) + + def test_rejects_text_past_the_inbox_cap(self): + with pytest.raises(ValidationError): + InvocationRequest( + session_id="s1", + steering=[{"id": "e1", "text": "x" * (STEER_QUEUE_MAX_CHARS + 1)}], + ) diff --git a/backend/tests/apis/inference_api/test_inference_param_merge.py b/backend/tests/apis/inference_api/test_inference_param_merge.py index 608019375..1746604db 100644 --- a/backend/tests/apis/inference_api/test_inference_param_merge.py +++ b/backend/tests/apis/inference_api/test_inference_param_merge.py @@ -121,3 +121,96 @@ def test_out_of_domain_with_no_default_is_dropped(self): model = _model(effort=spec) merged = _merge_inference_params(model, {"effort": "max"}) assert "effort" not in merged + + +class TestOmissionMeansUnsupported: + """A spec that declares *any* param is authoritative: silence about a param + means unsupported, not "pass it through". + + The original default forwarded any request key in ``KNOWN_CANONICAL_PARAMS`` + that the spec didn't mention. Anthropic deprecated ``temperature`` / + ``top_p`` / ``top_k`` on Claude Opus 4.7 and later — a non-default value + returns a hard 400 — and our curated templates for those models *omit* + those params rather than declaring ``supported: false``. So the permissive + default let a temperature reach Bedrock and kill the turn mid-stream. + + Note the SPA already behaved this way: `model-settings.ts` renders a row + only for a param the spec declares AND marks supported, so omission was + already "unsupported" in the UI. The backend was the surface that disagreed. + """ + + # Opus 4.7 / Sonnet 5 shape: max_tokens + effort declared, sampling params + # deliberately absent because the model 400s on them. + def _opus_47_spec(self) -> SimpleNamespace: + return _model( + max_tokens=ModelParamSpec(supported=True, min=1, max=64000, default=32000), + effort=ModelParamSpec( + supported=True, allowed=["low", "medium", "high"], default="medium" + ), + ) + + def test_omitted_param_is_dropped_when_a_spec_is_declared(self): + merged = _merge_inference_params(self._opus_47_spec(), {"temperature": 0.9}) + assert "temperature" not in merged + + def test_every_deprecated_sampling_param_is_dropped(self): + merged = _merge_inference_params( + self._opus_47_spec(), + {"temperature": 0.9, "top_p": 0.5, "top_k": 40}, + ) + assert merged.keys() == {"max_tokens", "effort"} + + def test_declared_params_still_merge_normally(self): + """The inversion must not disturb the params the spec does declare.""" + merged = _merge_inference_params( + self._opus_47_spec(), {"max_tokens": 4096, "effort": "high"} + ) + assert merged["max_tokens"] == 4096 + assert merged["effort"] == "high" + + def test_drop_is_logged_with_the_model_id(self, caplog): + """The drop-log is the mitigation for taking a param away — without it + the inversion is silent and unfalsifiable in production.""" + import logging + + with caplog.at_level(logging.INFO): + _merge_inference_params(self._opus_47_spec(), {"temperature": 0.9}) + assert any( + "omitted from its supportedParams" in r.getMessage() + for r in caplog.records + ) + + def test_model_with_no_spec_stays_permissive(self): + """A hand-created record that declares nothing hasn't made a claim, so + there is no omission to read. Keep the canonical allow-list behavior.""" + no_spec = SimpleNamespace(model_id="hand-made", supported_params=None) + merged = _merge_inference_params(no_spec, {"temperature": 0.7}) + assert merged["temperature"] == 0.7 + + def test_model_with_empty_spec_stays_permissive(self): + empty = _model() # SupportedParams(params={}) + merged = _merge_inference_params(empty, {"temperature": 0.7}) + assert merged["temperature"] == 0.7 + + def test_unrecognized_key_is_still_dropped_without_a_spec(self): + no_spec = SimpleNamespace(model_id="hand-made", supported_params=None) + merged = _merge_inference_params(no_spec, {"not_a_real_param": 1}) + assert merged == {} + + def test_explicit_unsupported_still_wins(self): + """Declaring `supported: false` keeps working — the inversion only + changes what *silence* means.""" + model = _model(temperature=ModelParamSpec(supported=False)) + merged = _merge_inference_params(model, {"temperature": 0.9}) + assert "temperature" not in merged + + def test_stale_persisted_override_can_no_longer_reach_the_provider(self): + """The SPA persists overrides per model id in localStorage and sends + them verbatim, unfiltered by the current spec. So an override set while + a param was declared outlives the spec that justified it. Before the + inversion that stale value reached Bedrock; now it is dropped.""" + merged = _merge_inference_params( + self._opus_47_spec(), {"temperature": 1.0, "max_tokens": 8192} + ) + assert merged == {"max_tokens": 8192, "effort": "medium"} + diff --git a/backend/tests/architecture/test_admin_scope_coverage.py b/backend/tests/architecture/test_admin_scope_coverage.py index dec034e20..b93e4a1af 100644 --- a/backend/tests/architecture/test_admin_scope_coverage.py +++ b/backend/tests/architecture/test_admin_scope_coverage.py @@ -52,6 +52,7 @@ "file_sources/routes.py": "admin.file_sources", "export_targets/routes.py": "admin.export_targets", "user_menu_links/routes.py": "admin.user_menu_links", + "announcements/routes.py": "admin.announcements", "system_prompts/routes.py": "admin.system_prompts", "fine_tuning/routes.py": "admin.fine_tuning", } diff --git a/backend/tests/costs/test_calculator.py b/backend/tests/costs/test_calculator.py index 0d6e81200..1fecd43d7 100644 --- a/backend/tests/costs/test_calculator.py +++ b/backend/tests/costs/test_calculator.py @@ -280,3 +280,111 @@ def test_none_value_is_invalid(self): "inputTokens": None, "outputTokens": 50, }) is False + + +# GPT-5.6 Sol ($/Mtok), transcribed from the model card. Only the ratios are +# load-bearing here: cache reads are a 90% discount off input, cache writes a +# 1.25x premium. The absolute rates are re-verified against the Price List API +# before any catalog row ships them. +GPT_56_SOL_PRICING = { + "inputPricePerMtok": 4.40, + "outputPricePerMtok": 17.60, + "cacheReadPricePerMtok": 0.44, + "cacheWritePricePerMtok": 5.50, +} + + +class TestOpenAIFamilyCostAfterUsageNormalization: + """GPT-5.6 costs are only right once its usage buckets are disjoint. + + OpenAI reports an *inclusive* ``input_tokens``; Strands forwards it as + ``inputTokens`` alongside ``cacheReadInputTokens``. Fed in raw, every + cached token is priced at the input rate AND the cache-read rate. The + normalization at apis/shared/models/usage_normalization.py is what makes + the numbers below correct — these tests pin the dollar consequence. + """ + + def test_fully_cached_call_costs_only_the_cache_read_rate(self): + # 30k-token stable prefix served entirely from cache, no new input. + normalized_usage = { + "inputTokens": 0, + "outputTokens": 0, + "cacheReadInputTokens": 30_000, + } + + total_cost, breakdown = CostCalculator.calculate_message_cost( + normalized_usage, GPT_56_SOL_PRICING + ) + + assert total_cost == pytest.approx(30_000 / 1_000_000 * 0.44) + assert total_cost == pytest.approx(0.0132) + assert breakdown.input_cost == 0.0 + + def test_raw_openai_usage_would_double_bill_the_same_call(self): + """The regression guard: what the un-normalized dict costs.""" + raw_usage = { + "inputTokens": 30_000, # OpenAI's inclusive total + "outputTokens": 0, + "cacheReadInputTokens": 30_000, + } + + double_billed, _ = CostCalculator.calculate_message_cost( + raw_usage, GPT_56_SOL_PRICING + ) + correct, _ = CostCalculator.calculate_message_cost( + {**raw_usage, "inputTokens": 0}, GPT_56_SOL_PRICING + ) + + assert double_billed == pytest.approx(30_000 / 1_000_000 * (4.40 + 0.44)) + # 11x the real cost — the cache discount is not merely lost, the + # cached tokens are charged twice over. + assert double_billed == pytest.approx(correct * 11.0) + + def test_cache_write_premium_is_not_stacked_on_the_input_rate(self): + # A turn that writes a 30k prefix to cache: those tokens are inside + # OpenAI's input_tokens, so normalization subtracts them out and they + # are billed once, at 1.25x. + normalized_usage = { + "inputTokens": 100, + "outputTokens": 0, + "cacheWriteInputTokens": 30_000, + } + + total_cost, breakdown = CostCalculator.calculate_message_cost( + normalized_usage, GPT_56_SOL_PRICING + ) + + assert breakdown.cache_write_cost == pytest.approx(30_000 / 1_000_000 * 5.50) + assert breakdown.input_cost == pytest.approx(100 / 1_000_000 * 4.40) + assert total_cost == pytest.approx(0.16544) + + def test_mixed_read_write_call_prices_each_bucket_once(self): + # The realistic 5.6 turn: most of the prefix read from cache, a small + # increment written, a little genuinely new input. Buckets partition + # the 30,500 inclusive input tokens OpenAI reported. + normalized_usage = { + "inputTokens": 100, + "outputTokens": 120, + "cacheReadInputTokens": 30_000, + "cacheWriteInputTokens": 400, + } + + total_cost, breakdown = CostCalculator.calculate_message_cost( + normalized_usage, GPT_56_SOL_PRICING + ) + + assert ( + normalized_usage["inputTokens"] + + normalized_usage["cacheReadInputTokens"] + + normalized_usage["cacheWriteInputTokens"] + ) == 30_500 + assert breakdown.input_cost == pytest.approx(100 / 1_000_000 * 4.40) + assert breakdown.cache_read_cost == pytest.approx(30_000 / 1_000_000 * 0.44) + assert breakdown.cache_write_cost == pytest.approx(400 / 1_000_000 * 5.50) + assert breakdown.output_cost == pytest.approx(120 / 1_000_000 * 17.60) + assert total_cost == pytest.approx( + breakdown.input_cost + + breakdown.output_cost + + breakdown.cache_read_cost + + breakdown.cache_write_cost + ) diff --git a/backend/tests/fine_tuning/test_admin_routes.py b/backend/tests/fine_tuning/test_admin_routes.py index eb6bee181..b22deb04d 100644 --- a/backend/tests/fine_tuning/test_admin_routes.py +++ b/backend/tests/fine_tuning/test_admin_routes.py @@ -51,8 +51,8 @@ def _override_inf_repo(app: FastAPI, inf_repo: MagicMock): "email": "user@example.com", "granted_by": "admin@example.com", "granted_at": "2026-01-01T00:00:00Z", - "monthly_quota_hours": 10.0, - "current_month_usage_hours": 2.0, + "monthly_quota_usd": 10.0, + "current_month_usage_usd": 2.0, "quota_period": "2026-03", } @@ -102,7 +102,7 @@ def test_returns_201_with_new_grant(self, make_user): client = TestClient(app) resp = client.post( "/admin/fine-tuning/access", - json={"email": "user@example.com", "monthly_quota_hours": 10.0}, + json={"email": "user@example.com", "monthly_quota_usd": 10.0}, ) assert resp.status_code == 201 @@ -180,7 +180,7 @@ def test_returns_200_with_updated_grant(self, make_user): admin = make_user(email="admin@example.com", roles=["Admin"]) _override_auth(app, admin) - updated = {**SAMPLE_GRANT, "monthly_quota_hours": 50.0} + updated = {**SAMPLE_GRANT, "monthly_quota_usd": 50.0} mock_repo = MagicMock() mock_repo.update_quota.return_value = updated _override_repo(app, mock_repo) @@ -188,11 +188,11 @@ def test_returns_200_with_updated_grant(self, make_user): client = TestClient(app) resp = client.put( "/admin/fine-tuning/access/user@example.com", - json={"monthly_quota_hours": 50.0}, + json={"monthly_quota_usd": 50.0}, ) assert resp.status_code == 200 - assert resp.json()["monthly_quota_hours"] == 50.0 + assert resp.json()["monthly_quota_usd"] == 50.0 def test_returns_404_for_nonexistent(self, make_user): app = _create_app() @@ -206,7 +206,7 @@ def test_returns_404_for_nonexistent(self, make_user): client = TestClient(app) resp = client.put( "/admin/fine-tuning/access/nobody@example.com", - json={"monthly_quota_hours": 50.0}, + json={"monthly_quota_usd": 50.0}, ) assert resp.status_code == 404 diff --git a/backend/tests/fine_tuning/test_inference_routes.py b/backend/tests/fine_tuning/test_inference_routes.py index 789e33c6f..b3f6265ca 100644 --- a/backend/tests/fine_tuning/test_inference_routes.py +++ b/backend/tests/fine_tuning/test_inference_routes.py @@ -45,8 +45,8 @@ def _setup_deps( "email": "user@example.com", "granted_by": "admin@example.com", "granted_at": "2026-01-01T00:00:00Z", - "monthly_quota_hours": 10.0, - "current_month_usage_hours": 2.0, + "monthly_quota_usd": 10.0, + "current_month_usage_usd": 2.0, "quota_period": "2026-03", } @@ -239,7 +239,7 @@ def test_rejects_instance_type_with_no_known_price(self, make_user): ) assert resp.status_code == 400 - assert "Unsupported instance type" in resp.json()["detail"] + assert "is not available for" in resp.json()["detail"] mock_sm.create_transform_job.assert_not_called() @patch.dict("os.environ", {"PROJECT_PREFIX": "test-prefix"}) @@ -353,7 +353,7 @@ def test_returns_400_when_quota_insufficient(self, make_user): app = _create_app() user = make_user(email="user@example.com") - low_quota = {**SAMPLE_GRANT, "monthly_quota_hours": 10.0, "current_month_usage_hours": 9.8} + low_quota = {**SAMPLE_GRANT, "monthly_quota_usd": 10.0, "current_month_usage_usd": 9.8} mock_jobs = MagicMock() mock_jobs.get_job.return_value = SAMPLE_COMPLETED_TRAINING_JOB diff --git a/backend/tests/fine_tuning/test_inference_script.py b/backend/tests/fine_tuning/test_inference_script.py index 8aad9d8eb..d82c07e42 100644 --- a/backend/tests/fine_tuning/test_inference_script.py +++ b/backend/tests/fine_tuning/test_inference_script.py @@ -1,45 +1,96 @@ -"""Unit tests for SageMaker inference script handler functions.""" +"""Unit tests for the Batch Transform handler. + +``inference.py`` is a dispatcher: it resolves the task recorded in the model +artifact and delegates to the matching ``task_*`` module. These cover the +dispatch itself, the shared CSV output contract, and the per-task payload +parsing — the parts that must hold for every modality. +""" import json -import pytest -from unittest.mock import MagicMock, patch +import zipfile import numpy as np +import pytest +from unittest.mock import MagicMock +from apis.app_api.fine_tuning import task_types +from apis.app_api.fine_tuning.sagemaker_scripts import task_text_classification +from apis.app_api.fine_tuning.sagemaker_scripts import inference as inference_handler from apis.app_api.fine_tuning.sagemaker_scripts.inference import ( + _sanitize_label, input_fn, output_fn, - predict_fn, - _sanitize_label, + read_task_type, + resolve_task_module, ) +@pytest.fixture(autouse=True) +def _reset_loaded_task(): + """model_fn writes module state; keep it from leaking between tests.""" + inference_handler._LOADED_TASK_TYPE = None + yield + inference_handler._LOADED_TASK_TYPE = None + +TEXT_SPEC = task_types.get_task_spec(task_types.TEXT_CLASSIFICATION) + + +def _texts(records): + """Pull the text out of the record dicts input_fn now returns.""" + return [record["text"] for record in records] + + +class TestTaskDispatch: + """The handler must serve whichever task the artifact was trained for.""" + + def test_reads_the_recorded_task_type(self, tmp_path): + (tmp_path / "task_type.json").write_text( + json.dumps({"task_type": task_types.IMAGE_CLASSIFICATION}) + ) + + assert read_task_type(str(tmp_path)) == task_types.IMAGE_CLASSIFICATION + + def test_artifact_without_a_marker_is_text_classification(self, tmp_path): + """Artifacts trained before task types existed carry no marker. + + They are all text classifiers, and must keep loading. + """ + assert read_task_type(str(tmp_path)) == task_types.TEXT_CLASSIFICATION + + @pytest.mark.parametrize("task_type", task_types.TASK_TYPES) + def test_every_registered_task_has_an_inference_module(self, task_type): + module, spec = resolve_task_module(task_type) + + assert spec.task_type == task_type + for handler in ("model_fn", "input_fn", "predict_fn"): + assert hasattr(module, handler), f"{module.__name__} lacks {handler}" + + def test_unknown_task_type_raises(self): + with pytest.raises(ValueError, match="Unknown task type"): + resolve_task_module("image-to-interpretive-dance") + + class TestInputFn: + """With no model loaded the handler assumes text, preserving old behaviour.""" def test_text_plain_parses_lines(self): - body = "Hello world\nFoo bar\nBaz qux\n" - result = input_fn(body, "text/plain") - assert result == ["Hello world", "Foo bar", "Baz qux"] + result = input_fn("Hello world\nFoo bar\nBaz qux\n", "text/plain") + assert _texts(result) == ["Hello world", "Foo bar", "Baz qux"] def test_text_plain_skips_empty_lines(self): - body = "Hello\n\n \nWorld\n" - result = input_fn(body, "text/plain") - assert result == ["Hello", "World"] + assert _texts(input_fn("Hello\n\n \nWorld\n", "text/plain")) == ["Hello", "World"] def test_json_list_input(self): body = json.dumps(["Hello", "World"]) - result = input_fn(body, "application/json") - assert result == ["Hello", "World"] + assert _texts(input_fn(body, "application/json")) == ["Hello", "World"] def test_json_dict_with_texts_key(self): body = json.dumps({"texts": ["Hello", "World"]}) - result = input_fn(body, "application/json") - assert result == ["Hello", "World"] + assert _texts(input_fn(body, "application/json")) == ["Hello", "World"] def test_json_dict_without_texts_key_raises(self): - body = json.dumps({"data": ["Hello"]}) with pytest.raises(ValueError, match="must be a list"): - input_fn(body, "application/json") + input_fn(json.dumps({"data": ["Hello"]}), "application/json") def test_unsupported_content_type_raises(self): with pytest.raises(ValueError, match="Unsupported content type"): @@ -47,32 +98,86 @@ def test_unsupported_content_type_raises(self): def test_json_list_skips_empty_strings(self): body = json.dumps(["Hello", "", " ", "World"]) - result = input_fn(body, "application/json") - assert result == ["Hello", "World"] + assert _texts(input_fn(body, "application/json")) == ["Hello", "World"] def test_text_plain_bytes_input(self): """SageMaker HuggingFace DLC passes request_body as bytes.""" - body = b"Hello world\nFoo bar\nBaz qux\n" - result = input_fn(body, "text/plain") - assert result == ["Hello world", "Foo bar", "Baz qux"] + result = input_fn(b"Hello world\nFoo bar\n", "text/plain") + assert _texts(result) == ["Hello world", "Foo bar"] def test_json_bytes_input(self): - """JSON body delivered as bytes is decoded correctly.""" body = json.dumps(["Hello", "World"]).encode("utf-8") - result = input_fn(body, "application/json") - assert result == ["Hello", "World"] + assert _texts(input_fn(body, "application/json")) == ["Hello", "World"] def test_bytes_with_utf8_characters(self): - """Non-ASCII text encoded as UTF-8 bytes is handled correctly.""" body = "café latte\nnaïve résumé\n".encode("utf-8") - result = input_fn(body, "text/plain") - assert result == ["café latte", "naïve résumé"] + assert _texts(input_fn(body, "text/plain")) == ["café latte", "naïve résumé"] def test_bytearray_input(self): - """bytearray input is also decoded correctly.""" - body = bytearray(b"Hello\nWorld\n") - result = input_fn(body, "text/plain") - assert result == ["Hello", "World"] + assert _texts(input_fn(bytearray(b"Hello\nWorld\n"), "text/plain")) == [ + "Hello", + "World", + ] + + def test_text_records_identify_themselves_by_their_text(self): + records = input_fn("alpha\nbeta\n", "text/plain") + assert [r["identifier"] for r in records] == ["alpha", "beta"] + + def test_dispatches_to_the_loaded_task(self, tmp_path): + """A loaded image model must not parse its payload as newline text.""" + from apis.app_api.fine_tuning.sagemaker_scripts import task_image_classification + + archive = tmp_path / "images.zip" + png = bytes.fromhex( + "89504e470d0a1a0a0000000d4948445200000001000000010806000000" + "1f15c4890000000a49444154789c6360000002000100ffff0300000600" + "05570c9d0000000049454e44ae426082" + ) + with zipfile.ZipFile(archive, "w") as handle: + handle.writestr("a.png", png) + handle.writestr("b.png", png) + + records = input_fn( + archive.read_bytes(), + "application/zip", + {"task_type": task_types.IMAGE_CLASSIFICATION}, + ) + + assert sorted(r["identifier"] for r in records) == ["a.png", "b.png"] + assert all(task_image_classification is not None for _ in records) + + def test_image_task_rejects_a_non_archive_payload(self): + with pytest.raises(ValueError, match="Expected archive bytes"): + input_fn( + "not an archive", + "application/zip", + {"task_type": task_types.IMAGE_CLASSIFICATION}, + ) + + def test_dispatches_on_module_state_when_called_with_two_arguments(self): + """The toolkit calls input_fn(input_data, content_type) — no model. + + The loaded task therefore has to come from module state written by + model_fn. Without it an image artifact parses its .zip as newline + delimited text and fails on every record. + """ + inference_handler._LOADED_TASK_TYPE = task_types.IMAGE_CLASSIFICATION + + with pytest.raises(ValueError, match="Expected archive bytes"): + input_fn("alpha\nbeta\n", "application/zip") + + def test_falls_back_to_text_when_nothing_is_loaded(self): + assert inference_handler._LOADED_TASK_TYPE is None + assert _texts(input_fn("alpha\nbeta\n", "text/plain")) == ["alpha", "beta"] + + def test_an_explicit_model_argument_overrides_module_state(self): + inference_handler._LOADED_TASK_TYPE = task_types.IMAGE_CLASSIFICATION + + records = input_fn( + "alpha\n", "text/plain", {"task_type": task_types.TEXT_CLASSIFICATION} + ) + + assert _texts(records) == ["alpha"] class TestSanitizeLabel: @@ -94,69 +199,81 @@ def test_underscores_preserved(self): class TestOutputFn: + """One CSV shape for every task, so a new modality never breaks the viewer.""" def test_csv_header_includes_label_columns(self): - prediction = { - "texts": ["hello"], - "probabilities": np.array([[0.8, 0.2]]), - "labels": ["positive", "negative"], - } - result = output_fn(prediction) + result = output_fn( + { + "identifiers": ["hello"], + "probabilities": np.array([[0.8, 0.2]]), + "labels": ["positive", "negative"], + "identifier_column": "text", + } + ) + assert result.split("\n")[0] == "text,prob_positive,prob_negative" + + def test_image_task_names_the_first_column_image(self): + result = output_fn( + { + "identifiers": ["cats/a.png"], + "probabilities": np.array([[0.8, 0.2]]), + "labels": ["cat", "dog"], + "identifier_column": "image", + } + ) lines = result.split("\n") - assert lines[0] == "text,prob_positive,prob_negative" + assert lines[0] == "image,prob_cat,prob_dog" + assert lines[1].startswith('"cats/a.png"') def test_csv_row_has_quoted_text(self): - prediction = { - "texts": ["hello world"], - "probabilities": np.array([[0.85, 0.15]]), - "labels": ["pos", "neg"], - } - result = output_fn(prediction) - lines = result.split("\n") - assert lines[1].startswith('"hello world"') + result = output_fn( + { + "identifiers": ["hello world"], + "probabilities": np.array([[0.85, 0.15]]), + "labels": ["pos", "neg"], + } + ) + assert result.split("\n")[1].startswith('"hello world"') def test_escapes_quotes_in_text(self): - prediction = { - "texts": ['She said "hello"'], - "probabilities": np.array([[0.9, 0.1]]), - "labels": ["pos", "neg"], - } - result = output_fn(prediction) - lines = result.split("\n") - # Quotes in text should be doubled - assert '""hello""' in lines[1] + result = output_fn( + { + "identifiers": ['She said "hello"'], + "probabilities": np.array([[0.9, 0.1]]), + "labels": ["pos", "neg"], + } + ) + assert '""hello""' in result.split("\n")[1] def test_escapes_commas_in_text(self): - prediction = { - "texts": ["hello, world"], - "probabilities": np.array([[0.7, 0.3]]), - "labels": ["pos", "neg"], - } - result = output_fn(prediction) - lines = result.split("\n") - # Text with commas should be quoted - assert lines[1].startswith('"hello, world"') + result = output_fn( + { + "identifiers": ["hello, world"], + "probabilities": np.array([[0.7, 0.3]]), + "labels": ["pos", "neg"], + } + ) + assert result.split("\n")[1].startswith('"hello, world"') def test_multiple_rows(self): - prediction = { - "texts": ["a", "b", "c"], - "probabilities": np.array([[0.9, 0.1], [0.3, 0.7], [0.5, 0.5]]), - "labels": ["pos", "neg"], - } - result = output_fn(prediction) - lines = result.split("\n") - assert len(lines) == 4 # header + 3 rows + result = output_fn( + { + "identifiers": ["a", "b", "c"], + "probabilities": np.array([[0.9, 0.1], [0.3, 0.7], [0.5, 0.5]]), + "labels": ["pos", "neg"], + } + ) + assert len(result.split("\n")) == 4 # header + 3 rows def test_probability_values_six_decimals(self): - prediction = { - "texts": ["test"], - "probabilities": np.array([[0.123456789, 0.876543211]]), - "labels": ["a", "b"], - } - result = output_fn(prediction) - lines = result.split("\n") - # Should have 6 decimal places - assert "0.123457" in lines[1] # rounded + result = output_fn( + { + "identifiers": ["test"], + "probabilities": np.array([[0.123456789, 0.876543211]]), + "labels": ["a", "b"], + } + ) + assert "0.123457" in result.split("\n")[1] # rounded _has_torch = True @@ -167,50 +284,52 @@ def test_probability_values_six_decimals(self): @pytest.mark.skipif(not _has_torch, reason="torch not installed (SageMaker DLC only)") -class TestPredictFn: +class TestTextPredictFn: + + def _loaded(self, model, tokenizer): + return {"model": model, "tokenizer": tokenizer, "device": torch.device("cpu")} def test_empty_input_returns_empty(self): - model_tuple = (MagicMock(), MagicMock(), "cpu") - result = predict_fn([], model_tuple) - assert result["texts"] == [] + result = task_text_classification.predict_fn( + [], self._loaded(MagicMock(), MagicMock()), TEXT_SPEC + ) + assert result["identifiers"] == [] assert result["probabilities"].shape == (0, 0) assert result["labels"] == [] def test_returns_correct_structure(self): - # Create mock model that returns logits mock_model = MagicMock() mock_model.config.id2label = {0: "positive", 1: "negative"} + outputs = MagicMock() + outputs.logits = torch.tensor([[2.0, -1.0], [0.5, 1.5]]) + mock_model.return_value = outputs - mock_outputs = MagicMock() - mock_outputs.logits = torch.tensor([[2.0, -1.0], [0.5, 1.5]]) - - mock_model.return_value = mock_outputs - - # Mock tokenizer mock_tokenizer = MagicMock() mock_tokenizer.return_value = { "input_ids": torch.tensor([[1, 2], [3, 4]]), "attention_mask": torch.tensor([[1, 1], [1, 1]]), } - device = torch.device("cpu") - result = predict_fn(["hello", "world"], (mock_model, mock_tokenizer, device)) + records = [ + {"text": "hello", "identifier": "hello"}, + {"text": "world", "identifier": "world"}, + ] + result = task_text_classification.predict_fn( + records, self._loaded(mock_model, mock_tokenizer), TEXT_SPEC + ) - assert result["texts"] == ["hello", "world"] + assert result["identifiers"] == ["hello", "world"] assert result["probabilities"].shape == (2, 2) assert result["labels"] == ["positive", "negative"] - - # Probabilities should sum to 1 for each row (softmax) for row in result["probabilities"]: assert abs(sum(row) - 1.0) < 1e-5 def test_uses_class_prefix_when_no_id2label(self): mock_model = MagicMock() mock_model.config.id2label = None - - mock_outputs = MagicMock() - mock_outputs.logits = torch.tensor([[1.0, 2.0, 3.0]]) - mock_model.return_value = mock_outputs + outputs = MagicMock() + outputs.logits = torch.tensor([[1.0, 2.0, 3.0]]) + mock_model.return_value = outputs mock_tokenizer = MagicMock() mock_tokenizer.return_value = { @@ -218,7 +337,10 @@ def test_uses_class_prefix_when_no_id2label(self): "attention_mask": torch.tensor([[1, 1]]), } - device = torch.device("cpu") - result = predict_fn(["test"], (mock_model, mock_tokenizer, device)) + result = task_text_classification.predict_fn( + [{"text": "test", "identifier": "test"}], + self._loaded(mock_model, mock_tokenizer), + TEXT_SPEC, + ) assert result["labels"] == ["class_0", "class_1", "class_2"] diff --git a/backend/tests/fine_tuning/test_job_guards.py b/backend/tests/fine_tuning/test_job_guards.py new file mode 100644 index 000000000..4dbdcdd5b --- /dev/null +++ b/backend/tests/fine_tuning/test_job_guards.py @@ -0,0 +1,334 @@ +"""Tests for the guards that stand between a request and a billed GPU. + +Each of these prevents a job that would provision hardware and then fail, or +spend past a user's quota. They are the reason a bad submission returns a 400 +in milliseconds rather than an opaque traceback several billed minutes later. +""" + +import httpx +import pytest +from fastapi import HTTPException + +from apis.app_api.fine_tuning import task_types +from apis.app_api.fine_tuning.routes import ( + MIN_BUDGETED_RUNTIME_SECONDS, + _budgeted_runtime, + _validate_dataset_format, + _validate_instance_type, + preflight_huggingface_model, + validate_huggingface_model_id, +) + +TEXT_SPEC = task_types.get_task_spec(task_types.TEXT_CLASSIFICATION) +IMAGE_SPEC = task_types.get_task_spec(task_types.IMAGE_CLASSIFICATION) +IMAGE_TEXT_SPEC = task_types.get_task_spec(task_types.IMAGE_TEXT_CLASSIFICATION) + + +class TestDatasetFormatIsTaskAware: + + def test_text_task_accepts_a_manifest(self): + _validate_dataset_format("datasets/u/1/train.csv", TEXT_SPEC) + _validate_dataset_format("datasets/u/1/train.jsonl", TEXT_SPEC) + + def test_text_task_rejects_an_archive(self): + with pytest.raises(HTTPException) as excinfo: + _validate_dataset_format("datasets/u/1/train.zip", TEXT_SPEC) + assert excinfo.value.status_code == 400 + + def test_image_task_requires_an_archive(self): + """A bare CSV cannot carry the images it references.""" + with pytest.raises(HTTPException) as excinfo: + _validate_dataset_format("datasets/u/1/train.csv", IMAGE_SPEC) + assert "zip" in excinfo.value.detail.lower() + + def test_image_task_accepts_a_zip(self): + _validate_dataset_format("datasets/u/1/train.zip", IMAGE_SPEC) + + def test_error_names_the_required_columns(self): + with pytest.raises(HTTPException) as excinfo: + _validate_dataset_format("bad.csv", IMAGE_TEXT_SPEC) + for column in ("image", "text", "label"): + assert f'"{column}"' in excinfo.value.detail + + +class TestInstanceValidation: + + def test_accepts_a_priced_training_instance(self): + _validate_instance_type("ml.g6.xlarge") + + def test_rejects_an_unpriced_instance(self): + """An unpriced instance runs real GPUs and records no spend.""" + with pytest.raises(HTTPException) as excinfo: + _validate_instance_type("ml.p4d.24xlarge") + assert excinfo.value.status_code == 400 + + def test_transform_uses_its_own_table(self): + _validate_instance_type("ml.g6.xlarge", transform=True) + + def test_error_names_the_operation(self): + with pytest.raises(HTTPException) as excinfo: + _validate_instance_type("ml.p3.2xlarge", transform=True) + assert "Batch Transform" in excinfo.value.detail + + +class TestBudgetedRuntime: + """The budget becomes the stopping condition, bounding spend exactly.""" + + def test_leaves_an_affordable_request_alone(self): + # $100 buys ~71h on ml.g5.xlarge, well over the 4h requested. + assert _budgeted_runtime(14400, "ml.g5.xlarge", 100.0) == 14400 + + def test_clamps_to_what_the_budget_affords(self): + # $10 / $1.408 per hour = 7.10h = 25568s, under the 24h requested. + effective = _budgeted_runtime(86400, "ml.g5.xlarge", 10.0) + assert effective == int((10.0 / 1.408) * 3600) + assert effective < 86400 + + def test_a_full_day_is_admitted_rather_than_rejected(self): + """Worst-case rejection would block every job at the 24h default. + + 24h on the cheapest instance is ~$27, more than any ordinary monthly + quota, even though such a job typically finishes in minutes. + """ + assert _budgeted_runtime(86400, "ml.g6.xlarge", 15.0) > 0 + + def test_rejects_a_budget_too_small_to_be_useful(self): + with pytest.raises(HTTPException) as excinfo: + _budgeted_runtime(86400, "ml.g5.xlarge", 0.10) + assert "Insufficient quota" in excinfo.value.detail + + def test_the_floor_is_exactly_the_minimum_runtime(self): + rate = 1.408 + just_enough = (MIN_BUDGETED_RUNTIME_SECONDS / 3600) * rate + assert _budgeted_runtime(86400, "ml.g5.xlarge", just_enough * 1.01) > 0 + with pytest.raises(HTTPException): + _budgeted_runtime(86400, "ml.g5.xlarge", just_enough * 0.5) + + def test_transform_prices_against_the_transform_table(self): + assert _budgeted_runtime(3600, "ml.g6.xlarge", 50.0, transform=True) == 3600 + + +class _FakeResponse: + def __init__(self, status_code, payload=None): + self.status_code = status_code + self._payload = payload or {} + + def json(self): + return self._payload + + +class _FakeClient: + def __init__(self, response=None, error=None): + self._response = response + self._error = error + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return False + + async def get(self, url): + if self._error: + raise self._error + return self._response + + +def _patch_client(monkeypatch, *, response=None, error=None): + monkeypatch.setattr( + httpx, "AsyncClient", lambda *a, **k: _FakeClient(response, error) + ) + + +@pytest.mark.asyncio +class TestHuggingFacePreflight: + """Ask the Hub whether a model can serve the task before billing a GPU.""" + + async def test_accepts_a_trainable_model(self, monkeypatch): + _patch_client( + monkeypatch, + response=_FakeResponse( + 200, + { + "pipeline_tag": "fill-mask", + "siblings": [{"rfilename": "model.safetensors"}], + }, + ), + ) + await preflight_huggingface_model("bert-base-uncased", TEXT_SPEC) + + async def test_rejects_a_gguf_only_repository(self, monkeypatch): + """GGUF is a llama.cpp inference format and cannot be fine-tuned. + + Without this the job provisions a GPU and dies in from_pretrained. + """ + _patch_client( + monkeypatch, + response=_FakeResponse( + 200, + { + "pipeline_tag": "image-text-to-text", + "siblings": [ + {"rfilename": "model-Q4_K_M.gguf"}, + {"rfilename": "README.md"}, + ], + }, + ), + ) + with pytest.raises(HTTPException) as excinfo: + await preflight_huggingface_model("someone/model-GGUF", IMAGE_TEXT_SPEC) + assert "GGUF" in excinfo.value.detail + + async def test_rejects_a_repository_with_no_weights(self, monkeypatch): + _patch_client( + monkeypatch, + response=_FakeResponse( + 200, {"pipeline_tag": "fill-mask", "siblings": [{"rfilename": "README.md"}]} + ), + ) + with pytest.raises(HTTPException) as excinfo: + await preflight_huggingface_model("someone/docs-only", TEXT_SPEC) + assert "no loadable model weights" in excinfo.value.detail + + async def test_rejects_a_modality_mismatch(self, monkeypatch): + """An image model cannot be fine-tuned for text classification.""" + _patch_client( + monkeypatch, + response=_FakeResponse( + 200, + { + "pipeline_tag": "image-classification", + "siblings": [{"rfilename": "model.safetensors"}], + }, + ), + ) + with pytest.raises(HTTPException) as excinfo: + await preflight_huggingface_model("google/vit-base-patch16-224", TEXT_SPEC) + assert "not compatible" in excinfo.value.detail + + async def test_accepts_a_matching_modality(self, monkeypatch): + _patch_client( + monkeypatch, + response=_FakeResponse( + 200, + { + "pipeline_tag": "image-classification", + "siblings": [{"rfilename": "model.safetensors"}], + }, + ), + ) + await preflight_huggingface_model("google/vit-base-patch16-224", IMAGE_SPEC) + + async def test_reports_a_missing_model(self, monkeypatch): + _patch_client(monkeypatch, response=_FakeResponse(404)) + with pytest.raises(HTTPException) as excinfo: + await preflight_huggingface_model("nobody/nothing", TEXT_SPEC) + assert "was not found" in excinfo.value.detail + + async def test_an_unreachable_hub_does_not_block_submission(self, monkeypatch): + """The Hub being down is our problem, not the researcher's.""" + _patch_client(monkeypatch, error=httpx.ConnectError("boom")) + await preflight_huggingface_model("bert-base-uncased", TEXT_SPEC) + + async def test_a_hub_error_response_does_not_block_submission(self, monkeypatch): + _patch_client(monkeypatch, response=_FakeResponse(503)) + await preflight_huggingface_model("bert-base-uncased", TEXT_SPEC) + + async def test_rejects_a_generative_vlm_for_the_dual_encoder_task(self, monkeypatch): + """LLaVA-style models are image-text, but not dual encoders. + + They are generative: there is no text tower to pool, so the fusion + head has nothing to concatenate. The trainer catches this, but only + after a GPU has been provisioned — so the tag filter must exclude + them here, before anything is billed. + """ + _patch_client( + monkeypatch, + response=_FakeResponse( + 200, + { + "pipeline_tag": "image-text-to-text", + "siblings": [{"rfilename": "model-00001-of-00003.safetensors"}], + }, + ), + ) + with pytest.raises(HTTPException) as excinfo: + await preflight_huggingface_model("llava-hf/llava-1.5-7b-hf", IMAGE_TEXT_SPEC) + assert "not compatible" in excinfo.value.detail + + async def test_accepts_a_dual_encoder(self, monkeypatch): + _patch_client( + monkeypatch, + response=_FakeResponse( + 200, + { + "pipeline_tag": "zero-shot-image-classification", + "siblings": [{"rfilename": "model.safetensors"}], + }, + ), + ) + await preflight_huggingface_model("openai/clip-vit-base-patch32", IMAGE_TEXT_SPEC) + + async def test_an_untagged_model_is_allowed_through(self, monkeypatch): + """Many valid checkpoints simply carry no pipeline_tag.""" + _patch_client( + monkeypatch, + response=_FakeResponse( + 200, {"pipeline_tag": None, "siblings": [{"rfilename": "model.safetensors"}]} + ), + ) + await preflight_huggingface_model("someone/untagged", TEXT_SPEC) + + +class TestValidateHuggingFaceModelId: + """The id reaches two sinks: a Hub URL path, and the training job's + ``model_name_or_path``. A length ceiling alone let a value carrying URL + structure change the meaning of both.""" + + @pytest.mark.parametrize( + "hf_id", + [ + "bert-base-uncased", + "openai/clip-vit-base-patch32", + "meta-llama/Llama-3.2-1B", + "org/model.with.dots", + "org/model_with_underscores", + ], + ) + def test_accepts_real_repo_ids(self, hf_id): + assert validate_huggingface_model_id(hf_id) == hf_id + + def test_strips_surrounding_whitespace(self): + assert validate_huggingface_model_id(" org/model ") == "org/model" + + @pytest.mark.parametrize( + "hf_id", + [ + "../../etc/passwd", + "org/../../admin", + "..%2f..%2fadmin", + "org/model?redirect=https://evil.example", + "org/model#frag", + "org/model/extra", + "//evil.example/path", + "https://evil.example/model", + "org model", + "org/mo\ndel", + "", + " ", + "/leading-slash/model", + ], + ) + def test_rejects_anything_carrying_url_structure(self, hf_id): + with pytest.raises(HTTPException) as excinfo: + validate_huggingface_model_id(hf_id) + assert excinfo.value.status_code == 400 + + def test_a_trailing_newline_is_stripped_not_smuggled(self): + """`$` would match before a trailing newline; the pattern uses `\\Z`.""" + assert validate_huggingface_model_id("org/model\n") == "org/model" + + def test_rejects_an_overlong_id(self): + with pytest.raises(HTTPException) as excinfo: + validate_huggingface_model_id("a" * 201) + assert excinfo.value.status_code == 400 diff --git a/backend/tests/fine_tuning/test_job_routes.py b/backend/tests/fine_tuning/test_job_routes.py index 4c1f8a892..2672a29ca 100644 --- a/backend/tests/fine_tuning/test_job_routes.py +++ b/backend/tests/fine_tuning/test_job_routes.py @@ -43,8 +43,8 @@ def _setup_deps(app, user, grant, jobs_repo=None, s3_service=None, sagemaker=Non "email": "user@example.com", "granted_by": "admin@example.com", "granted_at": "2026-01-01T00:00:00Z", - "monthly_quota_hours": 10.0, - "current_month_usage_hours": 2.0, + "monthly_quota_usd": 10.0, + "current_month_usage_usd": 2.0, "quota_period": "2026-03", } @@ -231,7 +231,7 @@ def test_rejects_instance_type_with_no_known_price(self, make_user): ) assert resp.status_code == 400 - assert "Unsupported instance type" in resp.json()["detail"] + assert "is not available for" in resp.json()["detail"] mock_sm.create_training_job.assert_not_called() def test_accepts_a_priced_instance_type(self, make_user): @@ -351,7 +351,7 @@ def test_returns_400_when_quota_insufficient(self, make_user): app = _create_app() user = make_user(email="user@example.com") - low_quota_grant = {**SAMPLE_GRANT, "monthly_quota_hours": 10.0, "current_month_usage_hours": 9.5} + low_quota_grant = {**SAMPLE_GRANT, "monthly_quota_usd": 10.0, "current_month_usage_usd": 9.5} mock_s3 = MagicMock() mock_s3.check_object_exists.return_value = True diff --git a/backend/tests/fine_tuning/test_repository.py b/backend/tests/fine_tuning/test_repository.py index 3bab9ae76..9ac2cda82 100644 --- a/backend/tests/fine_tuning/test_repository.py +++ b/backend/tests/fine_tuning/test_repository.py @@ -4,6 +4,8 @@ from datetime import datetime, timezone from unittest.mock import patch +from apis.app_api.fine_tuning.repository import DEFAULT_QUOTA_USD + class TestGrantAccess: @@ -13,21 +15,21 @@ def test_grant_access_creates_item(self, repository): assert result["email"] == "alice@example.com" assert result["granted_by"] == "admin@example.com" assert result["granted_at"] != "" - assert result["monthly_quota_hours"] == 10.0 - assert result["current_month_usage_hours"] == 0.0 + assert result["monthly_quota_usd"] == DEFAULT_QUOTA_USD + assert result["current_month_usage_usd"] == 0.0 assert result["quota_period"] != "" def test_grant_access_normalizes_email_to_lowercase(self, repository): result = repository.grant_access("Alice@Example.COM", "admin@example.com") assert result["email"] == "alice@example.com" - def test_grant_access_default_quota_is_10_hours(self, repository): + def test_grant_access_default_quota_is_the_dollar_default(self, repository): result = repository.grant_access("user@example.com", "admin@example.com") - assert result["monthly_quota_hours"] == 10.0 + assert result["monthly_quota_usd"] == DEFAULT_QUOTA_USD def test_grant_access_custom_quota(self, repository): - result = repository.grant_access("user@example.com", "admin@example.com", monthly_quota_hours=25.0) - assert result["monthly_quota_hours"] == 25.0 + result = repository.grant_access("user@example.com", "admin@example.com", monthly_quota_usd=25.0) + assert result["monthly_quota_usd"] == 25.0 def test_grant_access_raises_on_duplicate(self, repository): repository.grant_access("dup@example.com", "admin@example.com") @@ -73,12 +75,12 @@ def test_list_access_returns_all_grants(self, repository): class TestUpdateQuota: - def test_update_quota_changes_monthly_hours(self, repository): - repository.grant_access("user@example.com", "admin@example.com", monthly_quota_hours=10.0) + def test_update_quota_changes_monthly_dollars(self, repository): + repository.grant_access("user@example.com", "admin@example.com", monthly_quota_usd=10.0) result = repository.update_quota("user@example.com", 50.0) assert result is not None - assert result["monthly_quota_hours"] == 50.0 + assert result["monthly_quota_usd"] == 50.0 def test_update_quota_returns_none_for_nonexistent(self, repository): result = repository.update_quota("nobody@example.com", 50.0) @@ -107,7 +109,7 @@ def test_no_reset_when_same_period(self, repository): result = repository.check_and_reset_quota("user@example.com") assert result is not None - assert result["current_month_usage_hours"] == 3.5 + assert result["current_month_usage_usd"] == 3.5 def test_resets_usage_when_new_month(self, repository): repository.grant_access("user@example.com", "admin@example.com") @@ -120,7 +122,7 @@ def test_resets_usage_when_new_month(self, repository): result = repository.check_and_reset_quota("user@example.com") assert result is not None - assert result["current_month_usage_hours"] == 0.0 + assert result["current_month_usage_usd"] == 0.0 assert result["quota_period"] == "2099-12" def test_returns_none_for_nonexistent(self, repository): @@ -130,12 +132,12 @@ def test_returns_none_for_nonexistent(self, repository): class TestIncrementUsage: - def test_increment_adds_hours_atomically(self, repository): + def test_increment_adds_dollars_atomically(self, repository): repository.grant_access("user@example.com", "admin@example.com") result = repository.increment_usage("user@example.com", 2.5) assert result is not None - assert result["current_month_usage_hours"] == 2.5 + assert result["current_month_usage_usd"] == 2.5 def test_increment_accumulates_across_calls(self, repository): repository.grant_access("user@example.com", "admin@example.com") @@ -144,8 +146,85 @@ def test_increment_accumulates_across_calls(self, repository): repository.increment_usage("user@example.com", 2.5) result = repository.increment_usage("user@example.com", 0.5) - assert result["current_month_usage_hours"] == pytest.approx(4.0) + assert result["current_month_usage_usd"] == pytest.approx(4.0) def test_increment_returns_none_for_nonexistent(self, repository): result = repository.increment_usage("nobody@example.com", 1.0) assert result is None + + +class TestLegacyQuotaMigration: + """Grants written before the quota moved to dollars must keep working. + + The conversion is lazy: a record is read in the new shape and rewritten on + the next check_and_reset_quota, so no environment needs a backfill run. + """ + + def _seed_legacy(self, repository, email, hours, used_hours, period): + from decimal import Decimal + + repository._table.put_item( + Item={ + "PK": f"EMAIL#{email}", + "SK": "ACCESS", + "email": email, + "granted_by": "admin@example.com", + "granted_at": "2026-01-01T00:00:00+00:00", + "monthly_quota_hours": Decimal(str(hours)), + "current_month_usage_hours": Decimal(str(used_hours)), + "quota_period": period, + } + ) + + def _period(self): + return datetime.now(timezone.utc).strftime("%Y-%m") + + def test_reads_a_legacy_grant_as_dollars(self, repository): + from apis.app_api.fine_tuning.repository import LEGACY_HOURS_TO_USD + + self._seed_legacy(repository, "old@example.com", 10, 2.5, self._period()) + + grant = repository.get_access("old@example.com") + + assert grant["monthly_quota_usd"] == round(10 * LEGACY_HOURS_TO_USD, 4) + assert grant["current_month_usage_usd"] == round(2.5 * LEGACY_HOURS_TO_USD, 4) + assert "monthly_quota_hours" not in grant + + def test_migration_preserves_spend_within_the_period(self, repository): + """A user must not be able to zero their own spend by being migrated.""" + self._seed_legacy(repository, "old@example.com", 10, 2.5, self._period()) + + grant = repository.check_and_reset_quota("old@example.com") + + assert grant["current_month_usage_usd"] > 0 + + def test_migration_rewrites_the_stored_record(self, repository): + self._seed_legacy(repository, "old@example.com", 10, 2.5, self._period()) + + repository.check_and_reset_quota("old@example.com") + + item = repository._table.get_item( + Key={"PK": "EMAIL#old@example.com", "SK": "ACCESS"} + )["Item"] + assert "monthly_quota_usd" in item + assert "monthly_quota_hours" not in item + assert "current_month_usage_hours" not in item + + def test_a_new_month_still_resets_a_legacy_grant(self, repository): + self._seed_legacy(repository, "old@example.com", 10, 9.0, "2020-01") + + grant = repository.check_and_reset_quota("old@example.com") + + assert grant["current_month_usage_usd"] == 0.0 + assert grant["quota_period"] == self._period() + + def test_update_quota_clears_the_legacy_field(self, repository): + self._seed_legacy(repository, "old@example.com", 10, 0, self._period()) + + repository.update_quota("old@example.com", 42.0) + + item = repository._table.get_item( + Key={"PK": "EMAIL#old@example.com", "SK": "ACCESS"} + )["Item"] + assert float(item["monthly_quota_usd"]) == 42.0 + assert "monthly_quota_hours" not in item diff --git a/backend/tests/fine_tuning/test_task_types.py b/backend/tests/fine_tuning/test_task_types.py new file mode 100644 index 000000000..4c2b0d51a --- /dev/null +++ b/backend/tests/fine_tuning/test_task_types.py @@ -0,0 +1,220 @@ +"""Tests for the task-type registry and the invariants that hang off it.""" + +import pytest + +from apis.app_api.fine_tuning import pricing, task_types +from apis.app_api.fine_tuning.job_models import ( + AVAILABLE_MODELS, + MODEL_CATALOG, + models_for_task, +) + + +class TestRegistry: + + def test_every_listed_task_resolves(self): + for task_type in task_types.TASK_TYPES: + assert task_types.get_task_spec(task_type).task_type == task_type + + def test_unknown_task_raises(self): + with pytest.raises(ValueError, match="Unknown task type"): + task_types.get_task_spec("interpretive-dance") + + def test_none_resolves_to_the_legacy_default(self): + """Job rows written before task types existed have no attribute. + + They are all text classifiers and must keep resolving. + """ + assert task_types.get_task_spec(None).task_type == task_types.TEXT_CLASSIFICATION + assert task_types.get_task_spec("").task_type == task_types.TEXT_CLASSIFICATION + + def test_default_task_is_text_classification(self): + assert task_types.DEFAULT_TASK_TYPE == task_types.TEXT_CLASSIFICATION + + def test_task_order_is_deterministic(self): + """User-facing lists must not reorder between calls.""" + assert task_types.TASK_TYPES == tuple(task_types.TASK_TYPES) + assert list(task_types.TASK_SPECS) == list(task_types.TASK_SPECS) + + @pytest.mark.parametrize("task_type", task_types.TASK_TYPES) + def test_label_column_is_required(self, task_type): + spec = task_types.get_task_spec(task_type) + assert spec.label_column in spec.required_columns + + @pytest.mark.parametrize("task_type", task_types.TASK_TYPES) + def test_image_tasks_require_an_archive(self, task_type): + """A task referencing image files needs them bundled with the manifest.""" + spec = task_types.get_task_spec(task_type) + if spec.image_column is not None: + assert spec.requires_archive + assert spec.image_column in spec.required_columns + assert spec.upload_extensions == (".zip",) + else: + assert not spec.requires_archive + + def test_requires_images_predicate(self): + assert not task_types.requires_images(task_types.TEXT_CLASSIFICATION) + assert task_types.requires_images(task_types.IMAGE_CLASSIFICATION) + assert task_types.requires_images(task_types.IMAGE_TEXT_CLASSIFICATION) + + def test_archive_task_list_matches_the_specs(self): + assert task_types.ARCHIVE_TASK_TYPES == ( + task_types.IMAGE_CLASSIFICATION, + task_types.IMAGE_TEXT_CLASSIFICATION, + ) + + @pytest.mark.parametrize("task_type", task_types.TASK_TYPES) + def test_payload_size_is_within_batch_transform_limits(self, task_type): + """Batch Transform caps MaxPayloadInMB at 100.""" + spec = task_types.get_task_spec(task_type) + assert 0 < spec.inference_max_payload_mb <= 100 + + @pytest.mark.parametrize("task_type", task_types.TASK_TYPES) + def test_default_instance_is_priced(self, task_type): + """A task defaulting to an unpriced instance is unusable on arrival.""" + spec = task_types.get_task_spec(task_type) + assert pricing.training_rate(spec.default_instance_type) is not None + assert pricing.transform_rate(spec.default_instance_type) is not None + + +class TestCatalog: + + @pytest.mark.parametrize("model", AVAILABLE_MODELS, ids=lambda m: m.model_id) + def test_every_model_declares_a_known_task(self, model): + assert model.task_type in task_types.TASK_TYPES + + @pytest.mark.parametrize("model", AVAILABLE_MODELS, ids=lambda m: m.model_id) + def test_every_model_default_instance_is_priced(self, model): + assert pricing.training_rate(model.default_instance_type) is not None + + def test_model_ids_are_unique(self): + ids = [m.model_id for m in AVAILABLE_MODELS] + assert len(ids) == len(set(ids)) + + def test_every_task_has_at_least_one_model(self): + for task_type in task_types.TASK_TYPES: + assert models_for_task(task_type), f"no catalog models for {task_type}" + + def test_models_for_task_filters(self): + image_models = models_for_task(task_types.IMAGE_CLASSIFICATION) + assert all(m.task_type == task_types.IMAGE_CLASSIFICATION for m in image_models) + assert "bert-base-uncased" not in {m.model_id for m in image_models} + + def test_text_model_defaults_are_unchanged(self): + """The catalog refactor must not silently retune existing models.""" + assert MODEL_CATALOG["gpt2-medium"].default_hyperparameters == { + "epochs": "3", + "learning_rate": "2e-5", + "weight_decay": "0.01", + "split_ratio": "0.8", + "seed": "42", + "per_device_train_batch_size": "8", + "context_length": "512", + } + assert ( + MODEL_CATALOG["electra-tiny"].default_hyperparameters[ + "per_device_train_batch_size" + ] + == "32" + ) + assert ( + MODEL_CATALOG["eurollm-1.7b-instruct"].default_hyperparameters[ + "learning_rate" + ] + == "2e-5" + ) + + +class TestPricing: + + def test_training_and_transform_tables_are_populated(self): + assert pricing.TRAINING_COST_PER_HOUR + assert pricing.TRANSFORM_COST_PER_HOUR + + def test_retired_p3_family_is_absent(self): + """The Price List API returns no on-demand SageMaker rate for ml.p3.*. + + Pricing them let a caller pick an instance no job could provision. + """ + assert not [i for i in pricing.TRAINING_COST_PER_HOUR if i.startswith("ml.p3.")] + + def test_g5_16xlarge_uses_the_real_rate(self): + """This was 6.10 against an actual 5.12 — a ~19% overcharge.""" + assert pricing.training_rate("ml.g5.16xlarge") == 5.12 + + def test_unpriced_instance_returns_none(self): + assert pricing.training_rate("ml.nonexistent.xlarge") is None + assert pricing.transform_rate("ml.nonexistent.xlarge") is None + + def test_cost_is_prorated_by_seconds(self): + assert pricing.calculate_cost("ml.g6.xlarge", 3600) == pytest.approx(1.127) + assert pricing.calculate_cost("ml.g6.xlarge", 1800) == pytest.approx(0.5635) + + def test_cost_of_an_unpriced_instance_is_zero(self): + assert pricing.calculate_cost("ml.nope.xlarge", 3600) == 0.0 + + def test_transform_rate_can_differ_from_training(self): + """One shared map would silently misprice these.""" + assert pricing.training_rate("ml.g6e.24xlarge") != pricing.transform_rate( + "ml.g6e.24xlarge" + ) + + def test_estimate_max_cost_uses_full_runtime(self): + assert pricing.estimate_max_cost("ml.g5.xlarge", 86400) == pytest.approx(33.792) + + def test_supported_lists_are_cheapest_first(self): + instances = pricing.supported_training_instances() + rates = [pricing.training_rate(i) for i in instances] + assert rates == sorted(rates) + + +class TestDlcFamilies: + """Text and vision must resolve to different containers.""" + + def _service(self, monkeypatch): + monkeypatch.setenv("AWS_REGION", "us-west-2") + monkeypatch.delenv("FINE_TUNING_TRAINING_IMAGE_VISION", raising=False) + monkeypatch.delenv("FINE_TUNING_TRAINING_IMAGE_TEXT", raising=False) + from apis.app_api.fine_tuning.sagemaker_service import SageMakerService + + return SageMakerService(sagemaker_client=object(), logs_client=object()) + + def test_text_keeps_the_validated_container(self, monkeypatch): + """Every existing model was trained on transformers 4.36. + + Bumping this image would re-baseline all of them at once. + """ + service = self._service(monkeypatch) + uri = service.get_huggingface_image_uri(task_types.TEXT_CLASSIFICATION) + assert "transformers4.36" in uri + + def test_vision_gets_a_modern_container(self, monkeypatch): + service = self._service(monkeypatch) + for task_type in task_types.ARCHIVE_TASK_TYPES: + uri = service.get_huggingface_image_uri(task_type) + assert "transformers4.56" in uri + + def test_legacy_call_without_a_task_uses_the_text_image(self, monkeypatch): + service = self._service(monkeypatch) + assert service.get_huggingface_image_uri() == service.get_huggingface_image_uri( + task_types.TEXT_CLASSIFICATION + ) + + def test_environment_override_wins(self, monkeypatch): + """Escape hatch for a retired or region-lagging DLC tag.""" + service = self._service(monkeypatch) + monkeypatch.setenv( + "FINE_TUNING_TRAINING_IMAGE_VISION", "1.dkr.ecr.us-west-2.amazonaws.com/x:y" + ) + assert ( + service.get_huggingface_image_uri(task_types.IMAGE_CLASSIFICATION) + == "1.dkr.ecr.us-west-2.amazonaws.com/x:y" + ) + + def test_unsupported_region_raises(self, monkeypatch): + monkeypatch.setenv("AWS_REGION", "ap-south-1") + from apis.app_api.fine_tuning.sagemaker_service import SageMakerService + + service = SageMakerService(sagemaker_client=object(), logs_client=object()) + with pytest.raises(ValueError, match="No HuggingFace DLC image"): + service.get_huggingface_image_uri(task_types.TEXT_CLASSIFICATION) diff --git a/backend/tests/fine_tuning/test_train_script.py b/backend/tests/fine_tuning/test_train_script.py index 6731b34c3..244811f94 100644 --- a/backend/tests/fine_tuning/test_train_script.py +++ b/backend/tests/fine_tuning/test_train_script.py @@ -1,19 +1,58 @@ -"""Unit tests for SageMaker training script core functions.""" +"""Unit tests for the shared training helpers in ``task_common``. + +These deliberately avoid torch/transformers: the dataset, archive and label +contracts have to be assertable in the backend venv, where the ML stack is +absent. That constraint is why the helpers live in a module that imports its +heavy dependencies lazily. +""" + +import io +import json +import zipfile -import os import pytest -from unittest.mock import MagicMock, patch, call +from unittest.mock import MagicMock, patch -from apis.app_api.fine_tuning.sagemaker_scripts.train import ( - resolve_max_context_length, - find_dataset_in_channel, - load_dataset_frame, - resolve_dataset_reader, - validate_dataset_columns, - SUPPORTED_DATASET_EXTENSIONS, - copy_inference_script, +from apis.app_api.fine_tuning import task_types +from apis.app_api.fine_tuning.sagemaker_scripts.task_common import ( + DATASET_READERS, DynamoDBProgressCallback, SageMakerLoggingCallback, + build_label_mapping, + copy_inference_bundle, + extract_archive, + find_file_in_dir, + label_names, + load_manifest_frame, + prepare_dataset, + resolve_dataset_reader, + resolve_image_path, + resolve_max_context_length, + validate_dataset_columns, +) + +TEXT_SPEC = task_types.get_task_spec(task_types.TEXT_CLASSIFICATION) +IMAGE_SPEC = task_types.get_task_spec(task_types.IMAGE_CLASSIFICATION) +IMAGE_TEXT_SPEC = task_types.get_task_spec(task_types.IMAGE_TEXT_CLASSIFICATION) + +MANIFEST_EXTENSIONS = TEXT_SPEC.manifest_extensions + + +def _write_zip(path, entries): + """Write a zip from {archive name: bytes-or-str} and return its path.""" + with zipfile.ZipFile(path, "w") as archive: + for name, content in entries.items(): + if isinstance(content, str): + content = content.encode("utf-8") + archive.writestr(name, content) + return str(path) + + +# 1x1 PNG — enough for path resolution tests, which never decode the file. +PNG_BYTES = bytes.fromhex( + "89504e470d0a1a0a0000000d4948445200000001000000010806000000" + "1f15c4890000000a49444154789c6360000002000100ffff0300000600" + "05570c9d0000000049454e44ae426082" ) @@ -24,53 +63,72 @@ def test_returns_min_of_valid_values(self): config.max_position_embeddings = 512 config.n_positions = 1024 config.seq_length = None + config.text_config = None tokenizer = MagicMock() tokenizer.model_max_length = 2048 - result = resolve_max_context_length(config, tokenizer) - assert result == 512 + assert resolve_max_context_length(config, tokenizer) == 512 def test_returns_none_when_all_invalid(self): config = MagicMock(spec=[]) tokenizer = MagicMock(spec=[]) - result = resolve_max_context_length(config, tokenizer) - assert result is None + assert resolve_max_context_length(config, tokenizer) is None def test_ignores_very_large_values(self): config = MagicMock() config.max_position_embeddings = 2_000_000 config.n_positions = None config.seq_length = None + config.text_config = None tokenizer = MagicMock() tokenizer.model_max_length = 512 - result = resolve_max_context_length(config, tokenizer) - assert result == 512 + assert resolve_max_context_length(config, tokenizer) == 512 def test_uses_model_max_length_as_fallback(self): config = MagicMock() config.max_position_embeddings = None config.n_positions = None config.seq_length = None + config.text_config = None tokenizer = MagicMock() tokenizer.model_max_length = 768 - result = resolve_max_context_length(config, tokenizer) - assert result == 768 + assert resolve_max_context_length(config, tokenizer) == 768 + def test_reads_nested_text_config(self): + """Multimodal configs keep the text limits on config.text_config. -class TestFindDatasetInChannel: + Reading only the top level yields None on every vision-language model, + which silently drops the context cap. + """ + text_config = MagicMock() + text_config.max_position_embeddings = 77 + text_config.n_positions = None + text_config.seq_length = None + + config = MagicMock() + config.max_position_embeddings = None + config.n_positions = None + config.seq_length = None + config.text_config = text_config + + tokenizer = MagicMock(spec=[]) + + assert resolve_max_context_length(config, tokenizer) == 77 + + +class TestFindFileInDir: def test_finds_csv_file(self, tmp_path): csv_file = tmp_path / "dataset.csv" csv_file.write_text("text,label\nhello,1\n") - result = find_dataset_in_channel(str(tmp_path)) - assert result == str(csv_file) + assert find_file_in_dir(str(tmp_path), MANIFEST_EXTENSIONS) == str(csv_file) @pytest.mark.parametrize("filename", ["dataset.jsonl", "dataset.json"]) def test_finds_json_formats(self, tmp_path, filename): @@ -78,26 +136,40 @@ def test_finds_json_formats(self, tmp_path, filename): dataset = tmp_path / filename dataset.write_text('{"text": "hello", "label": "a"}\n') - result = find_dataset_in_channel(str(tmp_path)) - assert result == str(dataset) + assert find_file_in_dir(str(tmp_path), MANIFEST_EXTENSIONS) == str(dataset) def test_raises_when_no_supported_dataset(self, tmp_path): - txt_file = tmp_path / "readme.txt" - txt_file.write_text("not a dataset") + (tmp_path / "readme.txt").write_text("not a dataset") with pytest.raises(FileNotFoundError, match="No dataset file found"): - find_dataset_in_channel(str(tmp_path)) + find_file_in_dir(str(tmp_path), MANIFEST_EXTENSIONS) def test_case_insensitive_extension(self, tmp_path): csv_file = tmp_path / "DATA.CSV" csv_file.write_text("text,label\nhello,1\n") - result = find_dataset_in_channel(str(tmp_path)) - assert result == str(csv_file) + assert find_file_in_dir(str(tmp_path), MANIFEST_EXTENSIONS) == str(csv_file) def test_raises_when_dir_missing(self): with pytest.raises(FileNotFoundError, match="does not exist"): - find_dataset_in_channel("/nonexistent/path") + find_file_in_dir("/nonexistent/path", MANIFEST_EXTENSIONS) + + def test_prefers_shallowest_match(self, tmp_path): + """A manifest at the archive root wins over one inside an image folder.""" + (tmp_path / "images").mkdir() + (tmp_path / "images" / "notes.csv").write_text("a,b\n") + root_manifest = tmp_path / "manifest.csv" + root_manifest.write_text("image,label\na.png,cat\n") + + assert find_file_in_dir(str(tmp_path), MANIFEST_EXTENSIONS) == str(root_manifest) + + def test_skips_macos_resource_forks(self, tmp_path): + """Zips made on macOS carry ._ shadow files that are not manifests.""" + (tmp_path / "._manifest.csv").write_text("junk") + real = tmp_path / "manifest.csv" + real.write_text("image,label\na.png,cat\n") + + assert find_file_in_dir(str(tmp_path), MANIFEST_EXTENSIONS) == str(real) class TestResolveDatasetReader: @@ -110,7 +182,13 @@ class TestResolveDatasetReader: """ def test_supports_the_formats_the_ui_offers(self): - assert set(SUPPORTED_DATASET_EXTENSIONS) == {".csv", ".jsonl", ".json"} + assert set(DATASET_READERS) == {".csv", ".jsonl", ".json"} + + def test_manifest_extensions_match_the_readers(self): + """The registry and the reader table must not drift apart.""" + for task_type in task_types.TASK_TYPES: + spec = task_types.get_task_spec(task_type) + assert set(spec.manifest_extensions) == set(DATASET_READERS) def test_csv_uses_read_csv(self): assert resolve_dataset_reader("/data/dataset.csv") == ("read_csv", {}) @@ -135,19 +213,97 @@ def test_raises_on_unsupported_extension(self): class TestValidateDatasetColumns: def test_accepts_required_columns(self): - validate_dataset_columns(["text", "label"], "/data/dataset.csv") + validate_dataset_columns(["text", "label"], "/data/dataset.csv", TEXT_SPEC) def test_raises_when_label_missing(self): with pytest.raises(ValueError, match="missing required column"): - validate_dataset_columns(["text"], "/data/dataset.csv") + validate_dataset_columns(["text"], "/data/dataset.csv", TEXT_SPEC) def test_raises_when_text_missing(self): with pytest.raises(ValueError, match="missing required column"): - validate_dataset_columns(["label"], "/data/dataset.csv") + validate_dataset_columns(["label"], "/data/dataset.csv", TEXT_SPEC) + + def test_image_task_requires_image_column(self): + with pytest.raises(ValueError, match="missing required column"): + validate_dataset_columns(["text", "label"], "/m.csv", IMAGE_SPEC) + + def test_image_text_task_requires_all_three(self): + validate_dataset_columns( + ["image", "text", "label"], "/m.csv", IMAGE_TEXT_SPEC + ) + with pytest.raises(ValueError, match="missing required column"): + validate_dataset_columns(["image", "label"], "/m.csv", IMAGE_TEXT_SPEC) + + +class TestExtractArchive: + """A dataset archive is untrusted user input.""" + + def test_extracts_flat_entries(self, tmp_path): + archive = _write_zip(tmp_path / "d.zip", {"manifest.csv": "image,label\n"}) + dest = tmp_path / "out" + + extract_archive(archive, str(dest)) + + assert (dest / "manifest.csv").read_text() == "image,label\n" + + def test_extracts_nested_entries(self, tmp_path): + archive = _write_zip( + tmp_path / "d.zip", + {"manifest.csv": "image,label\n", "images/a.png": PNG_BYTES}, + ) + dest = tmp_path / "out" + + extract_archive(archive, str(dest)) + + assert (dest / "images" / "a.png").read_bytes() == PNG_BYTES + + def test_rejects_parent_traversal(self, tmp_path): + """Zip-Slip: a crafted entry must not write outside the destination.""" + archive = _write_zip(tmp_path / "evil.zip", {"../escaped.txt": "pwned"}) + + with pytest.raises(ValueError, match="unsafe archive entry"): + extract_archive(archive, str(tmp_path / "out")) + + assert not (tmp_path / "escaped.txt").exists() + + def test_rejects_absolute_paths(self, tmp_path): + archive = _write_zip(tmp_path / "evil.zip", {"/etc/passwd": "pwned"}) + + with pytest.raises(ValueError, match="unsafe archive entry"): + extract_archive(archive, str(tmp_path / "out")) + + +class TestResolveImagePath: + + def test_resolves_relative_reference(self, tmp_path): + (tmp_path / "images").mkdir() + image = tmp_path / "images" / "a.png" + image.write_bytes(PNG_BYTES) + + assert resolve_image_path(str(tmp_path), "images/a.png") == str(image) + + def test_strips_surrounding_whitespace(self, tmp_path): + image = tmp_path / "a.png" + image.write_bytes(PNG_BYTES) + + assert resolve_image_path(str(tmp_path), " a.png ") == str(image) + + def test_raises_when_missing(self, tmp_path): + with pytest.raises(FileNotFoundError, match="missing from the archive"): + resolve_image_path(str(tmp_path), "nope.png") + + def test_rejects_escape_from_archive(self, tmp_path): + outside = tmp_path.parent / "outside.png" + outside.write_bytes(PNG_BYTES) + root = tmp_path / "root" + root.mkdir() + with pytest.raises(ValueError, match="escapes the dataset archive"): + resolve_image_path(str(root), "../outside.png") -class TestLoadDatasetFrame: - """End-to-end load, where pandas is available (the training container).""" + +class TestLoadManifestFrame: + """End-to-end load through pandas, exercising every accepted format.""" @pytest.mark.parametrize( "filename,content", @@ -166,45 +322,134 @@ class TestLoadDatasetFrame: ], ) def test_loads_each_supported_format(self, tmp_path, filename, content): - pytest.importorskip("pandas") path = tmp_path / filename path.write_text(content) - df = load_dataset_frame(str(path)) + frame = load_manifest_frame(str(path), TEXT_SPEC) + + assert frame["text"].tolist() == ["hello", "bye"] + assert frame["label"].tolist() == ["positive", "negative"] + + +class TestPrepareDataset: + + def test_text_task_reads_the_channel_directly(self, tmp_path): + (tmp_path / "d.csv").write_text("text,label\nhello,a\nbye,b\n") + + frame, image_root = prepare_dataset(str(tmp_path), TEXT_SPEC) + + assert image_root is None + assert frame["text"].tolist() == ["hello", "bye"] + + def test_image_task_unpacks_and_resolves_paths(self, tmp_path, monkeypatch): + from apis.app_api.fine_tuning.sagemaker_scripts import task_common - assert df["text"].tolist() == ["hello", "bye"] - assert df["label"].tolist() == ["positive", "negative"] + monkeypatch.setattr(task_common, "EXTRACT_DIR", str(tmp_path / "extracted")) + channel = tmp_path / "channel" + channel.mkdir() + _write_zip( + channel / "dataset.zip", + { + "manifest.csv": "image,label\nimages/a.png,cat\nimages/b.png,dog\n", + "images/a.png": PNG_BYTES, + "images/b.png": PNG_BYTES, + }, + ) + + frame, image_root = prepare_dataset(str(channel), IMAGE_SPEC) + + assert image_root == str(tmp_path / "extracted") + assert frame["label"].tolist() == ["cat", "dog"] + # Image column is rewritten to absolute, existence-checked paths. + for path in frame["image"]: + assert path.startswith(image_root) + + def test_image_task_reports_a_missing_image(self, tmp_path, monkeypatch): + from apis.app_api.fine_tuning.sagemaker_scripts import task_common + + monkeypatch.setattr(task_common, "EXTRACT_DIR", str(tmp_path / "extracted")) + + channel = tmp_path / "channel" + channel.mkdir() + _write_zip( + channel / "dataset.zip", + {"manifest.csv": "image,label\nimages/gone.png,cat\n"}, + ) -class TestCopyInferenceScript: + with pytest.raises(FileNotFoundError, match="missing from the archive"): + prepare_dataset(str(channel), IMAGE_SPEC) + + +class TestBuildLabelMapping: + + def test_maps_string_labels_to_contiguous_ids(self): + import pandas as pd + + frame = pd.DataFrame({"text": ["a", "b", "c"], "label": ["dog", "cat", "dog"]}) + + frame, label2id, id2label = build_label_mapping(frame, TEXT_SPEC) + + assert label2id == {"cat": 0, "dog": 1} + assert id2label == {0: "cat", 1: "dog"} + assert frame["label"].tolist() == [1, 0, 1] + + def test_rejects_a_single_class(self): + """One class cannot be classified, and the GPU error is unreadable.""" + import pandas as pd + + frame = pd.DataFrame({"text": ["a", "b"], "label": ["same", "same"]}) + + with pytest.raises(ValueError, match="at least 2 distinct values"): + build_label_mapping(frame, TEXT_SPEC) + + +class TestLabelNames: + + def test_reads_int_keyed_id2label(self): + import numpy as np + + config = MagicMock() + config.id2label = {0: "cat", 1: "dog"} + + assert label_names(config, np.zeros((2, 2))) == ["cat", "dog"] + + def test_reads_string_keyed_id2label(self): + """A config round-tripped through JSON comes back string-keyed.""" + import numpy as np + + config = MagicMock() + config.id2label = {"0": "cat", "1": "dog"} + + assert label_names(config, np.zeros((2, 2))) == ["cat", "dog"] + + def test_falls_back_to_positional_names(self): + import numpy as np + + config = MagicMock() + config.id2label = None + + assert label_names(config, np.zeros((2, 3))) == ["class_0", "class_1", "class_2"] + + +class TestCopyInferenceBundle: def test_copies_files_to_code_dir(self, tmp_path): - # Create fake script files in a "source" dir source_dir = tmp_path / "scripts" source_dir.mkdir() (source_dir / "inference.py").write_text("# inference handler") (source_dir / "requirements.txt").write_text("pandas\n") - # Create output dir model_dir = tmp_path / "model" model_dir.mkdir() - # Patch __file__ so copy_inference_script looks in our source_dir - with patch( - "apis.app_api.fine_tuning.sagemaker_scripts.train.os.path.dirname", - return_value=str(source_dir), - ): - with patch( - "apis.app_api.fine_tuning.sagemaker_scripts.train.os.path.abspath", - return_value=str(source_dir / "train.py"), - ): - copy_inference_script(str(model_dir)) + copied = copy_inference_bundle(str(model_dir), script_dir=str(source_dir)) code_dir = model_dir / "code" assert code_dir.exists() - assert (code_dir / "inference.py").exists() - assert (code_dir / "requirements.txt").exists() assert (code_dir / "inference.py").read_text() == "# inference handler" + assert (code_dir / "requirements.txt").exists() + assert "inference.py" in copied def test_creates_code_directory(self, tmp_path): source_dir = tmp_path / "scripts" @@ -214,18 +459,31 @@ def test_creates_code_directory(self, tmp_path): model_dir = tmp_path / "model" model_dir.mkdir() - with patch( - "apis.app_api.fine_tuning.sagemaker_scripts.train.os.path.dirname", - return_value=str(source_dir), - ): - with patch( - "apis.app_api.fine_tuning.sagemaker_scripts.train.os.path.abspath", - return_value=str(source_dir / "train.py"), - ): - copy_inference_script(str(model_dir)) + copy_inference_bundle(str(model_dir), script_dir=str(source_dir)) assert (model_dir / "code").is_dir() + def test_bundles_every_task_module_from_the_real_tree(self, tmp_path): + """Batch Transform needs the task modules, not just inference.py. + + The handler dispatches on the artifact's task type, so a bundle + missing a task module fails at load time on the inference container. + """ + model_dir = tmp_path / "model" + model_dir.mkdir() + + copied = copy_inference_bundle(str(model_dir)) + + for expected in ( + "inference.py", + "task_types.py", + "task_common.py", + "task_text_classification.py", + "task_image_classification.py", + "task_image_text_classification.py", + ): + assert expected in copied, f"{expected} missing from the inference bundle" + class TestDynamoDBProgressCallback: @@ -234,8 +492,7 @@ def test_on_train_begin_sets_zero(self): cb = DynamoDBProgressCallback("table", "us-west-2", "PK", "SK") cb._client = mock_client - state = MagicMock() - cb.on_train_begin(MagicMock(), state, MagicMock()) + cb.on_train_begin(MagicMock(), MagicMock(), MagicMock()) mock_client.update_item.assert_called_once() call_kwargs = mock_client.update_item.call_args[1] @@ -246,8 +503,7 @@ def test_on_train_end_sets_one(self): cb = DynamoDBProgressCallback("table", "us-west-2", "PK", "SK") cb._client = mock_client - state = MagicMock() - cb.on_train_end(MagicMock(), state, MagicMock()) + cb.on_train_end(MagicMock(), MagicMock(), MagicMock()) call_kwargs = mock_client.update_item.call_args[1] assert call_kwargs["ExpressionAttributeValues"][":p"]["N"] == "1.0" @@ -257,11 +513,9 @@ def test_noop_when_no_table_configured(self): cb = DynamoDBProgressCallback("", "us-west-2", "", "") assert cb._client is None - # Calling _update_progress should not raise cb._update_progress(0.5) def test_logs_warning_when_params_empty(self, caplog): - """Should log a warning when DynamoDB params are missing.""" import logging with caplog.at_level(logging.WARNING): @@ -270,7 +524,6 @@ def test_logs_warning_when_params_empty(self, caplog): assert any("disabled" in msg and "EMPTY" in msg for msg in caplog.messages) def test_logs_info_when_initialized(self, caplog): - """Should log an info message when DynamoDB client is created.""" import logging mock_client = MagicMock() @@ -289,12 +542,10 @@ def test_throttles_step_updates(self): state = MagicMock() state.max_steps = 100 - # Step 5 should NOT trigger (5 % 10 != 0) state.global_step = 5 cb.on_step_end(MagicMock(), state, MagicMock()) mock_client.update_item.assert_not_called() - # Step 10 SHOULD trigger (10 % 10 == 0) state.global_step = 10 cb.on_step_end(MagicMock(), state, MagicMock()) mock_client.update_item.assert_called_once() @@ -304,11 +555,11 @@ class TestSageMakerLoggingCallback: def test_logs_accuracy_on_evaluate(self, caplog): import logging + cb = SageMakerLoggingCallback() args = MagicMock() args.num_train_epochs = 5 - state = MagicMock() state.epoch = 2 @@ -319,16 +570,15 @@ def test_logs_accuracy_on_evaluate(self, caplog): def test_skips_final_epoch(self, caplog): import logging + cb = SageMakerLoggingCallback() args = MagicMock() args.num_train_epochs = 3 - state = MagicMock() - state.epoch = 3 # Final epoch + state.epoch = 3 with caplog.at_level(logging.INFO): cb.on_evaluate(args, state, MagicMock(), metrics={"eval_accuracy": 0.95}) - # Should NOT log accuracy for final epoch (avoids redundancy) assert not any("eval_accuracy" in msg for msg in caplog.messages) diff --git a/backend/tests/fine_tuning/test_user_routes.py b/backend/tests/fine_tuning/test_user_routes.py index c59c2a037..867b3aa9d 100644 --- a/backend/tests/fine_tuning/test_user_routes.py +++ b/backend/tests/fine_tuning/test_user_routes.py @@ -37,8 +37,8 @@ def test_returns_access_info_for_whitelisted_user(self, make_user): "email": "allowed@example.com", "granted_by": "admin@example.com", "granted_at": "2026-01-01T00:00:00Z", - "monthly_quota_hours": 10.0, - "current_month_usage_hours": 3.5, + "monthly_quota_usd": 10.0, + "current_month_usage_usd": 3.5, "quota_period": "2026-03", } _override_repo(app, mock_repo) @@ -49,8 +49,8 @@ def test_returns_access_info_for_whitelisted_user(self, make_user): assert resp.status_code == 200 body = resp.json() assert body["has_access"] is True - assert body["monthly_quota_hours"] == 10.0 - assert body["current_month_usage_hours"] == 3.5 + assert body["monthly_quota_usd"] == 10.0 + assert body["current_month_usage_usd"] == 3.5 assert body["quota_period"] == "2026-03" def test_returns_no_access_for_non_whitelisted_user(self, make_user): @@ -68,7 +68,7 @@ def test_returns_no_access_for_non_whitelisted_user(self, make_user): assert resp.status_code == 200 body = resp.json() assert body["has_access"] is False - assert body["monthly_quota_hours"] is None + assert body["monthly_quota_usd"] is None def test_returns_401_when_unauthenticated(self): app = _create_app() diff --git a/backend/tests/lambdas/test_kb_ingestion_consumer.py b/backend/tests/lambdas/test_kb_ingestion_consumer.py index cf0d07fed..909b04527 100644 --- a/backend/tests/lambdas/test_kb_ingestion_consumer.py +++ b/backend/tests/lambdas/test_kb_ingestion_consumer.py @@ -13,6 +13,7 @@ legacy must ingest NOTHING here, managed must ingest here and NOT fall back. """ +import logging from datetime import datetime, timezone from unittest.mock import MagicMock, patch @@ -122,11 +123,17 @@ class _FakeBackend: a slow one; the last value repeats forever. """ - def __init__(self, statuses=None): + def __init__(self, statuses=None, other_documents=("DOC-someone-else",)): self.ingested = [] self.status_calls = 0 + self.search_filters = [] self._statuses = list(statuses or ["NOT_FOUND", "INDEXED"]) self._agent_client = _FakeAgentClient(self) + # Models a knowledge base that holds OTHER documents too. Without this a + # probe that ignores its filter still passes, because the only document + # present is the one being looked for — which is exactly why the + # query-by-document-id probe survived until a second document existed. + self._other_documents = list(other_documents) def next_status(self): if len(self._statuses) > 1: @@ -145,10 +152,56 @@ async def ingest(self, kb_ref, source): self.ingested.append(source.document_id) return None - async def search(self, kb_ref, query, top_k=5): - chunk = MagicMock() - chunk.metadata = {"document_id": DOCUMENT_ID} - return [chunk] + async def search(self, kb_ref, query, top_k=5, retrieval_filter=None): + """Honours an ``equals`` filter on ``document_id``; otherwise ranks badly. + + The unfiltered branch returns the *other* documents, which is what the real + service did: a document id is meaningless to an embedding model, so an + unfiltered search returns whatever the reranker prefers. Measured in dev + with two documents, querying one id returned five chunks that all belonged + to the other. + """ + self.search_filters.append(retrieval_filter) + + wanted = None + if retrieval_filter: + equals = retrieval_filter.get("equals") or {} + if equals.get("key") == "document_id": + wanted = equals.get("value") + + if wanted is not None: + doc_ids = [wanted] if wanted == DOCUMENT_ID else [] + else: + doc_ids = list(self._other_documents) + + chunks = [] + for doc_id in doc_ids: + chunk = MagicMock() + chunk.metadata = {"document_id": doc_id} + chunk.document_id = doc_id + chunks.append(chunk) + return chunks + + +@pytest.fixture(autouse=True) +def _fast_polls(monkeypatch): + """Never wait production durations in a unit test. + + The consumer's budgets are deliberately long — INDEXED_POLL_TIMEOUT_SECONDS is + 600 s because Lambda's async retry is capped at 2 attempts, so the wait for + indexing has to happen inside one invocation. Left unpatched, the handful of + tests that exercise a document which never finishes indexing would hold this + file for over twenty minutes. + + This is exactly why those constants are resolved at CALL time rather than bound + as default arguments: a default argument is evaluated once at import and cannot + be patched, which an earlier version of this module got wrong and which cost a + 33-second test that silently ignored its own override. + """ + monkeypatch.setattr(ic, "INDEXED_POLL_TIMEOUT_SECONDS", 0.05) + monkeypatch.setattr(ic, "INDEXED_POLL_INTERVAL_SECONDS", 0.001) + monkeypatch.setattr(ic, "RETRIEVABLE_POLL_TIMEOUT_SECONDS", 0.05) + monkeypatch.setattr(ic, "RETRIEVABLE_POLL_INTERVAL_SECONDS", 0.001) # --------------------------------------------------------------------------- @@ -478,7 +531,7 @@ def test_a_redelivery_completes_the_document_without_a_second_ingest(self, table first = _FakeBackend(statuses=["NOT_FOUND", "IN_PROGRESS"]) with patch( "apis.shared.kb_backend.managed_backend.ManagedKbBackend", return_value=first - ), patch.object(ic, "INDEXED_POLL_TIMEOUT_SECONDS", 0.01): + ): with pytest.raises(ic.IngestionRoutingError): ic.handle_object(BUCKET, KEY) assert first.ingested == [DOCUMENT_ID] @@ -554,9 +607,175 @@ def _agent(self): fake = _ProbeBroken(statuses=["NOT_FOUND"]) with patch( "apis.shared.kb_backend.managed_backend.ManagedKbBackend", return_value=fake - ), patch.object(ic, "INDEXED_POLL_TIMEOUT_SECONDS", 0.01): + ): with pytest.raises(ic.IngestionRoutingError): ic.handle_object(BUCKET, KEY) assert fake.ingested == [DOCUMENT_ID], "a probe failure must not block ingestion" assert _doc(table)["status"] != "failed" + + +# --------------------------------------------------------------------------- +# The retrievability probe must ask an exact question +# --------------------------------------------------------------------------- +class TestTheRetrievabilityProbeIsFiltered: + """Found in dev on 2026-09-01, with two documents in the knowledge base. + + The probe searched for the document *id as the query text* and checked whether + that document came back in the top 5. A document id means nothing to an + embedding model, so the search returned whatever the reranker preferred: + querying `DOC-40e985680a63` returned five chunks and every one belonged to a + different document. A perfectly retrievable document was reported as not + retrievable. + + It scales the wrong way — the more documents a knowledge base holds, the less + likely the target appears in an unfiltered top-5 — so every upload to a mature + knowledge base would burn its poll budget and dead-letter. It only ever worked + while the knowledge base held exactly one document, where anything returned was + necessarily the right thing. + """ + + def _seed_managed(self, table): + _seed_kb(table, retrievalEngine="managed", awsKbId="KB123", awsDataSourceId="DS456") + + def test_the_probe_filters_to_the_document_being_confirmed(self, table): + self._seed_managed(table) + fake = _FakeBackend(statuses=["INDEXED"]) + + with patch( + "apis.shared.kb_backend.managed_backend.ManagedKbBackend", return_value=fake + ): + ic.handle_object(BUCKET, KEY) + + assert fake.search_filters, "the probe never searched" + assert all(f is not None for f in fake.search_filters), ( + "the retrievability probe searched WITHOUT a filter; with other " + "documents present it can return five chunks that all belong to " + "something else and report a good document as not retrievable" + ) + assert fake.search_filters[0] == { + "equals": {"key": "document_id", "value": DOCUMENT_ID} + } + + def test_the_filter_uses_exact_match_not_a_prefix(self, table): + """`startsWith` would let DOC-1 confirm DOC-10 as retrievable.""" + self._seed_managed(table) + fake = _FakeBackend(statuses=["INDEXED"]) + + with patch( + "apis.shared.kb_backend.managed_backend.ManagedKbBackend", return_value=fake + ): + ic.handle_object(BUCKET, KEY) + + operators = {op for f in fake.search_filters for op in (f or {})} + assert operators == {"equals"}, f"unsafe filter operator(s): {operators}" + + def test_a_document_confirms_even_when_others_rank_higher(self, table): + """The regression itself: other documents present must not hide this one.""" + self._seed_managed(table) + fake = _FakeBackend( + statuses=["INDEXED"], + other_documents=("DOC-noise-1", "DOC-noise-2", "DOC-noise-3"), + ) + + with patch( + "apis.shared.kb_backend.managed_backend.ManagedKbBackend", return_value=fake + ): + ic.handle_object(BUCKET, KEY) + + assert _doc(table)["status"] == "complete" + + +# --------------------------------------------------------------------------- +# Statuses the SDK does not declare +# --------------------------------------------------------------------------- +class TestUndeclaredStatusesAreWaitedOut: + """`TEXT_INDEXED` is returned by the live service and is NOT in the packaged + model's DocumentStatus enum. Observed in dev on a document with image + extraction enabled: TEXT_INDEXED first, INDEXED later.""" + + def _seed_managed(self, table): + _seed_kb(table, retrievalEngine="managed", awsKbId="KB123", awsDataSourceId="DS456") + + def test_text_indexed_is_not_treated_as_done(self, table): + """Marking complete here would claim an image-only page is ready while the + vision model is still running — the exact report this module prevents.""" + self._seed_managed(table) + fake = _FakeBackend(statuses=["TEXT_INDEXED"]) + + with patch( + "apis.shared.kb_backend.managed_backend.ManagedKbBackend", return_value=fake + ): + with pytest.raises(ic.IngestionRoutingError): + ic.handle_object(BUCKET, KEY) + + assert _doc(table)["status"] != "complete" + + def test_text_indexed_does_not_cause_a_re_ingest(self, table): + self._seed_managed(table) + fake = _FakeBackend(statuses=["TEXT_INDEXED"]) + + with patch( + "apis.shared.kb_backend.managed_backend.ManagedKbBackend", return_value=fake + ): + with pytest.raises(ic.IngestionRoutingError): + ic.handle_object(BUCKET, KEY) + + assert fake.ingested == [] + + def test_text_indexed_becoming_indexed_completes_the_document(self, table): + """The observed real sequence. It must converge, not stall.""" + self._seed_managed(table) + fake = _FakeBackend(statuses=["TEXT_INDEXED", "TEXT_INDEXED", "INDEXED"]) + + with patch( + "apis.shared.kb_backend.managed_backend.ManagedKbBackend", return_value=fake + ): + ic.handle_object(BUCKET, KEY) + + assert _doc(table)["status"] == "complete" + assert fake.ingested == [], "already submitted; must not re-ingest" + + def test_a_status_nobody_has_seen_before_is_waited_out_not_failed(self, table): + """A future AWS status value must not dead-letter documents.""" + self._seed_managed(table) + fake = _FakeBackend(statuses=["SOME_FUTURE_STATUS"]) + + with patch( + "apis.shared.kb_backend.managed_backend.ManagedKbBackend", return_value=fake + ): + with pytest.raises(ic.IngestionRoutingError): + ic.handle_object(BUCKET, KEY) + + item = _doc(table) + assert item["status"] != "failed", ( + "an unrecognised status failed the document; unknown values must be " + "waited out, because the service already returns one the SDK omits" + ) + + def test_text_indexed_is_a_classified_status_not_an_unknown_one(self, table, caplog): + """Recognition is the only thing that distinguishes it, so test that. + + Dropping TEXT_INDEXED from DOC_STATUSES_IN_FLIGHT is behaviour-equivalent: + `_still_working` waits on unrecognised statuses too, so the document is + handled identically either way. A mutation removing it therefore survives + every behavioural assertion — which means the only honest thing left to + assert is that we have CLASSIFIED it, and are not merely falling through the + unknown-status branch and logging a warning on every poll for a state we + have already seen in production and understand. + """ + self._seed_managed(table) + fake = _FakeBackend(statuses=["TEXT_INDEXED"]) + + with caplog.at_level(logging.WARNING): + with patch( + "apis.shared.kb_backend.managed_backend.ManagedKbBackend", return_value=fake + ): + with pytest.raises(ic.IngestionRoutingError): + ic.handle_object(BUCKET, KEY) + + assert "TEXT_INDEXED" in ic.DOC_STATUSES_IN_FLIGHT + assert not any("unrecognised status" in r.message for r in caplog.records), ( + "TEXT_INDEXED was handled by the unknown-status fallback; it is a state " + "we have observed in production and it should be classified explicitly" + ) diff --git a/backend/tests/routes/test_api_converse_mantle.py b/backend/tests/routes/test_api_converse_mantle.py index ce10f3389..4366c582e 100644 --- a/backend/tests/routes/test_api_converse_mantle.py +++ b/backend/tests/routes/test_api_converse_mantle.py @@ -191,3 +191,138 @@ async def test_defaults_to_bedrock_on_lookup_error(self): with patch("apis.shared.models.managed_models.list_managed_models", AsyncMock(side_effect=RuntimeError("boom"))): assert await _resolve_model_routing("openai.gpt-5.4") == ("bedrock", None, None) + + +class TestBedrockRuntimeResponsesRouting: + """provider="bedrock-responses" rides the same handler, different builder. + + Everything downstream of model construction — SSE translation, usage and + cost accounting — is shared with the Mantle path; only the transport + differs. These pin that the two never cross. + """ + + def _patches(self, routing, build_runtime, build_mantle, record_cost): + return [ + patch("apis.app_api.chat.converse_routes._validate_api_key", AsyncMock(return_value=MOCK_KEY)), + patch("apis.app_api.chat.converse_routes.shared_quota.is_quota_enforcement_enabled", return_value=False), + patch("apis.app_api.chat.converse_routes.get_app_role_service", return_value=_role_service()), + patch("apis.app_api.chat.converse_routes._resolve_model_routing", AsyncMock(return_value=routing)), + patch("apis.app_api.chat.converse_routes.build_bedrock_responses_model", build_runtime), + patch("apis.app_api.chat.converse_routes.build_mantle_model", build_mantle), + patch("apis.app_api.chat.converse_routes._record_cost", record_cost), + ] + + def _post(self, patches, payload): + for p in patches: + p.start() + try: + return _client().post( + "/chat/api-converse", + headers={"X-API-Key": VALID_KEY}, + json=payload, + ) + finally: + for p in patches: + p.stop() + + def test_builds_via_the_runtime_transport_not_mantle(self): + build_runtime = MagicMock(return_value=_FakeMantleModel(MANTLE_EVENTS)) + build_mantle = MagicMock() + record_cost = AsyncMock() + resp = self._post( + self._patches( + ("bedrock-responses", "responses", "us-west-2"), + build_runtime, build_mantle, record_cost, + ), + {"model_id": "us.openai.gpt-5.6-sol", "max_tokens": 256, + "messages": [{"role": "user", "content": "hi"}]}, + ) + + assert resp.status_code == 200 + assert resp.json()["content"] == "hello mantle" + build_mantle.assert_not_called() + kwargs = build_runtime.call_args.kwargs + assert kwargs["model_id"] == "us.openai.gpt-5.6-sol" + assert kwargs["region"] == "us-west-2" + # Responses API renames the output cap; no api_mode on this transport. + assert kwargs["params"] == {"max_output_tokens": 256} + assert "api_mode" not in kwargs + + def test_records_cost_against_the_right_provider(self): + record_cost = AsyncMock() + resp = self._post( + self._patches( + ("bedrock-responses", "responses", None), + MagicMock(return_value=_FakeMantleModel(MANTLE_EVENTS)), + MagicMock(), record_cost, + ), + {"model_id": "us.openai.gpt-5.6-sol", + "messages": [{"role": "user", "content": "hi"}]}, + ) + + assert resp.status_code == 200 + record_cost.assert_awaited_once() + assert record_cost.await_args.kwargs["provider"] == "bedrock-responses" + + def test_a_stored_chat_api_mode_cannot_downgrade_the_surface(self): + """A legacy row saying 'chat' must not cost us prompt caching.""" + build_runtime = MagicMock(return_value=_FakeMantleModel(MANTLE_EVENTS)) + resp = self._post( + self._patches( + ("bedrock-responses", "chat", "us-west-2"), + build_runtime, MagicMock(), AsyncMock(), + ), + {"model_id": "us.openai.gpt-5.6-sol", "max_tokens": 256, + "messages": [{"role": "user", "content": "hi"}]}, + ) + + assert resp.status_code == 200 + # Responses-native name proves the surface stayed on Responses. + assert build_runtime.call_args.kwargs["params"] == {"max_output_tokens": 256} + + def test_streams_sse_like_the_mantle_path(self): + record_cost = AsyncMock() + resp = self._post( + self._patches( + ("bedrock-responses", "responses", "us-west-2"), + MagicMock(return_value=_FakeMantleModel(MANTLE_EVENTS)), + MagicMock(), record_cost, + ), + {"model_id": "us.openai.gpt-5.6-sol", "stream": True, + "messages": [{"role": "user", "content": "hi"}]}, + ) + + assert resp.status_code == 200 + assert "text/event-stream" in resp.headers["content-type"] + assert "event: message_start" in resp.text + assert "event: done" in resp.text + assert record_cost.await_args.kwargs["provider"] == "bedrock-responses" + + def test_bedrock_models_still_take_the_converse_path(self): + """Regression guard: the new branch must not capture plain Bedrock.""" + build_runtime = MagicMock() + build_mantle = MagicMock() + boto_client = MagicMock() + boto_client.converse.return_value = { + "output": {"message": {"content": [{"text": "hello bedrock"}]}}, + "usage": {"inputTokens": 10, "outputTokens": 3}, + "stopReason": "end_turn", + } + patches = self._patches( + ("bedrock", None, None), build_runtime, build_mantle, AsyncMock() + ) + patches.append( + patch("apis.app_api.chat.converse_routes._get_bedrock_client", + return_value=boto_client) + ) + resp = self._post( + patches, + {"model_id": "us.anthropic.claude-haiku-4-5", + "messages": [{"role": "user", "content": "hi"}]}, + ) + + assert resp.status_code == 200 + assert resp.json()["content"] == "hello bedrock" + boto_client.converse.assert_called_once() + build_runtime.assert_not_called() + build_mantle.assert_not_called() diff --git a/backend/tests/routes/test_sessions.py b/backend/tests/routes/test_sessions.py index 30b7bc591..b16a3891d 100644 --- a/backend/tests/routes/test_sessions.py +++ b/backend/tests/routes/test_sessions.py @@ -9,11 +9,13 @@ - DELETE /sessions/{session_id} → 204 - POST /sessions/bulk-delete → 200 with deletion results - GET /sessions/{session_id}/messages → 200 with message history +- POST /sessions/{session_id}/steer → 200 (mid-turn steering) +- DELETE /sessions/{session_id}/steer/{id} → 204 Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8 """ -from unittest.mock import AsyncMock, patch, MagicMock +from unittest.mock import AsyncMock, patch, MagicMock, call import pytest from fastapi import FastAPI @@ -602,6 +604,79 @@ def test_queues_share_cleanup_on_delete(self, app, make_user, authenticated_clie # Background task should have been called with the session id mock_share_service.delete_shares_for_session.assert_called_once_with("sess-001") + def test_queues_artifact_share_cleanup_on_delete( + self, app, make_user, authenticated_client + ): + """Artifacts outlive the chat that produced them, so deleting a + conversation must also revoke the share links pointing at its + artifacts — otherwise they keep resolving forever.""" + user = make_user() + client = authenticated_client(app, user) + + mock_service = AsyncMock() + mock_service.delete_session = AsyncMock(return_value=True) + mock_service.delete_agentcore_memory = AsyncMock() + mock_service.delete_session_files = AsyncMock() + + mock_share_service = AsyncMock() + mock_share_service.delete_shares_for_session = AsyncMock(return_value=0) + + mock_artifact_shares = MagicMock() + mock_artifact_shares.delete_for_session = MagicMock(return_value=1) + + with patch( + "apis.app_api.sessions.routes.SessionService", + return_value=mock_service, + ), patch( + "apis.app_api.sessions.routes.get_share_service", + return_value=mock_share_service, + ), patch( + "apis.app_api.sessions.routes.get_artifact_share_service", + return_value=mock_artifact_shares, + ): + resp = client.delete("/sessions/sess-001") + + assert resp.status_code == 204 + # Scoped to the caller: SessionIndex is not user-partitioned, so + # the owner id is what keeps the cascade off other users' shares. + mock_artifact_shares.delete_for_session.assert_called_once_with( + "sess-001", user.user_id + ) + + def test_artifact_share_cleanup_failure_does_not_break_delete( + self, app, make_user, authenticated_client + ): + """The cascade runs after the 204 is sent. A raising task would + surface as an unhandled background-task error, so the service + swallows failures — this pins that the route still succeeds.""" + user = make_user() + client = authenticated_client(app, user) + + mock_service = AsyncMock() + mock_service.delete_session = AsyncMock(return_value=True) + mock_service.delete_agentcore_memory = AsyncMock() + mock_service.delete_session_files = AsyncMock() + + mock_share_service = AsyncMock() + mock_share_service.delete_shares_for_session = AsyncMock(return_value=0) + + mock_artifact_shares = MagicMock() + mock_artifact_shares.delete_for_session = MagicMock(return_value=0) + + with patch( + "apis.app_api.sessions.routes.SessionService", + return_value=mock_service, + ), patch( + "apis.app_api.sessions.routes.get_share_service", + return_value=mock_share_service, + ), patch( + "apis.app_api.sessions.routes.get_artifact_share_service", + return_value=mock_artifact_shares, + ): + resp = client.delete("/sessions/sess-001") + + assert resp.status_code == 204 + # --------------------------------------------------------------------------- # Requirement 3.7: POST /sessions/bulk-delete returns 200 @@ -701,6 +776,95 @@ def test_bulk_delete_queues_share_cleanup(self, app, make_user, authenticated_cl assert resp.status_code == 200 assert mock_share_service.delete_shares_for_session.call_count == 2 + def test_bulk_delete_queues_artifact_share_cleanup( + self, app, make_user, authenticated_client + ): + """Bulk delete must cascade artifact shares too, per session. + + The single-delete path has its own test; this is the one that + catches the bulk path being wired wrong or not at all — the + failure mode being live share links surviving the conversations + that produced them. + """ + user = make_user() + client = authenticated_client(app, user) + + mock_service = AsyncMock() + mock_service.delete_session = AsyncMock(side_effect=[True, True]) + mock_service.delete_agentcore_memory = AsyncMock() + mock_service.delete_session_files = AsyncMock() + + mock_share_service = AsyncMock() + mock_share_service.delete_shares_for_session = AsyncMock(return_value=0) + + mock_artifact_shares = MagicMock() + mock_artifact_shares.delete_for_session = MagicMock(return_value=1) + + with patch( + "apis.app_api.sessions.routes.SessionService", + return_value=mock_service, + ), patch( + "apis.app_api.sessions.routes.get_share_service", + return_value=mock_share_service, + ), patch( + "apis.app_api.sessions.routes.get_artifact_share_service", + return_value=mock_artifact_shares, + ): + resp = client.post( + "/sessions/bulk-delete", + json={"sessionIds": ["sess-001", "sess-002"]}, + ) + + assert resp.status_code == 200 + # Once per session, each scoped to the caller — SessionIndex is + # not user-partitioned, so the owner id is what keeps the cascade + # off other users' shares. + assert mock_artifact_shares.delete_for_session.call_args_list == [ + call("sess-001", user.user_id), + call("sess-002", user.user_id), + ] + + def test_bulk_delete_skips_artifact_cleanup_for_failed_deletes( + self, app, make_user, authenticated_client + ): + """A session that wasn't deleted keeps its artifacts, so revoking + its share links would destroy live links to a conversation the + user still has.""" + user = make_user() + client = authenticated_client(app, user) + + mock_service = AsyncMock() + # Second session doesn't exist. + mock_service.delete_session = AsyncMock(side_effect=[True, False]) + mock_service.delete_agentcore_memory = AsyncMock() + mock_service.delete_session_files = AsyncMock() + + mock_share_service = AsyncMock() + mock_share_service.delete_shares_for_session = AsyncMock(return_value=0) + + mock_artifact_shares = MagicMock() + mock_artifact_shares.delete_for_session = MagicMock(return_value=0) + + with patch( + "apis.app_api.sessions.routes.SessionService", + return_value=mock_service, + ), patch( + "apis.app_api.sessions.routes.get_share_service", + return_value=mock_share_service, + ), patch( + "apis.app_api.sessions.routes.get_artifact_share_service", + return_value=mock_artifact_shares, + ): + resp = client.post( + "/sessions/bulk-delete", + json={"sessionIds": ["sess-001", "sess-missing"]}, + ) + + assert resp.status_code == 200 + assert mock_artifact_shares.delete_for_session.call_args_list == [ + call("sess-001", user.user_id) + ] + def test_rejects_empty_list(self, app, make_user, authenticated_client): """Req 3.7: Should return 422 for empty session_ids list.""" user = make_user() @@ -921,6 +1085,156 @@ def test_returns_401_for_unauthenticated(self, app, unauthenticated_client): assert resp.status_code == 401 +class TestSteerRunningTurn: + """POST /sessions/{session_id}/steer — mid-turn steering. + + See docs/specs/mid-turn-steering.md. On app-api, not inference-api, for + the same reason ``/interrupt`` is: the AgentCore Runtime data plane + proxies only ``/invocations`` and ``/ping``. Cookie-auth via + get_current_user_from_session per the app-api auth rule. + """ + + def test_queues_the_follow_up_against_the_live_turn(self, app, make_user, authenticated_client): + user = make_user() + client = authenticated_client(app, user) + + steer = AsyncMock(return_value=True) + with patch("apis.shared.sessions.session_lease.request_session_steer", steer): + resp = client.post( + "/sessions/sess-001/steer", + json={"text": "use the other file", "entryId": "e1"}, + ) + + assert resp.status_code == 200 + assert resp.json() == {"queued": True, "entryId": "e1"} + steer.assert_awaited_once_with( + "sess-001", + user.user_id, + text="use the other file", + entry_id="e1", + ) + + def test_reports_not_queued_when_no_turn_is_running(self, app, make_user, authenticated_client): + """The turn ended between the user typing and this landing. + + Not an error: the SPA leaves the entry in its queue and PR #916's + end-of-turn flush sends it as a normal turn. 200 either way so the + client never has to tell a lost race apart from a failure. + """ + user = make_user() + client = authenticated_client(app, user) + + with patch( + "apis.shared.sessions.session_lease.request_session_steer", + AsyncMock(return_value=False), + ): + resp = client.post( + "/sessions/sess-001/steer", + json={"text": "too late", "entryId": "e1"}, + ) + + assert resp.status_code == 200 + assert resp.json()["queued"] is False + + def test_returns_429_when_the_inbox_is_full(self, app, make_user, authenticated_client): + from apis.shared.sessions.session_lease import SteerQueueFullError + + user = make_user() + client = authenticated_client(app, user) + + with patch( + "apis.shared.sessions.session_lease.request_session_steer", + AsyncMock(side_effect=SteerQueueFullError("sess-001")), + ): + resp = client.post( + "/sessions/sess-001/steer", + json={"text": "one too many", "entryId": "e6"}, + ) + + assert resp.status_code == 429 + + def test_rejects_empty_text(self, app, make_user, authenticated_client): + user = make_user() + client = authenticated_client(app, user) + + steer = AsyncMock(return_value=True) + with patch("apis.shared.sessions.session_lease.request_session_steer", steer): + resp = client.post( + "/sessions/sess-001/steer", + json={"text": "", "entryId": "e1"}, + ) + + assert resp.status_code == 422 + steer.assert_not_awaited() + + def test_404_when_the_flag_is_off(self, app, make_user, authenticated_client, monkeypatch): + monkeypatch.setenv("MID_TURN_STEERING_ENABLED", "false") + user = make_user() + client = authenticated_client(app, user) + + steer = AsyncMock(return_value=True) + with patch("apis.shared.sessions.session_lease.request_session_steer", steer): + resp = client.post( + "/sessions/sess-001/steer", + json={"text": "hi", "entryId": "e1"}, + ) + + assert resp.status_code == 404 + steer.assert_not_awaited() + + def test_returns_401_for_unauthenticated(self, app, unauthenticated_client): + client = unauthenticated_client(app) + resp = client.post( + "/sessions/sess-001/steer", + json={"text": "hi", "entryId": "e1"}, + ) + assert resp.status_code == 401 + + +class TestWithdrawSteer: + """DELETE /sessions/{session_id}/steer/{entry_id} — the user un-queued it.""" + + def test_withdraws_the_entry(self, app, make_user, authenticated_client): + user = make_user() + client = authenticated_client(app, user) + + remove = AsyncMock(return_value=True) + with patch("apis.shared.sessions.session_lease.remove_steer_entry", remove): + resp = client.delete("/sessions/sess-001/steer/e1") + + assert resp.status_code == 204 + remove.assert_awaited_once_with("sess-001", user.user_id, "e1") + + def test_unknown_entry_still_returns_204(self, app, make_user, authenticated_client): + """The user's intent — don't send that — is satisfied either way.""" + user = make_user() + client = authenticated_client(app, user) + + with patch( + "apis.shared.sessions.session_lease.remove_steer_entry", + AsyncMock(return_value=False), + ): + resp = client.delete("/sessions/sess-001/steer/nope") + + assert resp.status_code == 204 + + def test_a_failed_withdrawal_is_not_a_500(self, app, make_user, authenticated_client): + user = make_user() + client = authenticated_client(app, user) + + with patch( + "apis.shared.sessions.session_lease.remove_steer_entry", + AsyncMock(side_effect=RuntimeError("dynamo down")), + ): + resp = client.delete("/sessions/sess-001/steer/e1") + + assert resp.status_code == 204 + + def test_returns_401_for_unauthenticated(self, app, unauthenticated_client): + client = unauthenticated_client(app) + assert client.delete("/sessions/sess-001/steer/e1").status_code == 401 + + class TestMarkSessionRead: """POST /sessions/{session_id}/read clears the durable unread flag. diff --git a/backend/tests/shared/test_announcements.py b/backend/tests/shared/test_announcements.py new file mode 100644 index 000000000..758693f2b --- /dev/null +++ b/backend/tests/shared/test_announcements.py @@ -0,0 +1,546 @@ +"""Tests for the announcements shared module (models + repository + service). + +The first class is the one that matters. Everything else here is ordinary CRUD +coverage; ``TestMonotonicAck`` is the §D2 regression, and the reason the write +is a conditional ``update_item`` rather than a ``put_item``. +""" + +import boto3 +import pytest +from botocore.exceptions import ClientError +from pydantic import ValidationError + +from apis.shared.announcements.models import ( + ACTION_RANKS, + Announcement, + AnnouncementAck, + AnnouncementCreate, + AnnouncementUpdate, +) +from apis.shared.announcements.repository import AnnouncementsRepository +from apis.shared.announcements.service import AnnouncementsService +from apis.shared.timestamps import from_iso + +AWS_REGION = "us-west-2" +TABLE_NAME = "test-announcements" + +FUTURE = "2099-01-01T00:00:00Z" +PAST = "2020-01-01T00:00:00Z" + + +@pytest.fixture() +def announcements_table(aws, monkeypatch): + ddb = boto3.client("dynamodb", region_name=AWS_REGION) + ddb.create_table( + TableName=TABLE_NAME, + KeySchema=[ + {"AttributeName": "PK", "KeyType": "HASH"}, + {"AttributeName": "SK", "KeyType": "RANGE"}, + ], + AttributeDefinitions=[ + {"AttributeName": "PK", "AttributeType": "S"}, + {"AttributeName": "SK", "AttributeType": "S"}, + ], + BillingMode="PAY_PER_REQUEST", + ) + monkeypatch.setenv("DYNAMODB_ANNOUNCEMENTS_TABLE_NAME", TABLE_NAME) + return boto3.resource("dynamodb", region_name=AWS_REGION).Table(TABLE_NAME) + + +@pytest.fixture() +def repo(announcements_table): + return AnnouncementsRepository(table_name=TABLE_NAME, region=AWS_REGION) + + +@pytest.fixture() +def service(repo): + return AnnouncementsService(repo) + + +def _create(**kw) -> AnnouncementCreate: + defaults = dict( + title="Skills are here", + body_markdown="# Skills\n\nTry them.", + publish_at=PAST, + ) + defaults.update(kw) + return AnnouncementCreate(**defaults) + + +# ====================================================================== +# §D2 — the monotonic ack guard. Written first; it is the regression. +# ====================================================================== + + +class TestMonotonicAck: + @pytest.mark.asyncio + async def test_seen_after_dismissed_leaves_dismissed_intact(self, service): + """`seen` is written on render and races the user's ✕ click. + + Without the conditional write, the late `seen` wins and the banner is + back on the next load. Same failure class as #741 / #751. + """ + announcement = await service.create_announcement(_create()) + + assert await service.record_ack( + user_id="u1", + announcement=announcement, + action="dismissed", + surface="banner", + ) + + # The straggler. + raised = await service.record_ack( + user_id="u1", + announcement=announcement, + action="seen", + surface="banner", + ) + + assert raised is False, "a weaker action must not raise the stored rank" + + ack = await service.get_ack( + "u1", announcement.announcement_id, announcement.revision + ) + assert ack.action == "dismissed" + assert ack.action_rank == ACTION_RANKS["dismissed"] + + @pytest.mark.asyncio + async def test_a_stronger_action_does_raise_the_rank(self, service): + announcement = await service.create_announcement(_create()) + + await service.record_ack( + user_id="u1", announcement=announcement, action="seen", surface="panel" + ) + raised = await service.record_ack( + user_id="u1", + announcement=announcement, + action="acknowledged", + surface="modal", + ) + + assert raised is True + ack = await service.get_ack( + "u1", announcement.announcement_id, announcement.revision + ) + assert ack.action == "acknowledged" + assert ack.surface == "modal" + + @pytest.mark.asyncio + async def test_repeating_the_same_action_is_a_no_op(self, service): + """Idempotent: `dismissed` twice must not error and must not downgrade.""" + announcement = await service.create_announcement(_create()) + + assert await service.record_ack( + user_id="u1", + announcement=announcement, + action="dismissed", + surface="banner", + ) + assert not await service.record_ack( + user_id="u1", + announcement=announcement, + action="dismissed", + surface="banner", + ) + + ack = await service.get_ack( + "u1", announcement.announcement_id, announcement.revision + ) + assert ack.action == "dismissed" + + @pytest.mark.asyncio + async def test_acks_are_scoped_to_the_user(self, service): + announcement = await service.create_announcement(_create()) + await service.record_ack( + user_id="u1", announcement=announcement, action="dismissed", surface="banner" + ) + + assert ( + await service.get_ack( + "u2", announcement.announcement_id, announcement.revision + ) + is None + ) + + @pytest.mark.asyncio + async def test_unknown_action_is_rejected(self, service): + announcement = await service.create_announcement(_create()) + with pytest.raises(ValueError): + await service.record_ack( + user_id="u1", + announcement=announcement, + action="skimmed", + surface="panel", + ) + + +# ====================================================================== +# §D4 — revision keying +# ====================================================================== + + +class TestRevisionKeying: + @pytest.mark.asyncio + async def test_bumping_revision_leaves_the_new_slot_unacked(self, service): + announcement = await service.create_announcement(_create()) + await service.record_ack( + user_id="u1", announcement=announcement, action="dismissed", surface="modal" + ) + + revised = await service.revise(announcement.announcement_id) + assert revised.revision == 2 + + # The R2 slot is empty — the user's suppression has lapsed. + assert await service.get_ack("u1", revised.announcement_id, 2) is None + # …and the R1 history is still readable, which is what lets the panel + # mark the entry "Updated" rather than plain unread. + r1 = await service.get_ack("u1", revised.announcement_id, 1) + assert r1 is not None and r1.action == "dismissed" + + acks = await service.list_acks("u1") + assert {a.revision for a in acks} == {1} + + @pytest.mark.asyncio + async def test_an_edit_does_not_bump_the_revision(self, service): + """A typo fix must not re-fire a modal at the whole user base.""" + announcement = await service.create_announcement(_create()) + updated = await service.update_announcement( + announcement.announcement_id, AnnouncementUpdate(title="Skills are here!") + ) + assert updated.title == "Skills are here!" + assert updated.revision == 1 + + @pytest.mark.asyncio + async def test_ack_after_revise_records_the_new_revision(self, service): + announcement = await service.create_announcement(_create()) + await service.record_ack( + user_id="u1", announcement=announcement, action="seen", surface="panel" + ) + revised = await service.revise(announcement.announcement_id) + + await service.record_ack( + user_id="u1", announcement=revised, action="dismissed", surface="banner" + ) + + acks = {a.revision: a.action for a in await service.list_acks("u1")} + assert acks == {1: "seen", 2: "dismissed"} + + +# ====================================================================== +# Validation +# ====================================================================== + + +class TestValidation: + def test_banner_requires_expires_at(self): + with pytest.raises(ValidationError, match="expiresAt is required"): + _create(surfaces=["panel", "banner"]) + + def test_modal_requires_expires_at(self): + with pytest.raises(ValidationError, match="expiresAt is required"): + _create(surfaces=["modal"]) + + def test_banner_with_expires_at_is_accepted(self): + data = _create(surfaces=["panel", "banner"], expires_at=FUTURE) + assert "banner" in data.surfaces + + def test_panel_only_needs_no_expiry(self): + assert _create(surfaces=["panel"]).expires_at is None + + def test_cta_url_rejects_javascript_scheme(self): + with pytest.raises(ValidationError): + _create(cta_label="Learn more", cta_url="javascript:alert(1)") + + def test_cta_url_and_label_travel_together(self): + with pytest.raises(ValidationError, match="cta_label is required"): + _create(cta_url="https://example.test/x") + with pytest.raises(ValidationError, match="cta_url is required"): + _create(cta_label="Learn more") + + def test_title_is_capped_at_140(self): + with pytest.raises(ValidationError): + _create(title="x" * 141) + + def test_body_is_capped_at_16kb(self): + with pytest.raises(ValidationError): + _create(body_markdown="x" * (16 * 1024 + 1)) + + def test_body_cap_counts_bytes_not_characters(self): + """A 3-byte character must count as three.""" + with pytest.raises(ValidationError): + _create(body_markdown="✅" * 6000) + + def test_expiry_must_follow_publish(self): + with pytest.raises(ValidationError, match="after publish_at"): + _create(surfaces=["banner"], publish_at=FUTURE, expires_at=PAST) + + def test_unparseable_timestamp_is_rejected(self): + with pytest.raises(ValidationError, match="ISO-8601"): + _create(publish_at="last tuesday") + + @pytest.mark.asyncio + async def test_patch_that_makes_the_record_invalid_raises(self, service): + """Adding `banner` to a record with no `expiresAt` is individually + valid and jointly not — so the merged record is re-validated.""" + announcement = await service.create_announcement(_create()) + with pytest.raises(ValueError, match="expiresAt is required"): + await service.update_announcement( + announcement.announcement_id, + AnnouncementUpdate(surfaces=["panel", "banner"]), + ) + + +# ====================================================================== +# Surfaces, state, and storage +# ====================================================================== + + +class TestSurfaces: + @pytest.mark.asyncio + async def test_panel_is_forced_on(self, service): + """Dismissing a loud surface can never destroy the information (§D1).""" + created = await service.create_announcement( + _create(surfaces=["banner"], expires_at=FUTURE) + ) + assert created.surfaces == ["panel", "banner"] + + @pytest.mark.asyncio + async def test_surfaces_are_stored_in_canonical_order(self, service): + created = await service.create_announcement( + _create(surfaces=["modal", "banner", "panel"], expires_at=FUTURE) + ) + assert created.surfaces == ["panel", "banner", "modal"] + + @pytest.mark.asyncio + async def test_patched_surfaces_also_get_panel_forced_on(self, service): + announcement = await service.create_announcement(_create()) + updated = await service.update_announcement( + announcement.announcement_id, + AnnouncementUpdate(surfaces=["modal"], expires_at=FUTURE), + ) + assert updated.surfaces == ["panel", "modal"] + + +class TestLifecycle: + @pytest.mark.asyncio + async def test_create_defaults_to_draft(self, service): + created = await service.create_announcement(_create()) + assert created.state == "draft" + assert created.revision == 1 + assert created.target_roles == ["*"] + assert created.show_to_new_users is False + + @pytest.mark.asyncio + async def test_publish_then_archive(self, service): + created = await service.create_announcement(_create()) + published = await service.publish(created.announcement_id) + assert published.state == "published" + archived = await service.archive(created.announcement_id) + assert archived.state == "archived" + + @pytest.mark.asyncio + async def test_publishing_an_archived_announcement_is_refused(self, service): + created = await service.create_announcement(_create()) + await service.archive(created.announcement_id) + with pytest.raises(ValueError, match="cannot publish"): + await service.publish(created.announcement_id) + + @pytest.mark.asyncio + async def test_archive_keeps_acks(self, service): + """The record of who saw what outlives the notice.""" + created = await service.create_announcement(_create()) + await service.record_ack( + user_id="u1", announcement=created, action="acknowledged", surface="modal" + ) + await service.archive(created.announcement_id) + assert len(await service.list_acks("u1")) == 1 + + @pytest.mark.asyncio + async def test_missing_id_returns_none_everywhere(self, service): + assert await service.get_announcement("nope") is None + assert await service.update_announcement("nope", AnnouncementUpdate()) is None + assert await service.publish("nope") is None + assert await service.archive("nope") is None + assert await service.revise("nope") is None + assert await service.delete_announcement("nope") is False + + @pytest.mark.asyncio + async def test_delete_then_get_is_none(self, service): + created = await service.create_announcement(_create()) + assert await service.delete_announcement(created.announcement_id) is True + assert await service.get_announcement(created.announcement_id) is None + + @pytest.mark.asyncio + async def test_list_filters_by_state_and_sorts_newest_first(self, service): + old = await service.create_announcement( + _create(title="Old", publish_at="2021-01-01T00:00:00Z") + ) + new = await service.create_announcement( + _create(title="New", publish_at="2026-01-01T00:00:00Z") + ) + await service.publish(new.announcement_id) + + every = await service.list_announcements() + assert [a.title for a in every] == ["New", "Old"] + + drafts = await service.list_announcements(states=["draft"]) + assert [a.announcement_id for a in drafts] == [old.announcement_id] + + +class TestStorageShape: + @pytest.mark.asyncio + async def test_announcement_key_shape(self, service, announcements_table): + created = await service.create_announcement(_create()) + item = announcements_table.get_item( + Key={ + "PK": "ANNOUNCEMENTS", + "SK": f"ANNOUNCEMENT#{created.announcement_id}", + } + )["Item"] + assert item["title"] == "Skills are here" + assert "ttl" not in item, "announcement rows must never expire" + + @pytest.mark.asyncio + async def test_ack_key_shape(self, service, announcements_table): + created = await service.create_announcement(_create()) + await service.record_ack( + user_id="u1", announcement=created, action="seen", surface="panel" + ) + item = announcements_table.get_item( + Key={ + "PK": "USER#u1", + "SK": f"ACK#{created.announcement_id}#R1", + } + )["Item"] + assert item["action"] == "seen" + assert int(item["actionRank"]) == 1 + assert int(item["revision"]) == 1 + + def test_round_trip_preserves_fields(self): + a = Announcement( + announcement_id="a1", + title="T", + body_markdown="B", + created_at=PAST, + updated_at=PAST, + publish_at=PAST, + summary="S", + surfaces=["panel", "modal"], + severity="warning", + state="published", + expires_at=FUTURE, + target_roles=["faculty"], + show_to_new_users=True, + requires_ack=True, + cta_label="Read", + cta_url="https://example.test/policy", + revision=3, + created_by="admin@example.test", + ) + assert Announcement.from_dynamo_item(a.to_dynamo_item()) == a + + def test_ack_partition_and_sort_keys(self): + assert AnnouncementAck.partition_key("u1") == "USER#u1" + assert AnnouncementAck.sort_key("a1", 2) == "ACK#a1#R2" + + +class TestAckTtl: + def _announcement(self, **kw) -> Announcement: + defaults = dict( + announcement_id="a1", + title="T", + body_markdown="B", + created_at=PAST, + updated_at=PAST, + publish_at=PAST, + ) + defaults.update(kw) + return Announcement(**defaults) + + def test_expiring_announcement_ttl_is_expiry_plus_90_days(self): + a = self._announcement(expires_at="2030-01-01T00:00:00Z") + assert a.ack_ttl("seen") == int(from_iso("2030-04-01T00:00:00Z").timestamp()) + + def test_open_ended_announcement_ttl_is_publish_plus_two_years(self): + a = self._announcement(publish_at="2030-01-01T00:00:00Z") + assert a.ack_ttl("dismissed") == int( + from_iso("2032-01-01T00:00:00Z").timestamp() + ) + + def test_compliance_ack_is_never_expired(self): + """A `requiresAck` acknowledgement is a record, so it keeps no TTL.""" + a = self._announcement(requires_ack=True, expires_at="2030-01-01T00:00:00Z") + assert a.ack_ttl("acknowledged") is None + # A weaker action on the same announcement still expires. + assert a.ack_ttl("seen") is not None + + @pytest.mark.asyncio + async def test_upgrading_to_a_compliance_ack_clears_the_ttl( + self, service, announcements_table + ): + """`seen` sets a TTL; the later `acknowledged` must remove it, or the + compliance record silently evaporates on the earlier schedule.""" + created = await service.create_announcement( + _create(surfaces=["modal"], expires_at=FUTURE, requires_ack=True) + ) + await service.record_ack( + user_id="u1", announcement=created, action="seen", surface="modal" + ) + key = {"PK": "USER#u1", "SK": f"ACK#{created.announcement_id}#R1"} + assert "ttl" in announcements_table.get_item(Key=key)["Item"] + + await service.record_ack( + user_id="u1", announcement=created, action="acknowledged", surface="modal" + ) + assert "ttl" not in announcements_table.get_item(Key=key)["Item"] + + +class TestDisabledRepository: + """No table configured is a disabled repository, not a crash — the table + ships in platform.yml while this code ships in backend.yml.""" + + @pytest.mark.asyncio + async def test_reads_are_empty_and_writes_refuse(self, monkeypatch): + monkeypatch.delenv("DYNAMODB_ANNOUNCEMENTS_TABLE_NAME", raising=False) + repo = AnnouncementsRepository() + assert repo.enabled is False + assert await repo.list_announcements() == [] + assert await repo.get_announcement("a1") is None + assert await repo.list_acks("u1") == [] + assert ( + await repo.record_ack( + user_id="u1", + announcement_id="a1", + revision=1, + action="seen", + surface="panel", + ) + is False + ) + with pytest.raises(RuntimeError): + await repo.create_announcement(_create()) + + +class TestRepositoryErrorPropagation: + @pytest.mark.asyncio + async def test_a_non_conditional_client_error_is_not_swallowed(self, repo): + """Only ConditionalCheckFailedException means "already acked". Any + other ClientError is a real fault and must surface.""" + + class _Boom: + def update_item(self, **_): + raise ClientError( + {"Error": {"Code": "ProvisionedThroughputExceededException"}}, + "UpdateItem", + ) + + repo._table = _Boom() + with pytest.raises(ClientError): + await repo.record_ack( + user_id="u1", + announcement_id="a1", + revision=1, + action="seen", + surface="panel", + ) diff --git a/backend/tests/shared/test_announcements_routes.py b/backend/tests/shared/test_announcements_routes.py new file mode 100644 index 000000000..2cd052845 --- /dev/null +++ b/backend/tests/shared/test_announcements_routes.py @@ -0,0 +1,351 @@ +"""Route tests for the admin announcements endpoints. + +PR-1 ships no user-facing surface, so there is only one router to cover. The +two checks worth naming: ``ctaUrl`` is rejected at the **API**, not only in the +admin form (anyone can curl this), and the whole package disappears when the +kill switch is thrown. +""" + +import importlib +import os + +import boto3 +import pytest +from fastapi import APIRouter, FastAPI, HTTPException +from fastapi.testclient import TestClient + +import apis.app_api.admin.routes as admin_routes_module +from apis.shared.announcements import repository as repo_module +from apis.shared.announcements import service as service_module +from apis.shared.auth.models import User +from tests.conftest import override_admin_auth + +AWS_REGION = "us-east-1" +TABLE_NAME = "test-announcements-routes" + +FUTURE = "2099-01-01T00:00:00Z" + + +def _make_user(email: str = "admin@example.com", roles=None) -> User: + return User( + email=email, + user_id="admin-001", + name="Test Admin", + roles=roles if roles is not None else ["system_admin"], + ) + + +@pytest.fixture() +def announcements_table(aws, monkeypatch): + ddb = boto3.client("dynamodb", region_name=AWS_REGION) + ddb.create_table( + TableName=TABLE_NAME, + KeySchema=[ + {"AttributeName": "PK", "KeyType": "HASH"}, + {"AttributeName": "SK", "KeyType": "RANGE"}, + ], + AttributeDefinitions=[ + {"AttributeName": "PK", "AttributeType": "S"}, + {"AttributeName": "SK", "AttributeType": "S"}, + ], + BillingMode="PAY_PER_REQUEST", + ) + monkeypatch.setenv("DYNAMODB_ANNOUNCEMENTS_TABLE_NAME", TABLE_NAME) + monkeypatch.setenv("AWS_REGION", AWS_REGION) + # Module-level singletons; reset so the next get_*() builds against moto. + monkeypatch.setattr(repo_module, "_repository", None) + monkeypatch.setattr(service_module, "_service", None) + return boto3.resource("dynamodb", region_name=AWS_REGION).Table(TABLE_NAME) + + +def _build_app(user: User = None) -> FastAPI: + from apis.app_api.admin.announcements.routes import router as admin_router + + app = FastAPI() + parent = APIRouter(prefix="/admin") + parent.include_router(admin_router) + app.include_router(parent) + override_admin_auth(app, (lambda: user) if user else (lambda: _make_user())) + return app + + +def _client(user: User = None) -> TestClient: + return TestClient(_build_app(user)) + + +def _payload(**kw) -> dict: + body = { + "title": "Skills are here", + "body_markdown": "# Skills", + "publish_at": "2020-01-01T00:00:00Z", + } + body.update(kw) + return body + + +class TestAdminCrud: + def test_create_returns_201_as_draft(self, announcements_table): + resp = _client().post("/admin/announcements/", json=_payload()) + assert resp.status_code == 201 + body = resp.json() + assert body["state"] == "draft" + assert body["revision"] == 1 + assert body["surfaces"] == ["panel"] + assert body["created_by"] == "admin@example.com" + + def test_list_then_get_round_trips(self, announcements_table): + client = _client() + created = client.post("/admin/announcements/", json=_payload()).json() + + listed = client.get("/admin/announcements/") + assert listed.status_code == 200 + assert listed.json()["total"] == 1 + + got = client.get(f"/admin/announcements/{created['announcement_id']}") + assert got.status_code == 200 + assert got.json()["title"] == "Skills are here" + + def test_list_filters_by_state(self, announcements_table): + client = _client() + created = client.post("/admin/announcements/", json=_payload()).json() + client.post("/admin/announcements/", json=_payload(title="Second")) + client.post(f"/admin/announcements/{created['announcement_id']}/publish") + + published = client.get("/admin/announcements/?state=published").json() + assert [a["title"] for a in published["announcements"]] == ["Skills are here"] + + def test_get_missing_returns_404(self, announcements_table): + assert _client().get("/admin/announcements/nope").status_code == 404 + + def test_publish_archive_revise(self, announcements_table): + client = _client() + created = client.post("/admin/announcements/", json=_payload()).json() + aid = created["announcement_id"] + + assert client.post(f"/admin/announcements/{aid}/publish").json()["state"] == ( + "published" + ) + assert client.post(f"/admin/announcements/{aid}/revise").json()["revision"] == 2 + assert client.post(f"/admin/announcements/{aid}/archive").json()["state"] == ( + "archived" + ) + + def test_publishing_an_archived_announcement_returns_400(self, announcements_table): + client = _client() + aid = client.post("/admin/announcements/", json=_payload()).json()[ + "announcement_id" + ] + client.post(f"/admin/announcements/{aid}/archive") + assert client.post(f"/admin/announcements/{aid}/publish").status_code == 400 + + def test_patch_leaves_revision_alone(self, announcements_table): + client = _client() + aid = client.post("/admin/announcements/", json=_payload()).json()[ + "announcement_id" + ] + patched = client.patch( + f"/admin/announcements/{aid}", json={"title": "Skills are here!"} + ).json() + assert patched["title"] == "Skills are here!" + assert patched["revision"] == 1 + + def test_patch_to_an_invalid_merged_record_returns_400(self, announcements_table): + client = _client() + aid = client.post("/admin/announcements/", json=_payload()).json()[ + "announcement_id" + ] + resp = client.patch( + f"/admin/announcements/{aid}", json={"surfaces": ["panel", "banner"]} + ) + assert resp.status_code == 400 + assert "expiresAt" in resp.json()["detail"] + + def test_patch_cannot_change_state(self, announcements_table): + """The publish guard must not be reachable around. + + If PATCH accepted `state`, an archived announcement could be put back + in front of every user by a request that looks like a body edit — and + the /publish state machine would be decorative. + """ + client = _client() + aid = client.post("/admin/announcements/", json=_payload()).json()[ + "announcement_id" + ] + client.post(f"/admin/announcements/{aid}/archive") + + resp = client.patch(f"/admin/announcements/{aid}", json={"state": "published"}) + + # Unknown field: pydantic ignores it rather than 422-ing, so assert on + # the outcome that matters — the state did not move. + assert resp.status_code == 200 + assert resp.json()["state"] == "archived" + + def test_create_cannot_start_published(self, announcements_table): + """Going live is its own call, never a side effect of authoring.""" + resp = _client().post("/admin/announcements/", json=_payload(state="published")) + assert resp.status_code == 422 + + def test_create_may_start_scheduled(self, announcements_table): + resp = _client().post("/admin/announcements/", json=_payload(state="scheduled")) + assert resp.status_code == 201 + assert resp.json()["state"] == "scheduled" + + def test_unknown_state_filter_is_rejected(self, announcements_table): + assert _client().get("/admin/announcements/?state=nonsense").status_code == 422 + + def test_delete_returns_204_then_404(self, announcements_table): + client = _client() + aid = client.post("/admin/announcements/", json=_payload()).json()[ + "announcement_id" + ] + assert client.delete(f"/admin/announcements/{aid}").status_code == 204 + assert client.delete(f"/admin/announcements/{aid}").status_code == 404 + + +class TestApiLevelValidation: + def test_cta_url_rejects_javascript_scheme(self, announcements_table): + """Rejected at the API, not only in the form. + + Angular's DomSanitizer strips `javascript:` from `[href]`, but the SPA + form is not the only client this endpoint has — and the announcement + scope is delegable, so the author may not be a platform admin (§D10). + """ + resp = _client().post( + "/admin/announcements/", + json=_payload(cta_label="Learn more", cta_url="javascript:alert(1)"), + ) + assert resp.status_code == 422 + + def test_patch_cta_url_rejects_javascript_scheme(self, announcements_table): + client = _client() + aid = client.post("/admin/announcements/", json=_payload()).json()[ + "announcement_id" + ] + resp = client.patch( + f"/admin/announcements/{aid}", + json={"cta_label": "Learn more", "cta_url": "javascript:alert(1)"}, + ) + assert resp.status_code == 422 + + def test_banner_without_expiry_returns_422(self, announcements_table): + resp = _client().post( + "/admin/announcements/", json=_payload(surfaces=["panel", "banner"]) + ) + assert resp.status_code == 422 + + def test_oversized_body_returns_422(self, announcements_table): + resp = _client().post( + "/admin/announcements/", + json=_payload(body_markdown="x" * (16 * 1024 + 1)), + ) + assert resp.status_code == 422 + + def test_target_roles_are_stored_verbatim_and_grant_nothing( + self, announcements_table + ): + """§D9 — a display filter, never an RBAC grant. + + The role list lands on the announcement item and nowhere else; the + role records are not touched. Pinned here so a future "fix" that writes + it through to `AppRole.granted*` has to delete a test that says why. + """ + created = _client().post( + "/admin/announcements/", + json=_payload(target_roles=["faculty", "staff"]), + ).json() + + assert created["target_roles"] == ["faculty", "staff"] + item = announcements_table.get_item( + Key={ + "PK": "ANNOUNCEMENTS", + "SK": f"ANNOUNCEMENT#{created['announcement_id']}", + } + )["Item"] + assert item["targetRoles"] == ["faculty", "staff"] + + +class TestAuthorization: + def test_non_admin_gets_403(self, announcements_table): + app = _build_app() + + def _forbid(): + raise HTTPException(status_code=403, detail="Forbidden") + + override_admin_auth(app, _forbid) + assert TestClient(app).get("/admin/announcements/").status_code == 403 + + +# --------------------------------------------------------------------------- +# ANNOUNCEMENTS_ENABLED kill switch +# --------------------------------------------------------------------------- + + +@pytest.fixture +def admin_router_paths(): + """Reload the admin router under a chosen ANNOUNCEMENTS_ENABLED value. + + Restores the module (flag unset → enabled) on teardown so a reload here + cannot leak an unmounted router into later tests. + """ + + def _load(*, enabled: bool) -> set[str]: + # Default-ON, so "disabled" must be set EXPLICITLY. + os.environ["ANNOUNCEMENTS_ENABLED"] = "true" if enabled else "false" + importlib.reload(admin_routes_module) + return { + getattr(route, "path", "") for route in admin_routes_module.router.routes + } + + yield _load + + os.environ.pop("ANNOUNCEMENTS_ENABLED", None) + importlib.reload(admin_routes_module) + + +class TestFeatureFlag: + def test_defaults_on_when_unset(self, monkeypatch): + from apis.shared.feature_flags import announcements_enabled + + monkeypatch.delenv("ANNOUNCEMENTS_ENABLED", raising=False) + assert announcements_enabled() is True + + @pytest.mark.parametrize( + "value, expected", + [ + ("false", False), + ("False", False), + (" false ", False), + ("true", True), + ("0", True), + # An unset GitHub Actions variable forwards as the empty string; it + # must resolve ENABLED, or the kill switch dark-ships a live feature. + ("", True), + (" ", True), + ], + ) + def test_only_literal_false_disables(self, monkeypatch, value, expected): + from apis.shared.feature_flags import announcements_enabled + + monkeypatch.setenv("ANNOUNCEMENTS_ENABLED", value) + assert announcements_enabled() is expected + + def test_admin_router_unmounted_when_disabled(self, admin_router_paths): + paths = admin_router_paths(enabled=False) + assert not any("/announcements" in p for p in paths) + + def test_admin_router_mounted_when_enabled(self, admin_router_paths): + paths = admin_router_paths(enabled=True) + assert any("/announcements" in p for p in paths) + + def test_disabled_router_404s_the_surface(self, announcements_table): + """The end a caller actually sees: the path is gone, not 403.""" + os.environ["ANNOUNCEMENTS_ENABLED"] = "false" + try: + importlib.reload(admin_routes_module) + app = FastAPI() + app.include_router(admin_routes_module.router) + override_admin_auth(app, lambda: _make_user()) + assert TestClient(app).get("/admin/announcements/").status_code == 404 + finally: + os.environ.pop("ANNOUNCEMENTS_ENABLED", None) + importlib.reload(admin_routes_module) diff --git a/backend/tests/shared/test_announcements_stats.py b/backend/tests/shared/test_announcements_stats.py new file mode 100644 index 000000000..3c6d932da --- /dev/null +++ b/backend/tests/shared/test_announcements_stats.py @@ -0,0 +1,311 @@ +"""Tests for the announcement stats funnel (PR-6, spec §9). + +The counters are the whole subject. They exist because ``/stats`` needs a +count of acks across users and the key shape does not support one — so the +announcement item carries per-(revision, action) tallies that the ack write +bumps. Two properties matter and both are easy to break: + +1. They count **users, not clicks**. A user who goes ``seen`` → ``dismissed`` + adds one to each, not two to ``seen``. +2. They are a **funnel, not a partition**. ``acknowledged`` implies + ``dismissed`` implies ``seen``, so the three are always non-increasing. +""" + +import boto3 +import pytest + +from apis.shared.announcements.models import ( + AnnouncementCreate, + ack_count_attr, +) +from apis.shared.announcements.repository import AnnouncementsRepository +from apis.shared.announcements.service import AnnouncementsService + +AWS_REGION = "us-west-2" +TABLE_NAME = "test-announcements-stats" + +PAST = "2020-01-01T00:00:00Z" +# `expiresAt` is required whenever a loud surface is selected. +FUTURE = "2099-01-01T00:00:00Z" + + +@pytest.fixture() +def announcements_table(aws, monkeypatch): + ddb = boto3.client("dynamodb", region_name=AWS_REGION) + ddb.create_table( + TableName=TABLE_NAME, + KeySchema=[ + {"AttributeName": "PK", "KeyType": "HASH"}, + {"AttributeName": "SK", "KeyType": "RANGE"}, + ], + AttributeDefinitions=[ + {"AttributeName": "PK", "AttributeType": "S"}, + {"AttributeName": "SK", "AttributeType": "S"}, + ], + BillingMode="PAY_PER_REQUEST", + ) + monkeypatch.setenv("DYNAMODB_ANNOUNCEMENTS_TABLE_NAME", TABLE_NAME) + return boto3.resource("dynamodb", region_name=AWS_REGION).Table(TABLE_NAME) + + +@pytest.fixture() +def repo(announcements_table): + return AnnouncementsRepository(table_name=TABLE_NAME, region=AWS_REGION) + + +@pytest.fixture() +def service(repo): + return AnnouncementsService(repo) + + +def _create(**kw) -> AnnouncementCreate: + defaults = dict( + title="Acceptable use policy update", + body_markdown="# Policy", + publish_at=PAST, + expires_at=FUTURE, + surfaces=["panel", "modal"], + ) + defaults.update(kw) + return AnnouncementCreate(**defaults) + + +async def _ack(service, announcement, user_id, action, surface="modal"): + return await service.record_ack( + user_id=user_id, + announcement=announcement, + action=action, + surface=surface, + ) + + +class TestAckCounters: + @pytest.mark.asyncio + async def test_first_ack_counts_one_user(self, service, repo): + announcement = await service.create_announcement(_create()) + await _ack(service, announcement, "u1", "seen") + + counts = await repo.get_ack_counts(announcement.announcement_id, 1) + assert counts == {"seen": 1, "dismissed": 0, "acknowledged": 0} + + @pytest.mark.asyncio + async def test_rising_through_ranks_counts_the_user_once_per_rank( + self, service, repo + ): + """The `UPDATED_OLD` read is what makes this true. + + Without the previous rank, `seen` then `dismissed` would add two to + the seen total — counting clicks instead of people. + """ + announcement = await service.create_announcement(_create()) + await _ack(service, announcement, "u1", "seen") + await _ack(service, announcement, "u1", "dismissed") + + counts = await repo.get_ack_counts(announcement.announcement_id, 1) + assert counts == {"seen": 1, "dismissed": 1, "acknowledged": 0} + + @pytest.mark.asyncio + async def test_jumping_straight_to_acknowledged_fills_the_funnel( + self, service, repo + ): + """A `requiresAck` modal writes `seen` then `acknowledged`, but a user + who never saw the intermediate state must still count at every rung — + otherwise `seen` would understate reach.""" + announcement = await service.create_announcement( + _create(requires_ack=True) + ) + await _ack(service, announcement, "u1", "acknowledged") + + counts = await repo.get_ack_counts(announcement.announcement_id, 1) + assert counts == {"seen": 1, "dismissed": 1, "acknowledged": 1} + + @pytest.mark.asyncio + async def test_a_weaker_late_ack_does_not_double_count(self, service, repo): + """§D2's straggler: `seen` arriving after `dismissed` is rejected by + the conditional write, so it must not touch the counters either.""" + announcement = await service.create_announcement(_create()) + await _ack(service, announcement, "u1", "dismissed") + raised = await _ack(service, announcement, "u1", "seen") + + assert raised is False + counts = await repo.get_ack_counts(announcement.announcement_id, 1) + assert counts == {"seen": 1, "dismissed": 1, "acknowledged": 0} + + @pytest.mark.asyncio + async def test_repeating_the_same_ack_is_idempotent(self, service, repo): + announcement = await service.create_announcement(_create()) + for _ in range(4): + await _ack(service, announcement, "u1", "seen") + + counts = await repo.get_ack_counts(announcement.announcement_id, 1) + assert counts["seen"] == 1 + + @pytest.mark.asyncio + async def test_counts_are_per_user(self, service, repo): + announcement = await service.create_announcement(_create()) + for user in ("u1", "u2", "u3"): + await _ack(service, announcement, user, "seen") + await _ack(service, announcement, "u2", "dismissed") + + counts = await repo.get_ack_counts(announcement.announcement_id, 1) + assert counts == {"seen": 3, "dismissed": 1, "acknowledged": 0} + + @pytest.mark.asyncio + async def test_funnel_is_never_increasing(self, service, repo): + announcement = await service.create_announcement(_create()) + await _ack(service, announcement, "u1", "acknowledged") + await _ack(service, announcement, "u2", "dismissed") + await _ack(service, announcement, "u3", "seen") + + c = await repo.get_ack_counts(announcement.announcement_id, 1) + assert c["seen"] >= c["dismissed"] >= c["acknowledged"] + assert c == {"seen": 3, "dismissed": 2, "acknowledged": 1} + + +class TestRevisionScoping: + @pytest.mark.asyncio + async def test_revise_starts_a_fresh_count(self, service, repo): + """"Show again" is a deliberate re-broadcast (§D4). + + Rolling the new revision's acks into the old totals would inflate them + and make the numbers lie about the version people actually saw. + """ + announcement = await service.create_announcement(_create()) + await _ack(service, announcement, "u1", "dismissed") + + revised = await service.revise(announcement.announcement_id) + assert revised.revision == 2 + + assert await repo.get_ack_counts(announcement.announcement_id, 2) == { + "seen": 0, + "dismissed": 0, + "acknowledged": 0, + } + # The old revision's history is untouched and still readable. + assert await repo.get_ack_counts(announcement.announcement_id, 1) == { + "seen": 1, + "dismissed": 1, + "acknowledged": 0, + } + + @pytest.mark.asyncio + async def test_counters_live_on_the_announcement_item( + self, service, announcements_table + ): + """No GSI and no scan — that is the point of the design.""" + announcement = await service.create_announcement(_create()) + await _ack(service, announcement, "u1", "seen") + + item = announcements_table.get_item( + Key={ + "PK": "ANNOUNCEMENTS", + "SK": f"ANNOUNCEMENT#{announcement.announcement_id}", + } + )["Item"] + assert item[ack_count_attr(1, "seen")] == 1 + + +class TestCountersSurviveAdminWrites: + """Every admin mutation is a full `put_item` of the Announcement model. + + So any attribute the model does not carry is destroyed by it. These are + the regression: without `Announcement.ack_counts`, publishing an + announcement — the single most common admin action — silently zeroed + every stat the feature exists to report. + """ + + @pytest.mark.asyncio + async def test_publishing_preserves_counts(self, service, repo): + announcement = await service.create_announcement(_create()) + await _ack(service, announcement, "u1", "seen") + + await service.publish(announcement.announcement_id) + + counts = await repo.get_ack_counts(announcement.announcement_id, 1) + assert counts["seen"] == 1 + + @pytest.mark.asyncio + async def test_archiving_preserves_counts(self, service, repo): + announcement = await service.create_announcement(_create()) + await _ack(service, announcement, "u1", "acknowledged") + + await service.publish(announcement.announcement_id) + await service.archive(announcement.announcement_id) + + counts = await repo.get_ack_counts(announcement.announcement_id, 1) + assert counts == {"seen": 1, "dismissed": 1, "acknowledged": 1} + + @pytest.mark.asyncio + async def test_editing_the_body_preserves_counts(self, service, repo): + from apis.shared.announcements.models import AnnouncementUpdate + + announcement = await service.create_announcement(_create()) + await _ack(service, announcement, "u1", "dismissed") + + await service.update_announcement( + announcement.announcement_id, + AnnouncementUpdate(title="Acceptable use policy update (typo fix)"), + ) + + counts = await repo.get_ack_counts(announcement.announcement_id, 1) + assert counts == {"seen": 1, "dismissed": 1, "acknowledged": 0} + + @pytest.mark.asyncio + async def test_acks_keep_accruing_after_an_admin_write(self, service, repo): + """The counters must stay live, not merely survive once.""" + announcement = await service.create_announcement(_create()) + await _ack(service, announcement, "u1", "seen") + + published = await service.publish(announcement.announcement_id) + await _ack(service, published, "u2", "seen") + + counts = await repo.get_ack_counts(announcement.announcement_id, 1) + assert counts["seen"] == 2 + + +class TestGetStats: + @pytest.mark.asyncio + async def test_reports_the_current_revision(self, service): + announcement = await service.create_announcement(_create()) + await _ack(service, announcement, "u1", "acknowledged") + + stats = await service.get_stats(announcement.announcement_id) + assert stats.announcement_id == announcement.announcement_id + assert stats.revision == 1 + assert (stats.seen, stats.dismissed, stats.acknowledged) == (1, 1, 1) + + @pytest.mark.asyncio + async def test_unknown_announcement_is_none(self, service): + assert await service.get_stats("nope") is None + + @pytest.mark.asyncio + async def test_zero_filled_before_anyone_acks(self, service): + announcement = await service.create_announcement(_create()) + stats = await service.get_stats(announcement.announcement_id) + assert (stats.seen, stats.dismissed, stats.acknowledged) == (0, 0, 0) + + @pytest.mark.asyncio + async def test_targeted_is_none_for_a_role_scoped_audience(self, service): + """None means "not estimated", never zero. + + The users table's StatusLoginIndex does not project `roles`, so a + role-filtered count has nothing to evaluate against — and the UI must + say the audience is unknown rather than imply nobody is targeted. + """ + announcement = await service.create_announcement( + _create(target_roles=["faculty"]) + ) + stats = await service.get_stats(announcement.announcement_id) + assert stats.targeted is None + + @pytest.mark.asyncio + async def test_targeted_is_none_when_the_user_directory_is_unavailable( + self, service, monkeypatch + ): + """A directory blip must not be reported as an audience of zero.""" + monkeypatch.delenv("DYNAMODB_USERS_TABLE_NAME", raising=False) + announcement = await service.create_announcement( + _create(target_roles=["*"]) + ) + stats = await service.get_stats(announcement.announcement_id) + assert stats.targeted is None diff --git a/backend/tests/shared/test_announcements_user_routes.py b/backend/tests/shared/test_announcements_user_routes.py new file mode 100644 index 000000000..4dc0f1933 --- /dev/null +++ b/backend/tests/shared/test_announcements_user_routes.py @@ -0,0 +1,382 @@ +"""Route tests for the user-facing announcement surface. + +The filter rules themselves are covered exhaustively (and without moto) in +``test_announcements_visibility.py``. What is tested here is the wiring: that +the endpoint serves the computed feed, that the ack path is monotonic +end-to-end, that an id targeted at another role 404s rather than 403s, and +that the payload does not leak admin metadata. +""" + +import boto3 +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from apis.shared.announcements import repository as repo_module +from apis.shared.announcements import service as service_module +from apis.shared.auth import get_current_user_from_session +from apis.shared.auth.models import User + +AWS_REGION = "us-east-1" +TABLE_NAME = "test-announcements-user-routes" +USERS_TABLE = "test-announcements-users" + +PAST = "2020-01-01T00:00:00Z" +FUTURE = "2099-01-01T00:00:00Z" + + +def _make_user(user_id: str = "u1", roles=None) -> User: + return User( + email=f"{user_id}@example.com", + user_id=user_id, + name="Test User", + roles=roles if roles is not None else ["User"], + ) + + +@pytest.fixture() +def announcements_table(aws, monkeypatch): + ddb = boto3.client("dynamodb", region_name=AWS_REGION) + for name in (TABLE_NAME, USERS_TABLE): + ddb.create_table( + TableName=name, + KeySchema=[ + {"AttributeName": "PK", "KeyType": "HASH"}, + {"AttributeName": "SK", "KeyType": "RANGE"}, + ], + AttributeDefinitions=[ + {"AttributeName": "PK", "AttributeType": "S"}, + {"AttributeName": "SK", "AttributeType": "S"}, + ], + BillingMode="PAY_PER_REQUEST", + ) + monkeypatch.setenv("DYNAMODB_ANNOUNCEMENTS_TABLE_NAME", TABLE_NAME) + monkeypatch.setenv("AWS_REGION", AWS_REGION) + # No users table configured: `_user_created_at` returns None, which the + # filter reads as "existing user". The new-user rule has its own coverage. + monkeypatch.delenv("DYNAMODB_USERS_TABLE_NAME", raising=False) + monkeypatch.setattr(repo_module, "_repository", None) + monkeypatch.setattr(service_module, "_service", None) + return boto3.resource("dynamodb", region_name=AWS_REGION).Table(TABLE_NAME) + + +def _client(user: User = None) -> TestClient: + from apis.app_api.announcements.routes import router + + app = FastAPI() + app.include_router(router) + app.dependency_overrides[get_current_user_from_session] = lambda: user or _make_user() + return TestClient(app) + + +async def _seed(**kw): + """Create + publish one announcement straight through the service.""" + from apis.shared.announcements.models import AnnouncementCreate + + service = service_module.get_announcements_service() + defaults = dict(title="Skills are here", body_markdown="# Skills", publish_at=PAST) + defaults.update(kw) + created = await service.create_announcement(AnnouncementCreate(**defaults)) + return await service.publish(created.announcement_id) + + +class TestFeed: + @pytest.mark.asyncio + async def test_returns_published_announcements(self, announcements_table): + await _seed() + body = _client().get("/announcements/").json() + + assert len(body["panel"]) == 1 + assert body["panel"][0]["title"] == "Skills are here" + assert body["unread_count"] == 1 + assert body["banner"] is None and body["modal"] is None + + @pytest.mark.asyncio + async def test_drafts_are_invisible(self, announcements_table): + from apis.shared.announcements.models import AnnouncementCreate + + await service_module.get_announcements_service().create_announcement( + AnnouncementCreate(title="Draft", body_markdown="x", publish_at=PAST) + ) + assert _client().get("/announcements/").json()["panel"] == [] + + @pytest.mark.asyncio + async def test_a_banner_announcement_fills_the_banner_slot(self, announcements_table): + await _seed(surfaces=["panel", "banner"], expires_at=FUTURE) + body = _client().get("/announcements/").json() + + assert body["banner"] is not None + assert len(body["panel"]) == 1, "the banner item is also a panel item" + + @pytest.mark.asyncio + async def test_targeting_scopes_the_feed_per_user(self, announcements_table): + await _seed(title="Faculty only", target_roles=["faculty"]) + + assert _client(_make_user("u1", ["student"])).get("/announcements/").json()["panel"] == [] + assert ( + len(_client(_make_user("u2", ["faculty"])).get("/announcements/").json()["panel"]) == 1 + ) + + @pytest.mark.asyncio + async def test_payload_omits_admin_metadata(self, announcements_table): + """A user must not learn which roles a notice was aimed at, who wrote + it, or that it exists in a state they cannot see.""" + await _seed(target_roles=["faculty", "staff"]) + item = _client(_make_user("u1", ["faculty"])).get("/announcements/").json()["panel"][0] + + for leaked in ("target_roles", "state", "created_by", "show_to_new_users", "updated_at"): + assert leaked not in item, f"{leaked} leaked into the user payload" + + @pytest.mark.asyncio + async def test_acks_are_scoped_to_the_caller(self, announcements_table): + announcement = await _seed() + _client(_make_user("u1")).post( + f"/announcements/{announcement.announcement_id}/ack", + json={"action": "seen", "surface": "panel"}, + ) + + assert _client(_make_user("u1")).get("/announcements/").json()["unread_count"] == 0 + assert _client(_make_user("u2")).get("/announcements/").json()["unread_count"] == 1 + + +class TestNewUserSuppressionWiring: + """The §D6 rule itself is covered in the visibility tests. What is covered + here is the *wiring* — that the route actually reads `created_at` off the + user profile. A bug here (wrong repository, wrong field, an exception + swallowed too eagerly) would silently disable new-user suppression while + every unit test still passed.""" + + @pytest.fixture() + def users_table(self, announcements_table, monkeypatch): + monkeypatch.setenv("DYNAMODB_USERS_TABLE_NAME", USERS_TABLE) + return boto3.resource("dynamodb", region_name=AWS_REGION).Table(USERS_TABLE) + + def _put_user(self, table, user_id: str, created_at: str) -> None: + table.put_item( + Item={ + "PK": f"USER#{user_id}", + "SK": "PROFILE", + "userId": user_id, + "email": f"{user_id}@example.com", + "name": "Test User", + "emailDomain": "example.com", + "createdAt": created_at, + "lastLoginAt": created_at, + } + ) + + @pytest.mark.asyncio + async def test_a_user_who_joined_after_publication_sees_nothing(self, users_table): + await _seed(publish_at="2026-01-01T00:00:00Z") + self._put_user(users_table, "newbie", "2026-06-01T00:00:00Z") + + body = _client(_make_user("newbie")).get("/announcements/").json() + assert body["panel"] == [] + assert body["unread_count"] == 0 + + @pytest.mark.asyncio + async def test_a_user_who_joined_earlier_still_sees_it(self, users_table): + await _seed(publish_at="2026-01-01T00:00:00Z") + self._put_user(users_table, "oldtimer", "2025-01-01T00:00:00Z") + + assert len(_client(_make_user("oldtimer")).get("/announcements/").json()["panel"]) == 1 + + @pytest.mark.asyncio + async def test_a_missing_profile_fails_toward_showing(self, users_table): + """Failing toward showing a message is recoverable; failing toward + silence is not — a directory blip must not decide what a user reads.""" + await _seed(publish_at="2026-01-01T00:00:00Z") + + assert len(_client(_make_user("ghost")).get("/announcements/").json()["panel"]) == 1 + + +class TestAck: + @pytest.mark.asyncio + async def test_ack_returns_204_and_clears_unread(self, announcements_table): + announcement = await _seed() + client = _client() + + resp = client.post( + f"/announcements/{announcement.announcement_id}/ack", + json={"action": "seen", "surface": "panel"}, + ) + assert resp.status_code == 204 + assert client.get("/announcements/").json()["unread_count"] == 0 + + @pytest.mark.asyncio + async def test_dismiss_drops_the_banner_but_keeps_the_panel_entry( + self, announcements_table + ): + announcement = await _seed(surfaces=["panel", "banner"], expires_at=FUTURE) + client = _client() + + client.post( + f"/announcements/{announcement.announcement_id}/ack", + json={"action": "dismissed", "surface": "banner"}, + ) + + body = client.get("/announcements/").json() + assert body["banner"] is None + assert len(body["panel"]) == 1 + + @pytest.mark.asyncio + async def test_a_late_seen_does_not_resurrect_a_dismissed_banner( + self, announcements_table + ): + """The §D2 race, end to end through the HTTP surface. + + `seen` is written on render and can land after the user's ✕. If the + monotonic guard were only in a unit test, this is the path that would + regress. + """ + announcement = await _seed(surfaces=["panel", "banner"], expires_at=FUTURE) + client = _client() + url = f"/announcements/{announcement.announcement_id}/ack" + + client.post(url, json={"action": "dismissed", "surface": "banner"}) + late = client.post(url, json={"action": "seen", "surface": "banner"}) + + assert late.status_code == 204, "a no-op ack is success, not an error" + assert client.get("/announcements/").json()["banner"] is None + + @pytest.mark.asyncio + async def test_ack_is_idempotent(self, announcements_table): + announcement = await _seed() + client = _client() + url = f"/announcements/{announcement.announcement_id}/ack" + + assert client.post(url, json={"action": "seen", "surface": "panel"}).status_code == 204 + assert client.post(url, json={"action": "seen", "surface": "panel"}).status_code == 204 + + @pytest.mark.asyncio + async def test_ack_on_an_unknown_id_is_404(self, announcements_table): + resp = _client().post( + "/announcements/does-not-exist/ack", + json={"action": "seen", "surface": "panel"}, + ) + assert resp.status_code == 404 + + @pytest.mark.asyncio + async def test_ack_on_another_roles_announcement_is_404_not_403( + self, announcements_table + ): + """403 would confirm the announcement exists. 404 does not.""" + announcement = await _seed(target_roles=["faculty"]) + + resp = _client(_make_user("u1", ["student"])).post( + f"/announcements/{announcement.announcement_id}/ack", + json={"action": "dismissed", "surface": "panel"}, + ) + assert resp.status_code == 404 + + @pytest.mark.asyncio + async def test_ack_on_a_draft_is_404(self, announcements_table): + from apis.shared.announcements.models import AnnouncementCreate + + draft = await service_module.get_announcements_service().create_announcement( + AnnouncementCreate(title="Draft", body_markdown="x", publish_at=PAST) + ) + resp = _client().post( + f"/announcements/{draft.announcement_id}/ack", + json={"action": "seen", "surface": "panel"}, + ) + assert resp.status_code == 404 + + @pytest.mark.asyncio + async def test_an_unknown_action_is_422(self, announcements_table): + announcement = await _seed() + resp = _client().post( + f"/announcements/{announcement.announcement_id}/ack", + json={"action": "skimmed", "surface": "panel"}, + ) + assert resp.status_code == 422 + + +class TestRevision: + @pytest.mark.asyncio + async def test_a_revise_makes_a_dismissed_item_unread_and_updated( + self, announcements_table + ): + announcement = await _seed(surfaces=["panel", "banner"], expires_at=FUTURE) + client = _client() + client.post( + f"/announcements/{announcement.announcement_id}/ack", + json={"action": "dismissed", "surface": "banner"}, + ) + assert client.get("/announcements/").json()["banner"] is None + + await service_module.get_announcements_service().revise(announcement.announcement_id) + + body = client.get("/announcements/").json() + assert body["banner"] is not None, "the revision lapsed the suppression" + assert body["panel"][0]["is_updated"] is True + assert body["panel"][0]["revision"] == 2 + assert body["unread_count"] == 1 + + @pytest.mark.asyncio + async def test_an_edit_does_not_re_show_a_dismissed_item(self, announcements_table): + """A typo fix must not re-fire at everyone who already dismissed.""" + from apis.shared.announcements.models import AnnouncementUpdate + + announcement = await _seed(surfaces=["panel", "banner"], expires_at=FUTURE) + client = _client() + client.post( + f"/announcements/{announcement.announcement_id}/ack", + json={"action": "dismissed", "surface": "banner"}, + ) + + await service_module.get_announcements_service().update_announcement( + announcement.announcement_id, AnnouncementUpdate(title="Skills are here!") + ) + + body = client.get("/announcements/").json() + assert body["banner"] is None + assert body["panel"][0]["title"] == "Skills are here!" + + +class TestFeatureFlag: + @pytest.mark.asyncio + async def test_both_routes_404_when_disabled(self, announcements_table, monkeypatch): + announcement = await _seed() + monkeypatch.setenv("ANNOUNCEMENTS_ENABLED", "false") + client = _client() + + assert client.get("/announcements/").status_code == 404 + assert ( + client.post( + f"/announcements/{announcement.announcement_id}/ack", + json={"action": "seen", "surface": "panel"}, + ).status_code + == 404 + ) + + @pytest.mark.asyncio + async def test_routes_serve_when_the_flag_is_unset(self, announcements_table, monkeypatch): + await _seed() + monkeypatch.delenv("ANNOUNCEMENTS_ENABLED", raising=False) + assert _client().get("/announcements/").status_code == 200 + + +class TestAuth: + def test_every_route_requires_a_session(self): + """Cookie session auth, never Bearer — CLAUDE.md's app_api rule.""" + from apis.app_api.announcements import routes as announcement_routes + + unauthenticated = [] + for route in announcement_routes.router.routes: + names = { + sub.call.__name__ + for sub in getattr(getattr(route, "dependant", None), "dependencies", []) + if getattr(sub, "call", None) + } + nested = set() + for sub in getattr(getattr(route, "dependant", None), "dependencies", []): + nested |= { + inner.call.__name__ + for inner in getattr(sub, "dependencies", []) + if getattr(inner, "call", None) + } + if "get_current_user_from_session" not in (names | nested): + unauthenticated.append(getattr(route, "path", "?")) + + assert unauthenticated == [] diff --git a/backend/tests/shared/test_announcements_visibility.py b/backend/tests/shared/test_announcements_visibility.py new file mode 100644 index 000000000..7b45dd50c --- /dev/null +++ b/backend/tests/shared/test_announcements_visibility.py @@ -0,0 +1,367 @@ +"""The visibility filter — state, dates, roles, new-user suppression, acks, caps. + +This is where the logic is, so this is where the tests are (spec §10). +``compute_feed`` is pure, so none of this needs moto: the cases below are the +rules stated as data. +""" + +from datetime import datetime, timedelta, timezone + +import pytest + +from apis.shared.announcements.models import Announcement, AnnouncementAck +from apis.shared.announcements.visibility import SEVERITY_ORDER, compute_feed + +NOW = datetime(2026, 6, 1, 12, 0, 0, tzinfo=timezone.utc) + + +def _iso(dt: datetime) -> str: + return dt.isoformat().replace("+00:00", "Z") + + +LAST_WEEK = _iso(NOW - timedelta(days=7)) +YESTERDAY = _iso(NOW - timedelta(days=1)) +TOMORROW = _iso(NOW + timedelta(days=1)) +NEXT_YEAR = _iso(NOW + timedelta(days=365)) +TWO_YEARS_AGO = _iso(NOW - timedelta(days=730)) + + +def _announcement(announcement_id: str = "a1", **kw) -> Announcement: + defaults = dict( + announcement_id=announcement_id, + title=f"Title {announcement_id}", + body_markdown="Body", + created_at=LAST_WEEK, + updated_at=LAST_WEEK, + publish_at=LAST_WEEK, + state="published", + surfaces=["panel"], + ) + defaults.update(kw) + return Announcement(**defaults) + + +def _ack(announcement_id: str, action: str, revision: int = 1) -> AnnouncementAck: + return AnnouncementAck( + user_id="u1", + announcement_id=announcement_id, + revision=revision, + action=action, + action_at=YESTERDAY, + surface="panel", + ) + + +def _feed(announcements, *, roles=("user",), acks=(), created_at=None, now=NOW): + return compute_feed( + announcements=list(announcements), + user_roles=list(roles), + acks=list(acks), + now=now, + user_created_at=created_at, + ) + + +def _panel_ids(feed) -> list: + return [v.announcement.announcement_id for v in feed.panel] + + +# ====================================================================== +# Steps 1-2: state and the publish window +# ====================================================================== + + +class TestStateAndDates: + @pytest.mark.parametrize("state", ["draft", "scheduled", "archived"]) + def test_only_published_announcements_are_visible(self, state): + assert _panel_ids(_feed([_announcement(state=state)])) == [] + + def test_published_is_visible(self): + assert _panel_ids(_feed([_announcement()])) == ["a1"] + + def test_a_future_publish_at_is_not_yet_visible(self): + assert _panel_ids(_feed([_announcement(publish_at=TOMORROW)])) == [] + + def test_an_expired_announcement_is_gone(self): + assert ( + _panel_ids(_feed([_announcement(publish_at=TWO_YEARS_AGO, expires_at=YESTERDAY)])) + == [] + ) + + def test_an_unexpired_announcement_is_visible(self): + assert _panel_ids(_feed([_announcement(expires_at=TOMORROW)])) == ["a1"] + + def test_no_expiry_means_never_expires(self): + assert _panel_ids(_feed([_announcement(expires_at=None)])) == ["a1"] + + def test_expiry_exactly_now_is_expired(self): + """`expiresAt > now` per the spec — the boundary closes the window.""" + assert _panel_ids(_feed([_announcement(expires_at=_iso(NOW))])) == [] + + def test_publish_exactly_now_is_live(self): + """`publishAt <= now` — the boundary opens the window.""" + assert _panel_ids(_feed([_announcement(publish_at=_iso(NOW))])) == ["a1"] + + def test_a_naive_now_is_normalized_rather_than_raising(self): + """Everything else here is tz-aware, so a naive `now` would blow up on + the first comparison instead of merely mis-sorting.""" + naive = NOW.replace(tzinfo=None) + assert _panel_ids(_feed([_announcement()], now=naive)) == ["a1"] + + def test_an_unparseable_date_fails_toward_showing(self): + """A hand-written row must not silently disappear.""" + a = _announcement() + a.publish_at = "not a date" + assert _panel_ids(_feed([a])) == ["a1"] + + +# ====================================================================== +# Step 3: targetRoles — a display filter, not an RBAC grant (§D9) +# ====================================================================== + + +class TestTargetRoles: + def test_wildcard_targets_everyone(self): + assert _panel_ids(_feed([_announcement(target_roles=["*"])], roles=["anything"])) == ["a1"] + + def test_a_matching_role_sees_it(self): + assert _panel_ids( + _feed([_announcement(target_roles=["faculty"])], roles=["staff", "faculty"]) + ) == ["a1"] + + def test_a_non_matching_role_does_not(self): + assert _panel_ids(_feed([_announcement(target_roles=["faculty"])], roles=["student"])) == [] + + def test_a_user_with_no_roles_still_sees_wildcard_items(self): + assert _panel_ids(_feed([_announcement()], roles=[])) == ["a1"] + + def test_a_user_with_no_roles_sees_no_targeted_items(self): + assert _panel_ids(_feed([_announcement(target_roles=["faculty"])], roles=[])) == [] + + def test_empty_target_roles_is_treated_as_everyone(self): + """A hand-written row with no targeting must not vanish.""" + assert _panel_ids(_feed([_announcement(target_roles=[])], roles=["student"])) == ["a1"] + + +# ====================================================================== +# Step 4: new-user backfill suppression (§D6) +# ====================================================================== + + +class TestNewUserSuppression: + def test_a_user_who_joined_after_publication_sees_nothing(self): + """The most common failure mode of announcement systems: a first login + that opens onto a queue of notices about features that, to this user, + have always existed.""" + assert _panel_ids(_feed([_announcement(publish_at=LAST_WEEK)], created_at=YESTERDAY)) == [] + + def test_a_user_who_joined_before_publication_sees_it(self): + assert _panel_ids( + _feed([_announcement(publish_at=YESTERDAY)], created_at=LAST_WEEK) + ) == ["a1"] + + def test_show_to_new_users_overrides_it(self): + """The standing-policy exception.""" + assert _panel_ids( + _feed( + [_announcement(publish_at=LAST_WEEK, show_to_new_users=True)], + created_at=YESTERDAY, + ) + ) == ["a1"] + + def test_a_missing_created_at_fails_toward_showing(self): + assert _panel_ids(_feed([_announcement()], created_at=None)) == ["a1"] + + def test_a_malformed_created_at_fails_toward_showing(self): + assert _panel_ids(_feed([_announcement()], created_at="tuesday")) == ["a1"] + + def test_a_legacy_offset_and_z_created_at_still_parses(self): + """Rows written before the timestamp fix carry `+00:00Z` forever.""" + legacy = (NOW - timedelta(days=30)).isoformat() + "Z" + assert _panel_ids(_feed([_announcement(publish_at=YESTERDAY)], created_at=legacy)) == ["a1"] + + +# ====================================================================== +# Acks: suppression is loud-surface-only; the panel is durable +# ====================================================================== + + +class TestAckSuppression: + def test_a_dismissed_announcement_stays_in_the_panel(self): + """§D1/§D2 — dismissing a loud surface must never destroy the record. + + The spec's filter chain lists the ack check before the caps, which read + literally would drop the entry from the panel too. D1 and D2 are + explicit that it stays, so suppression applies only to banner/modal. + """ + feed = _feed( + [_announcement(surfaces=["panel", "banner"], expires_at=NEXT_YEAR)], + acks=[_ack("a1", "dismissed")], + ) + assert _panel_ids(feed) == ["a1"] + assert feed.banner is None + + def test_seen_does_not_suppress_the_banner(self): + """`seen` clears the unread dot and nothing else.""" + feed = _feed( + [_announcement(surfaces=["panel", "banner"], expires_at=NEXT_YEAR)], + acks=[_ack("a1", "seen")], + ) + assert feed.banner is not None + assert feed.unread_count == 0 + + def test_acknowledged_suppresses_the_modal(self): + feed = _feed( + [_announcement(surfaces=["panel", "modal"], expires_at=NEXT_YEAR)], + acks=[_ack("a1", "acknowledged")], + ) + assert feed.modal is None + + def test_an_ack_at_an_older_revision_does_not_suppress(self): + """§D4 — bumping the revision lapses everyone's suppression at once.""" + feed = _feed( + [ + _announcement( + surfaces=["panel", "banner"], expires_at=NEXT_YEAR, revision=2 + ) + ], + acks=[_ack("a1", "dismissed", revision=1)], + ) + assert feed.banner is not None + + def test_an_ack_belonging_to_another_announcement_is_ignored(self): + feed = _feed( + [_announcement("a1", surfaces=["panel", "banner"], expires_at=NEXT_YEAR)], + acks=[_ack("a2", "dismissed")], + ) + assert feed.banner is not None + + +class TestUnreadAndUpdated: + def test_a_never_acked_announcement_is_unread_and_not_updated(self): + feed = _feed([_announcement()]) + assert feed.panel[0].is_unread is True + assert feed.panel[0].is_updated is False + assert feed.unread_count == 1 + + def test_acking_the_current_revision_clears_unread(self): + feed = _feed([_announcement()], acks=[_ack("a1", "seen")]) + assert feed.panel[0].is_unread is False + assert feed.unread_count == 0 + + def test_a_bumped_revision_reads_as_updated_not_merely_new(self): + """Acked R1, now on R2 — the panel says *Updated*, which is the whole + reason acks are keyed by revision.""" + feed = _feed([_announcement(revision=2)], acks=[_ack("a1", "dismissed", revision=1)]) + assert feed.panel[0].is_unread is True + assert feed.panel[0].is_updated is True + + def test_unread_count_counts_only_unacked_items(self): + feed = _feed( + [_announcement("a1"), _announcement("a2"), _announcement("a3")], + acks=[_ack("a2", "seen")], + ) + assert feed.unread_count == 2 + + +# ====================================================================== +# Step 6: the caps (§D7) +# ====================================================================== + + +class TestCaps: + def test_five_eligible_yield_five_panel_one_banner_one_modal(self): + loud = dict(surfaces=["panel", "banner", "modal"], expires_at=NEXT_YEAR) + feed = _feed([_announcement(f"a{i}", **loud) for i in range(5)]) + + assert len(feed.panel) == 5 + assert feed.banner is not None + assert feed.modal is not None + + def test_requires_ack_wins_the_modal_slot(self): + """A blocking notice is never queued behind an informational one, even + when the informational one is older and more severe.""" + loud = dict(surfaces=["panel", "modal"], expires_at=NEXT_YEAR) + informational = _announcement( + "info", severity="warning", publish_at=TWO_YEARS_AGO, **loud + ) + blocking = _announcement("blocking", requires_ack=True, publish_at=YESTERDAY, **loud) + + feed = _feed([informational, blocking]) + assert feed.modal.announcement.announcement_id == "blocking" + + def test_banner_prefers_the_higher_severity(self): + loud = dict(surfaces=["panel", "banner"], expires_at=NEXT_YEAR) + feed = _feed( + [ + _announcement("info-item", severity="info", **loud), + _announcement("warn-item", severity="warning", **loud), + ] + ) + assert feed.banner.announcement.announcement_id == "warn-item" + + def test_banner_ties_break_on_oldest_first(self): + """Oldest-first drains the queue in the order things happened.""" + loud = dict(surfaces=["panel", "banner"], severity="info", expires_at=NEXT_YEAR) + feed = _feed( + [ + _announcement("newer", publish_at=YESTERDAY, **loud), + _announcement("older", publish_at=TWO_YEARS_AGO, **loud), + ] + ) + assert feed.banner.announcement.announcement_id == "older" + + def test_a_panel_only_announcement_fills_no_loud_slot(self): + feed = _feed([_announcement(surfaces=["panel"])]) + assert feed.banner is None and feed.modal is None + assert len(feed.panel) == 1 + + def test_the_panel_is_newest_first(self): + feed = _feed( + [ + _announcement("oldest", publish_at=TWO_YEARS_AGO), + _announcement("newest", publish_at=YESTERDAY), + _announcement("middle", publish_at=LAST_WEEK), + ] + ) + assert _panel_ids(feed) == ["newest", "middle", "oldest"] + + def test_the_loser_of_a_slot_stays_in_the_panel(self): + """Whatever loses the cap stays eligible for the next page load.""" + loud = dict(surfaces=["panel", "banner"], expires_at=NEXT_YEAR) + feed = _feed([_announcement("a1", **loud), _announcement("a2", **loud)]) + assert len(feed.panel) == 2 + assert feed.banner is not None + + +class TestFeedHelpers: + def test_contains_and_get_answer_over_the_panel(self): + feed = _feed([_announcement("a1")]) + assert feed.contains("a1") is True + assert feed.get("a1").announcement_id == "a1" + assert feed.contains("nope") is False + assert feed.get("nope") is None + + def test_a_dismissed_item_is_still_ackable(self): + """A user who dismissed can ack again — idempotent, not a 404.""" + feed = _feed( + [_announcement(surfaces=["panel", "banner"], expires_at=NEXT_YEAR)], + acks=[_ack("a1", "dismissed")], + ) + assert feed.contains("a1") is True + + def test_an_empty_feed_is_empty(self): + feed = _feed([]) + assert feed.panel == [] and feed.banner is None and feed.modal is None + assert feed.unread_count == 0 + + def test_an_unknown_severity_sorts_last_rather_than_crashing(self): + loud = dict(surfaces=["panel", "banner"], expires_at=NEXT_YEAR) + feed = _feed( + [ + _announcement("weird", severity="chartreuse", **loud), + _announcement("known", severity="info", **loud), + ] + ) + assert feed.banner.announcement.announcement_id == "known" + assert "chartreuse" not in SEVERITY_ORDER diff --git a/backend/tests/shared/test_bedrock_responses.py b/backend/tests/shared/test_bedrock_responses.py new file mode 100644 index 000000000..92b5092e9 --- /dev/null +++ b/backend/tests/shared/test_bedrock_responses.py @@ -0,0 +1,255 @@ +"""Unit tests for the shared bedrock-runtime OpenAI Responses builder. + +Covers the construction contract both consumers depend on — the agent factory +and the API-key converse handler — plus the two things that distinguish this +transport from the Mantle one: + +- the bearer token is minted **per request**, not frozen at construction; +- usage still arrives in disjoint Bedrock-Converse buckets, because the + Responses API reports an inclusive ``input_tokens``. + +The token test is the load-bearing one. Our microVMs live 18-50 minutes +against a 12-hour token cap, so a token frozen at construction would work by +luck in dev and expire in prod under any longer-lived process. +""" + +from unittest.mock import patch + +import pytest + +from apis.shared.models.bedrock_responses import ( + BEDROCK_RESPONSES_PARAM_MAP, + BEDROCK_RUNTIME_OPENAI_PATH, + build_bedrock_responses_model, + get_bedrock_runtime_openai_base_url, +) +from apis.shared.models.usage_normalization import usage_normalized + +_TOKEN_FN = "apis.shared.bedrock.bearer_token.generate_bedrock_bearer_token" + + +class TestBaseUrl: + def test_regional_openai_path(self): + assert ( + get_bedrock_runtime_openai_base_url("us-west-2") + == "https://bedrock-runtime.us-west-2.amazonaws.com/openai/v1" + ) + + def test_path_is_fixed_not_model_derived(self): + """Unlike Mantle, bedrock-runtime serves every OpenAI model from one path.""" + assert BEDROCK_RUNTIME_OPENAI_PATH == "/openai/v1" + + def test_falls_back_to_ambient_region(self, monkeypatch): + monkeypatch.setenv("AWS_REGION", "us-east-1") + + assert "bedrock-runtime.us-east-1." in get_bedrock_runtime_openai_base_url() + + def test_raises_without_a_region(self, monkeypatch): + # Loud rather than defaulting to some other region: a wrong-region + # endpoint fails as an opaque auth error at the first turn. + monkeypatch.delenv("AWS_REGION", raising=False) + + with pytest.raises(ValueError, match="No AWS region"): + get_bedrock_runtime_openai_base_url() + + +class TestBuildBedrockResponsesModel: + def test_builds_a_responses_model_on_the_runtime_endpoint(self): + from strands.models import OpenAIResponsesModel + + model = build_bedrock_responses_model( + model_id="us.openai.gpt-5.6-sol", region="us-west-2" + ) + + assert isinstance(model, OpenAIResponsesModel) + assert model.client_args["base_url"] == ( + "https://bedrock-runtime.us-west-2.amazonaws.com/openai/v1" + ) + assert model.get_config()["model_id"] == "us.openai.gpt-5.6-sol" + + def test_does_not_use_bedrock_mantle_config(self): + """The Mantle config hardcodes the Mantle host and rejects our base_url.""" + model = build_bedrock_responses_model( + model_id="us.openai.gpt-5.6-sol", region="us-west-2" + ) + + assert getattr(model, "_bedrock_mantle_config", None) is None + + def test_params_are_forwarded(self): + model = build_bedrock_responses_model( + model_id="us.openai.gpt-5.6-sol", + region="us-west-2", + params={"max_output_tokens": 512, "temperature": 0.4}, + ) + + assert model.get_config()["params"] == { + "max_output_tokens": 512, + "temperature": 0.4, + } + + def test_no_params_key_when_none_given(self): + model = build_bedrock_responses_model( + model_id="us.openai.gpt-5.6-sol", region="us-west-2" + ) + + assert "params" not in model.get_config() + + def test_region_pins_both_url_and_token_signature(self): + """One resolved value drives both, so they cannot disagree.""" + model = build_bedrock_responses_model( + model_id="global.openai.gpt-5.6-sol", region="us-east-1" + ) + + assert "bedrock-runtime.us-east-1." in model.client_args["base_url"] + with patch(_TOKEN_FN, return_value="bedrock-api-key-x") as mint: + model._resolve_client_args() + mint.assert_called_once_with("us-east-1") + + def test_raises_without_a_region(self, monkeypatch): + monkeypatch.delenv("AWS_REGION", raising=False) + + with pytest.raises(ValueError, match="No AWS region"): + build_bedrock_responses_model(model_id="us.openai.gpt-5.6-sol") + + def test_model_class_is_memoized(self): + first = build_bedrock_responses_model("us.openai.gpt-5.6-sol", region="us-west-2") + second = build_bedrock_responses_model("global.openai.gpt-5.6-sol", region="us-east-1") + + assert type(first) is type(second) + + def test_param_map_is_the_responses_vocabulary(self): + """Native names belong to the API, not the transport — shared, not copied.""" + from apis.shared.models.mantle import MANTLE_RESPONSES_PARAM_MAP + + assert BEDROCK_RESPONSES_PARAM_MAP is MANTLE_RESPONSES_PARAM_MAP + assert BEDROCK_RESPONSES_PARAM_MAP["max_tokens"] == "max_output_tokens" + + +class TestInferenceProfileWarning: + def test_warns_when_the_id_names_no_inference_profile(self, caplog): + # bedrock-runtime does not offer in-Region inference for GPT-5.6, so a + # bare `openai.` id is a misconfiguration worth surfacing. + with caplog.at_level("WARNING"): + build_bedrock_responses_model("openai.gpt-5.6-sol", region="us-west-2") + + assert "names no inference profile" in caplog.text + + @pytest.mark.parametrize( + "model_id", + ["us.openai.gpt-5.6-sol", "global.openai.gpt-5.6-sol", "eu.openai.gpt-5.6-luna"], + ) + def test_silent_for_profile_prefixed_ids(self, model_id, caplog): + with caplog.at_level("WARNING"): + build_bedrock_responses_model(model_id, region="us-west-2") + + assert "names no inference profile" not in caplog.text + + def test_warning_does_not_block_construction(self, caplog): + """A future in-Region model must not need a code change to run.""" + with caplog.at_level("WARNING"): + model = build_bedrock_responses_model("openai.some-future-model", region="us-west-2") + + assert model.get_config()["model_id"] == "openai.some-future-model" + + +class TestPerRequestTokenMint: + """The reason this transport gets its own model class.""" + + def test_token_is_minted_on_every_resolve(self): + model = build_bedrock_responses_model( + model_id="us.openai.gpt-5.6-sol", region="us-west-2" + ) + + with patch(_TOKEN_FN, side_effect=["token-1", "token-2"]) as mint: + first = model._resolve_client_args() + second = model._resolve_client_args() + + assert mint.call_count == 2 + assert first["api_key"] == "token-1" + assert second["api_key"] == "token-2" + + def test_construction_does_not_mint(self): + """Nothing is signed until a request actually needs a credential.""" + with patch(_TOKEN_FN) as mint: + build_bedrock_responses_model( + model_id="us.openai.gpt-5.6-sol", region="us-west-2" + ) + + mint.assert_not_called() + + def test_placeholder_key_never_survives_to_a_request(self): + model = build_bedrock_responses_model( + model_id="us.openai.gpt-5.6-sol", region="us-west-2" + ) + placeholder = model.client_args["api_key"] + + with patch(_TOKEN_FN, return_value="bedrock-api-key-real"): + resolved = model._resolve_client_args() + + assert resolved["api_key"] == "bedrock-api-key-real" + assert resolved["api_key"] != placeholder + + def test_base_url_survives_the_token_swap(self): + model = build_bedrock_responses_model( + model_id="us.openai.gpt-5.6-sol", region="us-west-2" + ) + + with patch(_TOKEN_FN, return_value="bedrock-api-key-x"): + resolved = model._resolve_client_args() + + assert resolved["base_url"] == ( + "https://bedrock-runtime.us-west-2.amazonaws.com/openai/v1" + ) + + def test_resolve_does_not_mutate_the_stored_client_args(self): + model = build_bedrock_responses_model( + model_id="us.openai.gpt-5.6-sol", region="us-west-2" + ) + before = dict(model.client_args) + + with patch(_TOKEN_FN, return_value="bedrock-api-key-x"): + model._resolve_client_args() + + assert model.client_args == before + + +class TestUsageNormalizationApplies: + """This transport is an OpenAI surface, so its usage needs normalizing too.""" + + def test_model_class_is_usage_normalized(self): + model = build_bedrock_responses_model( + model_id="us.openai.gpt-5.6-sol", region="us-west-2" + ) + + assert type(model).__name__.startswith("UsageNormalized") + # The wrapper sits directly over our token-refreshing subclass. + assert type(model) is usage_normalized(type(model).__mro__[1]) + + def test_metadata_chunk_reports_disjoint_buckets(self): + from openai.types.responses.response_usage import ResponseUsage + + model = build_bedrock_responses_model( + model_id="us.openai.gpt-5.6-sol", region="us-west-2" + ) + usage_obj = ResponseUsage.model_validate( + { + "input_tokens": 30_500, + "input_tokens_details": {"cached_tokens": 30_000, "cache_write_tokens": 400}, + "output_tokens": 120, + "output_tokens_details": {"reasoning_tokens": 64}, + "total_tokens": 30_620, + } + ) + + usage = model._format_chunk({"chunk_type": "metadata", "data": usage_obj})[ + "metadata" + ]["usage"] + + assert usage["inputTokens"] == 100 + assert usage["cacheReadInputTokens"] == 30_000 + assert usage["cacheWriteInputTokens"] == 400 + assert ( + usage["inputTokens"] + + usage["cacheReadInputTokens"] + + usage["cacheWriteInputTokens"] + ) == usage["totalTokens"] - usage["outputTokens"] diff --git a/backend/tests/shared/test_bedrock_responses_explicit_cache.py b/backend/tests/shared/test_bedrock_responses_explicit_cache.py new file mode 100644 index 000000000..16c9333be --- /dev/null +++ b/backend/tests/shared/test_bedrock_responses_explicit_cache.py @@ -0,0 +1,325 @@ +"""Explicit prompt-cache controls on the bedrock-runtime Responses transport. + +⛔ **This feature is OPT-IN and DEFAULT OFF.** It was built on the premise that +a breakpoint after the static prefix would turn a history change from a full +re-write into a read. Measured live, that premise was wrong: GPT-5.6's default +implicit caching appends the history delta rather than re-writing, so the +breakpoint only stops history being cached — explicit cost ~57% MORE on a +churning conversation. The mechanism works; the placement is what failed. + +These tests therefore split in two: the behaviour tests opt in explicitly via +the `model` fixture, and `TestOptInFlag` pins that the DEFAULT path — what +production actually sends — is byte-identical to the stock Strands request. + +The request shape asserted here is AWS's documented one for explicit prompt +caching: ``prompt_cache_breakpoint`` on a content block of a ``developer`` +message, ``prompt_cache_options`` at request level, ``prompt_cache_key`` +top-level. Strands emits the system prompt as the top-level ``instructions`` +string, which has no content block to mark — so the override re-expresses it +as that developer message. + +These drive the real model class through the real ``_format_request``; nothing +here stubs the SDK's request assembly. +""" + +import pytest + +from apis.shared.models.bedrock_responses import ( + EXPLICIT_CACHE_ENABLED_ENV, + EXPLICIT_CACHE_TTL, + apply_explicit_prompt_cache, + build_bedrock_responses_model, + build_prompt_cache_key, + explicit_prompt_cache_enabled, +) + +SYSTEM = "You are a helpful assistant with a long stable preamble." +TOOLS = [ + { + "name": "search", + "description": "search things", + "inputSchema": {"json": {"type": "object", "properties": {}}}, + } +] +MESSAGES = [ + {"role": "user", "content": [{"text": "first"}]}, + {"role": "assistant", "content": [{"text": "reply"}]}, + {"role": "user", "content": [{"text": "second"}]}, +] + + +@pytest.fixture +def explicit_on(monkeypatch): + """Opt in. Explicit mode is DEFAULT OFF — it measured ~57% more expensive + than the model's implicit caching on a conversation with growing history + (see the module docstring in bedrock_responses.py).""" + monkeypatch.setenv(EXPLICIT_CACHE_ENABLED_ENV, "true") + + +@pytest.fixture +def model(explicit_on): + return build_bedrock_responses_model("us.openai.gpt-5.6-sol", region="us-west-2") + + +@pytest.fixture +def default_model(): + """The model as it behaves with no opt-in — i.e. in production.""" + return build_bedrock_responses_model("us.openai.gpt-5.6-sol", region="us-west-2") + + +def _format(model, *, system_prompt=SYSTEM, tool_specs=TOOLS, messages=MESSAGES): + return model._format_request(messages, tool_specs, system_prompt) + + +class TestBreakpointPlacement: + def test_instructions_become_a_developer_message_carrying_the_breakpoint(self, model): + request = _format(model) + + assert "instructions" not in request, ( + "the system prompt has to live in `input` to carry a content block" + ) + first = request["input"][0] + assert first["type"] == "message" + assert first["role"] == "developer" + assert first["content"] == [ + { + "type": "input_text", + "text": SYSTEM, + "prompt_cache_breakpoint": {"mode": "explicit"}, + } + ] + + def test_conversation_history_follows_the_breakpoint_in_order(self, model): + request = _format(model) + + # The boundary sits after tools + system and before any history, so + # a history change costs a read of the prefix, not a re-write. + history = request["input"][1:] + assert len(history) == len(MESSAGES) + assert [m["role"] for m in history] == ["user", "assistant", "user"] + + def test_only_one_breakpoint_is_emitted(self, model): + """The API caps breakpoints at 4; we spend exactly one, on the prefix.""" + request = _format(model) + + marked = [ + block + for item in request["input"] + if isinstance(item.get("content"), list) + for block in item["content"] + if isinstance(block, dict) and "prompt_cache_breakpoint" in block + ] + assert len(marked) == 1 + + def test_tools_stay_top_level_and_untouched(self, model): + request = _format(model) + + assert request["tools"][0]["name"] == "search" + + +class TestCacheOptions: + def test_options_ride_extra_body(self, model): + # `prompt_cache_options` is not a named parameter on the OpenAI SDK's + # responses.create, so it has to travel in extra_body. + request = _format(model) + + assert request["extra_body"]["prompt_cache_options"] == { + "mode": "explicit", + "ttl": EXPLICIT_CACHE_TTL, + } + + def test_ttl_agrees_with_the_classifier_window(self): + """The string here and the seconds the classifier uses must not drift.""" + from apis.shared.observability import OPENAI_RESPONSES_CACHE_TTL_SECONDS + + assert EXPLICIT_CACHE_TTL.endswith("m") + assert int(EXPLICIT_CACHE_TTL[:-1]) * 60 == OPENAI_RESPONSES_CACHE_TTL_SECONDS + + def test_an_existing_extra_body_is_merged_not_clobbered(self): + request = { + "instructions": SYSTEM, + "input": [], + "extra_body": {"something_else": 1}, + } + apply_explicit_prompt_cache(request, system_prompt=SYSTEM, tool_specs=TOOLS) + + assert request["extra_body"]["something_else"] == 1 + assert "prompt_cache_options" in request["extra_body"] + + def test_a_caller_supplied_option_wins(self): + request = { + "instructions": SYSTEM, + "input": [], + "extra_body": {"prompt_cache_options": {"mode": "implicit"}}, + } + apply_explicit_prompt_cache(request, system_prompt=SYSTEM, tool_specs=TOOLS) + + assert request["extra_body"]["prompt_cache_options"] == {"mode": "implicit"} + + +class TestPromptCacheKey: + def test_is_a_top_level_request_parameter(self, model): + # It IS a named SDK parameter, unlike prompt_cache_options. + request = _format(model) + + assert request["prompt_cache_key"] == build_prompt_cache_key(SYSTEM, TOOLS) + + def test_is_stable_across_turns_of_one_conversation(self, model): + """Keyed on the static prefix only — history must not rotate it. + + A key that changed every turn would be the exact cache-busting this + feature exists to prevent. + """ + turn_one = _format(model, messages=MESSAGES[:1]) + turn_two = _format(model, messages=MESSAGES) + + assert turn_one["prompt_cache_key"] == turn_two["prompt_cache_key"] + + def test_rotates_when_the_system_prompt_changes(self, model): + assert ( + _format(model)["prompt_cache_key"] + != _format(model, system_prompt=SYSTEM + " extra")["prompt_cache_key"] + ) + + def test_rotates_when_the_tool_set_changes(self, model): + other = [dict(TOOLS[0], name="different")] + + assert _format(model)["prompt_cache_key"] != _format(model, tool_specs=other)["prompt_cache_key"] + + def test_rotates_when_only_tool_order_changes(self, model): + """Prefix matching is order-sensitive, so the key must be too.""" + two = TOOLS + [dict(TOOLS[0], name="second")] + flipped = list(reversed(two)) + + assert ( + _format(model, tool_specs=two)["prompt_cache_key"] + != _format(model, tool_specs=flipped)["prompt_cache_key"] + ) + + def test_no_tools_is_stable_and_distinct_from_having_tools(self, model): + none_key = _format(model, tool_specs=None)["prompt_cache_key"] + + assert none_key == _format(model, tool_specs=[])["prompt_cache_key"] + assert none_key != _format(model)["prompt_cache_key"] + + def test_a_caller_supplied_key_wins(self): + request = {"instructions": SYSTEM, "input": [], "prompt_cache_key": "mine"} + apply_explicit_prompt_cache(request, system_prompt=SYSTEM, tool_specs=TOOLS) + + assert request["prompt_cache_key"] == "mine" + + +class TestNoSystemPrompt: + """With no static prefix to bound, stay on implicit caching.""" + + def test_request_is_left_untouched(self, model): + request = _format(model, system_prompt=None) + + assert "prompt_cache_key" not in request + assert "extra_body" not in request + assert request["input"][0]["role"] == "user" + + def test_explicit_mode_is_not_forced_on(self, model): + # Switching to explicit mode opts OUT of the model's default implicit + # caching. With a badly placed boundary that is worse than not + # switching at all, so absent a system prompt we do not switch. + request = _format(model, system_prompt=None) + + assert "prompt_cache_options" not in (request.get("extra_body") or {}) + + +class TestOptInFlag: + """Default OFF, and the default path must be byte-identical to stock. + + Explicit mode measured ~57% MORE expensive than implicit on a churning + conversation: the breakpoint after the static prefix stops history being + cached, so uncached input grows every turn. The mechanism works; the + placement is what failed. Nobody re-enables this without re-running + `scripts/probe_gpt56_cache_rates.py --mode both --grow-history` and beating + the implicit arm. + """ + + def test_disabled_by_default(self, monkeypatch): + monkeypatch.delenv(EXPLICIT_CACHE_ENABLED_ENV, raising=False) + + assert explicit_prompt_cache_enabled() is False + + def test_empty_string_stays_disabled(self, monkeypatch): + # Workflow env vars can materialize as "" — that must not opt in. + monkeypatch.setenv(EXPLICIT_CACHE_ENABLED_ENV, "") + + assert explicit_prompt_cache_enabled() is False + + @pytest.mark.parametrize("value", ["true", "TRUE", "True"]) + def test_only_the_literal_true_enables(self, monkeypatch, value): + monkeypatch.setenv(EXPLICIT_CACHE_ENABLED_ENV, value) + + assert explicit_prompt_cache_enabled() is True + + @pytest.mark.parametrize("value", ["false", "1", "yes", "on", "explicit"]) + def test_nothing_else_enables(self, monkeypatch, value): + monkeypatch.setenv(EXPLICIT_CACHE_ENABLED_ENV, value) + + assert explicit_prompt_cache_enabled() is False + + def test_the_default_request_is_the_stock_shape(self, default_model, monkeypatch): + """What production actually sends: untouched, on implicit caching.""" + monkeypatch.delenv(EXPLICIT_CACHE_ENABLED_ENV, raising=False) + + request = _format(default_model) + + assert request["instructions"] == SYSTEM + assert "prompt_cache_key" not in request + assert "extra_body" not in request + assert request["input"][0]["role"] == "user" + assert not any( + "prompt_cache_breakpoint" in block + for item in request["input"] + if isinstance(item.get("content"), list) + for block in item["content"] + if isinstance(block, dict) + ) + + +class TestOtherTransportsUnaffected: + def test_mantle_responses_gets_no_explicit_controls(self, explicit_on): + """openai.gpt-5.4 on Mantle is implicit-only — it has no breakpoints. + + Sending explicit controls there would at best be ignored and at worst + rejected, so the Mantle builder must not inherit any of this. + """ + from apis.shared.models.mantle import MantleApiMode, build_mantle_model + + model = build_mantle_model( + model_id="openai.gpt-5.4", + api_mode=MantleApiMode.RESPONSES, + region="us-east-1", + ) + request = model._format_request(MESSAGES, TOOLS, SYSTEM) + + assert request["instructions"] == SYSTEM + assert "prompt_cache_key" not in request + assert "extra_body" not in request + + +class TestUsageNormalizationStillApplies: + def test_disjoint_buckets_survive_the_format_override(self, model): + """PR-1's normalization and this override live on the same class.""" + from openai.types.responses.response_usage import ResponseUsage + + usage_obj = ResponseUsage.model_validate( + { + "input_tokens": 30_500, + "input_tokens_details": {"cached_tokens": 30_000, "cache_write_tokens": 400}, + "output_tokens": 120, + "output_tokens_details": {"reasoning_tokens": 64}, + "total_tokens": 30_620, + } + ) + usage = model._format_chunk({"chunk_type": "metadata", "data": usage_obj})[ + "metadata" + ]["usage"] + + assert usage["inputTokens"] == 100 + assert usage["cacheReadInputTokens"] == 30_000 + assert usage["cacheWriteInputTokens"] == 400 diff --git a/backend/tests/shared/test_caching_provider_contract.py b/backend/tests/shared/test_caching_provider_contract.py new file mode 100644 index 000000000..97802b837 --- /dev/null +++ b/backend/tests/shared/test_caching_provider_contract.py @@ -0,0 +1,70 @@ +"""Cross-package contract: the `supportsCaching` provider policy. + +The admin form always posts a `supportsCaching` value, so the backend's +provider-aware default never sees ``None`` from the UI and can never apply. +That forces the same policy to exist on both sides — and two sources of truth +that can drift is exactly what produced the bug this test guards. + +The failure it prevents is not cosmetic. On ``bedrock-responses`` caching is +implicit and server-side; nothing we send turns it off. A stored ``False`` +there is untrue, and its only practical effect is that the cache rates get +cleared — pricing cached tokens at $0.00 while the provider bills them in full. +A live probe on ``us.openai.gpt-5.6-sol`` measured 38,410 cache-read and 13,405 +cache-write tokens against 10 uncached input tokens on a warm conversation, so +that is close to total under-reporting of the model's spend. + +So this reads the TypeScript source and asserts the two lists agree. +""" + +import re +from pathlib import Path + +import pytest + +from apis.shared.models.managed_models import ( + _CACHING_DEFAULT_PROVIDERS, + _CACHING_FORCED_PROVIDERS, + _resolve_supports_caching, +) + +_MODEL_TS = ( + Path(__file__).resolve().parents[2].parent + / "frontend" + / "ai.client" + / "src" + / "app" + / "admin" + / "manage-models" + / "models" + / "managed-model.model.ts" +) + + +def _ts_provider_list(name: str) -> tuple[str, ...]: + """Extract a `readonly ModelProvider[]` literal from the TS source.""" + source = _MODEL_TS.read_text(encoding="utf-8") + match = re.search(rf"export const {name}: readonly ModelProvider\[\] = \[(.*?)\];", source, re.S) + assert match, f"{name} not found in {_MODEL_TS.name} — did it get renamed?" + return tuple(re.findall(r"'([^']+)'", match.group(1))) + + +@pytest.mark.skipif(not _MODEL_TS.exists(), reason="frontend sources not present") +class TestProviderListsAgree: + def test_caching_defaults_match(self): + assert _ts_provider_list("CACHING_DEFAULT_PROVIDERS") == _CACHING_DEFAULT_PROVIDERS + + def test_forced_providers_match(self): + assert _ts_provider_list("CACHING_FORCED_PROVIDERS") == _CACHING_FORCED_PROVIDERS + + +class TestPolicyInvariants: + def test_every_forced_provider_also_defaults_on(self): + """A provider that is forced on but not a default would contradict itself.""" + for provider in _CACHING_FORCED_PROVIDERS: + assert provider in _CACHING_DEFAULT_PROVIDERS + + def test_forcing_beats_an_explicit_false(self): + assert _resolve_supports_caching(False, "bedrock-responses") is True + + def test_optional_providers_still_honour_an_explicit_false(self): + assert _resolve_supports_caching(False, "bedrock") is False diff --git a/backend/tests/shared/test_openai_surface_model_records.py b/backend/tests/shared/test_openai_surface_model_records.py new file mode 100644 index 000000000..c0ee66cdb --- /dev/null +++ b/backend/tests/shared/test_openai_surface_model_records.py @@ -0,0 +1,91 @@ +"""Managed-model field resolution for the OpenAI-compatible Bedrock surfaces. + +``apiMode`` and ``region`` are wire-generic fields that mean something on two +providers — ``mantle`` and ``bedrock-responses`` — and nothing on the rest. +These pin how a record is normalized on write, which is the only place that +decision is made. +""" + +import pytest + +from apis.shared.models.managed_models import ( + _resolve_supports_caching, + _resolve_mantle_api_mode, + _resolve_mantle_region, +) + + +class TestApiModeResolution: + @pytest.mark.parametrize( + "stored,expected", + [("chat", "chat"), ("responses", "responses"), (None, "chat"), ("bogus", "chat")], + ) + def test_mantle_is_admin_selectable(self, stored, expected): + assert _resolve_mantle_api_mode(stored, "mantle") == expected + + @pytest.mark.parametrize("stored", ["chat", "responses", None, "", "bogus"]) + def test_bedrock_responses_is_always_responses(self, stored): + """Not a choice: that transport exists because 5.6 caches only there. + + A stored 'chat' — from a hand-edited record, or a provider switch that + left the old value behind — would silently downgrade the model to an + uncached Chat Completions call. Normalize, don't honor. + """ + assert _resolve_mantle_api_mode(stored, "bedrock-responses") == "responses" + + @pytest.mark.parametrize("provider", ["bedrock", "openai", "gemini"]) + def test_inert_for_other_providers(self, provider): + assert _resolve_mantle_api_mode("responses", provider) is None + + def test_provider_matching_is_case_insensitive(self): + assert _resolve_mantle_api_mode("chat", "Bedrock-Responses") == "responses" + + +class TestRegionResolution: + @pytest.mark.parametrize("provider", ["mantle", "bedrock-responses"]) + def test_kept_on_both_openai_surfaces(self, provider): + assert _resolve_mantle_region("us-east-1", provider) == "us-east-1" + + @pytest.mark.parametrize("provider", ["mantle", "bedrock-responses"]) + def test_empty_means_the_apps_region(self, provider): + assert _resolve_mantle_region("", provider) is None + assert _resolve_mantle_region(None, provider) is None + + @pytest.mark.parametrize("provider", ["bedrock", "openai", "gemini"]) + def test_dropped_for_other_providers(self, provider): + assert _resolve_mantle_region("us-east-1", provider) is None + + +class TestCachingDefault: + @pytest.mark.parametrize("provider", ["bedrock", "bedrock-responses"]) + def test_defaults_on_for_caching_families(self, provider): + assert _resolve_supports_caching(None, provider) is True + + def test_mantle_defaults_off(self): + """Mantle hosts open-weight models that mostly don't cache.""" + assert _resolve_supports_caching(None, "mantle") is False + + @pytest.mark.parametrize("provider", ["openai", "gemini"]) + def test_other_providers_default_off(self, provider): + assert _resolve_supports_caching(None, provider) is False + + @pytest.mark.parametrize("provider", ["bedrock", "mantle", "openai", "gemini"]) + def test_explicit_value_wins_where_caching_is_optional(self, provider): + assert _resolve_supports_caching(False, provider) is False + assert _resolve_supports_caching(True, provider) is True + + @pytest.mark.parametrize("stored", [None, True, False]) + def test_bedrock_responses_is_always_caching(self, stored): + """Not a setting: the transport caches implicitly, server-side. + + A stored False would be untrue, and its only practical effect is that + the cache rates get cleared — pricing cached tokens at $0.00 while the + provider bills them in full. On a warm conversation nearly every input + token is a cache read, so that is close to total under-reporting. + Normalized rather than honored, exactly like `apiMode` on the same + transport. + """ + assert _resolve_supports_caching(stored, "bedrock-responses") is True + + def test_forcing_is_case_insensitive(self): + assert _resolve_supports_caching(False, "Bedrock-Responses") is True diff --git a/backend/tests/shared/test_prompt_cache_observability.py b/backend/tests/shared/test_prompt_cache_observability.py index 7fff282fd..8c2c25611 100644 --- a/backend/tests/shared/test_prompt_cache_observability.py +++ b/backend/tests/shared/test_prompt_cache_observability.py @@ -7,8 +7,11 @@ from apis.shared.observability import ( CACHE_TTL_SECONDS, + DEFAULT_CACHE_TTL_SECONDS, + OPENAI_RESPONSES_CACHE_TTL_SECONDS, PARTIAL_MISS_WRITE_READ_RATIO, CacheStatus, + cache_ttl_seconds_for, classify_cache_status, compute_wasted_usd, emit_prompt_cache_metrics, @@ -513,3 +516,142 @@ def test_the_old_classifier_would_have_called_every_one_of_them_a_hit(self): if status is CacheStatus.HIT: assert wasted == 0.0 assert any(status is CacheStatus.PARTIAL_MISS for status, _ in self._replay()) + + +class TestCacheTtlResolution: + """The TTL is a property of the model, not of the module. + + Bedrock/Anthropic prompt caching is a ~5-minute sliding window; the OpenAI + Responses API on bedrock-runtime holds entries for 30 minutes. A single + hardcoded 300s was wrong by 6x for GPT-5.6 — and wrong in the direction + that hides waste. + """ + + def test_bedrock_responses_gets_the_thirty_minute_window(self): + assert ( + cache_ttl_seconds_for(provider="bedrock-responses") + == OPENAI_RESPONSES_CACHE_TTL_SECONDS + == 1800 + ) + + def test_provider_match_is_case_insensitive(self): + assert cache_ttl_seconds_for(provider="Bedrock-Responses") == 1800 + + def test_bedrock_keeps_the_five_minute_window(self): + assert cache_ttl_seconds_for(provider="bedrock") == DEFAULT_CACHE_TTL_SECONDS == 300 + + def test_mantle_is_deliberately_not_widened(self): + """openai.gpt-5.4 on Mantle is implicit-only; AWS documents no 30m TTL. + + Guessing here would over-report waste — the opposite error, but the + same class of mistake. + """ + assert cache_ttl_seconds_for(provider="mantle") == DEFAULT_CACHE_TTL_SECONDS + + def test_unknown_provider_falls_back_to_the_default(self): + assert cache_ttl_seconds_for(provider="something-new") == DEFAULT_CACHE_TTL_SECONDS + assert cache_ttl_seconds_for() == DEFAULT_CACHE_TTL_SECONDS + + def test_model_id_fallback_for_rows_written_before_provider_existed(self): + assert cache_ttl_seconds_for(model_id="us.openai.gpt-5.6-sol") == 1800 + assert cache_ttl_seconds_for(model_id="global.openai.gpt-5.6-luna") == 1800 + assert ( + cache_ttl_seconds_for(model_id="us.anthropic.claude-haiku-4-5") + == DEFAULT_CACHE_TTL_SECONDS + ) + + def test_provider_wins_over_the_model_id_fallback(self): + # An explicit provider is authoritative; the id sniff is only for rows + # that predate the field. + assert ( + cache_ttl_seconds_for(provider="bedrock", model_id="us.openai.gpt-5.6-sol") + == DEFAULT_CACHE_TTL_SECONDS + ) + + +class TestTtlAwareClassification: + """The dollars-visible consequence of the TTL being right.""" + + GAP = 900 # 15 min: past the Bedrock window, inside the OpenAI one. + + def test_gap_inside_the_openai_ttl_is_avoidable_waste(self): + assert ( + classify_cache_status( + 0, + 30_000, + previous_call_exists=True, + gap_seconds=self.GAP, + previous_cached_prefix_tokens=30_000, + ttl_seconds=OPENAI_RESPONSES_CACHE_TTL_SECONDS, + ) + is CacheStatus.MISS_AVOIDABLE + ) + + def test_the_same_gap_under_the_old_constant_looked_unavoidable(self): + """This is the bug: a live entry reported as a legitimate expiry.""" + assert ( + classify_cache_status( + 0, + 30_000, + previous_call_exists=True, + gap_seconds=self.GAP, + previous_cached_prefix_tokens=30_000, + ttl_seconds=DEFAULT_CACHE_TTL_SECONDS, + ) + is CacheStatus.MISS_TTL_EXPIRED + ) + + def test_partial_miss_survives_a_gap_inside_the_openai_ttl(self): + assert ( + classify_cache_status( + 11_000, + 190_000, + previous_call_exists=True, + gap_seconds=self.GAP, + ttl_seconds=OPENAI_RESPONSES_CACHE_TTL_SECONDS, + ) + is CacheStatus.PARTIAL_MISS + ) + + def test_partial_miss_degrades_to_hit_under_the_wrong_ttl(self): + """The compaction-spiral shape, mislabelled — wastedUsd goes to $0.""" + assert ( + classify_cache_status( + 11_000, + 190_000, + previous_call_exists=True, + gap_seconds=self.GAP, + ttl_seconds=DEFAULT_CACHE_TTL_SECONDS, + ) + is CacheStatus.HIT + ) + + def test_beyond_the_openai_ttl_is_still_a_real_expiry(self): + assert ( + classify_cache_status( + 0, + 30_000, + previous_call_exists=True, + gap_seconds=OPENAI_RESPONSES_CACHE_TTL_SECONDS + 1, + previous_cached_prefix_tokens=30_000, + ttl_seconds=OPENAI_RESPONSES_CACHE_TTL_SECONDS, + ) + is CacheStatus.MISS_TTL_EXPIRED + ) + + def test_default_is_unchanged_for_every_existing_caller(self): + """Omitting ttl_seconds must behave exactly as before this change.""" + for gap, expected in ( + (CACHE_TTL_SECONDS, CacheStatus.MISS_AVOIDABLE), + (CACHE_TTL_SECONDS + 1, CacheStatus.MISS_TTL_EXPIRED), + ): + assert ( + classify_cache_status( + 0, + 5_000, + previous_call_exists=True, + gap_seconds=gap, + previous_cached_prefix_tokens=5_000, + ) + is expected + ) diff --git a/backend/tests/shared/test_session_lease.py b/backend/tests/shared/test_session_lease.py index 9f8067911..8d45c59ab 100644 --- a/backend/tests/shared/test_session_lease.py +++ b/backend/tests/shared/test_session_lease.py @@ -14,13 +14,21 @@ from apis.shared.sessions.session_lease import ( LEASE_WINDOW_SECONDS, + STEER_QUEUE_MAX_CHARS, + STEER_QUEUE_MAX_ENTRIES, SessionBusyError, SessionLease, + SteerQueueFullError, acquire_session_lease, + clear_steer_entry, is_session_lease_held, + peek_steer_queue, release_session_lease, + remove_steer_entry, renew_session_lease, + seed_steer_queue, request_session_cancel, + request_session_steer, ) @@ -280,3 +288,268 @@ async def test_acquire_release_reacquire_cycle(self, sessions_metadata_table): with pytest.raises(SessionBusyError): await acquire_session_lease("s1", "u1") await release_session_lease(lease) + + +class TestSteerInbox: + """The lease row's mid-turn steering inbox (docs/specs/mid-turn-steering.md). + + Same item, same owner-scoping, same fail-soft posture as the cancel marker. + The property that matters throughout: the user's words are either injected + exactly once or left for the end-of-turn flush — never both, never neither. + """ + + @pytest.mark.asyncio + async def test_steer_is_queued_and_peekable_by_owner(self, sessions_metadata_table): + lease = await acquire_session_lease("s1", "u1") + assert await request_session_steer("s1", "u1", text="use the other file", entry_id="e1") is True + + entries = await peek_steer_queue(lease) + assert [e["id"] for e in entries] == ["e1"] + assert entries[0]["text"] == "use the other file" + assert entries[0]["at"] + + @pytest.mark.asyncio + async def test_steer_with_no_active_lease_is_noop(self, sessions_metadata_table): + # No turn streaming → nothing to steer. The SPA sends a normal turn. + assert await request_session_steer("s1", "u1", text="hi", entry_id="e1") is False + assert _lease_item(sessions_metadata_table) is None + + @pytest.mark.asyncio + async def test_steer_after_turn_ended_is_rejected(self, sessions_metadata_table): + lease = await acquire_session_lease("s1", "u1") + await release_session_lease(lease) + # The turn ended between the user typing and the POST landing. This + # race resolving to "not queued" is the correct outcome. + assert await request_session_steer("s1", "u1", text="hi", entry_id="e1") is False + + @pytest.mark.asyncio + async def test_peek_does_not_consume(self, sessions_metadata_table): + lease = await acquire_session_lease("s1", "u1") + await request_session_steer("s1", "u1", text="hi", entry_id="e1") + # Commit-on-append: AfterToolsEvent also fires on the interrupt path, + # where the mutated message is discarded. A peek that consumed would + # destroy the user's words on exactly that path. + assert len(await peek_steer_queue(lease)) == 1 + assert len(await peek_steer_queue(lease)) == 1 + + @pytest.mark.asyncio + async def test_entries_are_peeked_in_arrival_order(self, sessions_metadata_table): + lease = await acquire_session_lease("s1", "u1") + for i in range(3): + await request_session_steer("s1", "u1", text=f"t{i}", entry_id=f"e{i}") + assert [e["id"] for e in await peek_steer_queue(lease)] == ["e0", "e1", "e2"] + + @pytest.mark.asyncio + async def test_peek_is_owner_scoped(self, sessions_metadata_table): + first = await acquire_session_lease("s1", "u1") + await request_session_steer("s1", "u1", text="for the first turn", entry_id="e1") + # The turn ends and a resume force-acquires. The new owner must not + # inherit the previous turn's inbox. + resumed = await acquire_session_lease("s1", "u1", force=True) + assert resumed.owner != first.owner + assert await peek_steer_queue(resumed) == [] + + @pytest.mark.asyncio + async def test_peek_with_no_lease_row_is_empty(self, sessions_metadata_table): + lease = SessionLease(session_id="s1", user_id="u1", owner="ghost") + assert await peek_steer_queue(lease) == [] + + @pytest.mark.asyncio + async def test_clear_removes_only_the_named_entry(self, sessions_metadata_table): + lease = await acquire_session_lease("s1", "u1") + for i in range(3): + await request_session_steer("s1", "u1", text=f"t{i}", entry_id=f"e{i}") + + assert await clear_steer_entry(lease, "e1") is True + assert [e["id"] for e in await peek_steer_queue(lease)] == ["e0", "e2"] + + @pytest.mark.asyncio + async def test_clear_is_idempotent(self, sessions_metadata_table): + lease = await acquire_session_lease("s1", "u1") + await request_session_steer("s1", "u1", text="hi", entry_id="e1") + + assert await clear_steer_entry(lease, "e1") is True + # A re-delivery after a lost ack must not remove a later entry that + # slid into the vacated index. + await request_session_steer("s1", "u1", text="second", entry_id="e2") + assert await clear_steer_entry(lease, "e1") is False + assert [e["id"] for e in await peek_steer_queue(lease)] == ["e2"] + + @pytest.mark.asyncio + async def test_clear_is_owner_scoped(self, sessions_metadata_table): + first = await acquire_session_lease("s1", "u1") + await request_session_steer("s1", "u1", text="hi", entry_id="e1") + resumed = await acquire_session_lease("s1", "u1", force=True) + # A superseded turn cannot consume the current owner's inbox. + assert await clear_steer_entry(first, "e1") is False + + @pytest.mark.asyncio + async def test_remove_withdraws_a_queued_entry_for_the_user(self, sessions_metadata_table): + lease = await acquire_session_lease("s1", "u1") + await request_session_steer("s1", "u1", text="hi", entry_id="e1") + # The user deleted the pending-ack chip from the composer. + assert await remove_steer_entry("s1", "u1", "e1") is True + assert await peek_steer_queue(lease) == [] + + @pytest.mark.asyncio + async def test_remove_unknown_entry_is_noop(self, sessions_metadata_table): + await acquire_session_lease("s1", "u1") + assert await remove_steer_entry("s1", "u1", "nope") is False + + @pytest.mark.asyncio + async def test_queue_entry_cap_is_enforced(self, sessions_metadata_table): + lease = await acquire_session_lease("s1", "u1") + for i in range(STEER_QUEUE_MAX_ENTRIES): + await request_session_steer("s1", "u1", text="x", entry_id=f"e{i}") + with pytest.raises(SteerQueueFullError): + await request_session_steer("s1", "u1", text="x", entry_id="over") + assert len(await peek_steer_queue(lease)) == STEER_QUEUE_MAX_ENTRIES + + @pytest.mark.asyncio + async def test_queue_size_cap_is_enforced(self, sessions_metadata_table): + await acquire_session_lease("s1", "u1") + await request_session_steer("s1", "u1", text="x" * (STEER_QUEUE_MAX_CHARS - 10), entry_id="e1") + with pytest.raises(SteerQueueFullError): + await request_session_steer("s1", "u1", text="y" * 100, entry_id="e2") + + @pytest.mark.asyncio + async def test_release_deletes_the_inbox_with_the_lease(self, sessions_metadata_table): + lease = await acquire_session_lease("s1", "u1") + await request_session_steer("s1", "u1", text="hi", entry_id="e1") + await release_session_lease(lease) + # No separate GC path: an unconsumed inbox cannot outlive its turn. + assert _lease_item(sessions_metadata_table) is None + + @pytest.mark.asyncio + async def test_cancel_and_steer_coexist_on_the_row(self, sessions_metadata_table): + """Cancel beats steering: the stop is observed, the inbox is left alone. + + The SPA's queue entry survives the stop and the user can resend it. + """ + lease = await acquire_session_lease("s1", "u1") + await request_session_steer("s1", "u1", text="hi", entry_id="e1") + await request_session_cancel("s1", "u1") + + assert await renew_session_lease(lease) is True + assert [e["id"] for e in await peek_steer_queue(lease)] == ["e1"] + + +class TestSteerSeeding: + """Carrying queued follow-ups into a turn that is just starting. + + The paused-turn path (docs/specs/mid-turn-steering.md): a turn paused for + consent has no running loop to steer, and the pause releases its lease — + inbox and all. The resume request carries the entries and they are seeded + onto the resumed turn's lease, where the ordinary hook picks them up. + """ + + @pytest.mark.asyncio + async def test_seeded_entries_are_peekable_by_the_new_turn(self, sessions_metadata_table): + lease = await acquire_session_lease("s1", "u1") + assert await seed_steer_queue(lease, [{"id": "e1", "text": "use the other file"}]) == 1 + + entries = await peek_steer_queue(lease) + assert [e["id"] for e in entries] == ["e1"] + assert entries[0]["text"] == "use the other file" + + @pytest.mark.asyncio + async def test_seeding_replaces_rather_than_appends(self, sessions_metadata_table): + lease = await acquire_session_lease("s1", "u1") + await seed_steer_queue(lease, [{"id": "e1", "text": "one"}]) + await seed_steer_queue(lease, [{"id": "e2", "text": "two"}]) + # A seed runs at turn start on a lease we just took, so there is nothing + # legitimate to append to — appending would re-inject a retried resume's + # first payload alongside its second. + assert [e["id"] for e in await peek_steer_queue(lease)] == ["e2"] + + @pytest.mark.asyncio + async def test_seeding_and_live_steering_coexist(self, sessions_metadata_table): + lease = await acquire_session_lease("s1", "u1") + await seed_steer_queue(lease, [{"id": "carried", "text": "from the pause"}]) + # The resumed turn is a live turn like any other; a steer typed during + # it lands behind the carried one. + await request_session_steer("s1", "u1", text="and this too", entry_id="live") + + assert [e["id"] for e in await peek_steer_queue(lease)] == ["carried", "live"] + + @pytest.mark.asyncio + async def test_seeding_is_owner_scoped(self, sessions_metadata_table): + first = await acquire_session_lease("s1", "u1") + resumed = await acquire_session_lease("s1", "u1", force=True) + assert await seed_steer_queue(first, [{"id": "e1", "text": "hi"}]) == 0 + assert await peek_steer_queue(resumed) == [] + + @pytest.mark.asyncio + async def test_nothing_to_seed_is_a_noop(self, sessions_metadata_table): + lease = await acquire_session_lease("s1", "u1") + assert await seed_steer_queue(lease, []) == 0 + assert await seed_steer_queue(None, [{"id": "e1", "text": "hi"}]) == 0 + assert await peek_steer_queue(lease) == [] + + @pytest.mark.asyncio + async def test_malformed_entries_are_dropped(self, sessions_metadata_table): + lease = await acquire_session_lease("s1", "u1") + seeded = await seed_steer_queue( + lease, + [{"id": "", "text": "no id"}, {"id": "e2", "text": ""}, {"id": "e3", "text": "ok"}], + ) + assert seeded == 1 + assert [e["id"] for e in await peek_steer_queue(lease)] == ["e3"] + + @pytest.mark.asyncio + async def test_seeding_respects_the_entry_cap(self, sessions_metadata_table): + lease = await acquire_session_lease("s1", "u1") + seeded = await seed_steer_queue( + lease, + [{"id": f"e{i}", "text": "x"} for i in range(STEER_QUEUE_MAX_ENTRIES + 3)], + ) + assert seeded == STEER_QUEUE_MAX_ENTRIES + + @pytest.mark.asyncio + async def test_seeding_respects_the_size_cap(self, sessions_metadata_table): + lease = await acquire_session_lease("s1", "u1") + seeded = await seed_steer_queue( + lease, + [ + {"id": "e1", "text": "x" * (STEER_QUEUE_MAX_CHARS - 10)}, + {"id": "e2", "text": "y" * 100}, + ], + ) + assert seeded == 1 + + @pytest.mark.asyncio + async def test_acquire_clears_a_previous_turns_inbox(self, sessions_metadata_table): + """The reason seeding is safe at all. + + Owner-scoping hides a stale queue from `peek_steer_queue`, but seeding + stamps `steerFor` to the NEW owner — which would make a previous turn's + leftovers visible and inject them into a turn they were never meant for. + Acquire clears the inbox so that cannot happen. + """ + first = await acquire_session_lease("s1", "u1") + await request_session_steer("s1", "u1", text="from the old turn", entry_id="stale") + + resumed = await acquire_session_lease("s1", "u1", force=True) + item = _lease_item(sessions_metadata_table) + assert "steerQueue" not in item + assert "steerFor" not in item + + await seed_steer_queue(resumed, [{"id": "carried", "text": "from the pause"}]) + assert [e["id"] for e in await peek_steer_queue(resumed)] == ["carried"] + + @pytest.mark.asyncio + async def test_a_retried_resume_seeds_fresh(self, sessions_metadata_table): + """The acquire→seed order in the route is load-bearing. + + Acquire REMOVEs the inbox, so a duplicate resume's own seed is what + applies — the previous attempt's entries cannot linger and be injected + alongside them. + """ + lease = await acquire_session_lease("s1", "u1") + await seed_steer_queue(lease, [{"id": "carried", "text": "from the pause"}]) + + retried = await acquire_session_lease("s1", "u1", force=True) + assert await peek_steer_queue(retried) == [] + + await seed_steer_queue(retried, [{"id": "carried", "text": "from the pause"}]) + assert [e["id"] for e in await peek_steer_queue(retried)] == ["carried"] diff --git a/backend/tests/shared/test_usage_normalization.py b/backend/tests/shared/test_usage_normalization.py new file mode 100644 index 000000000..12cc96bfb --- /dev/null +++ b/backend/tests/shared/test_usage_normalization.py @@ -0,0 +1,339 @@ +"""Unit tests for provider-aware token-usage normalization. + +The invariant under test is the one ``CostCalculator`` and the context-size +sum in the stream coordinator both depend on: ``inputTokens``, +``cacheReadInputTokens`` and ``cacheWriteInputTokens`` are **disjoint**, and +their sum is the call's total input. + +Bedrock Converse already satisfies it. The OpenAI family does not — per AWS's +GPT-5.6 prompt-caching guidance ``input_tokens = cached_tokens + +cache_write_tokens + non-cached remainder`` — so its usage is rewritten at the +model seam. + +The SDK-shape tests below deliberately drive the *real* ``strands`` model +classes with *real* ``openai`` usage objects rather than hand-rolled stubs. A +stub that merely matches the broken behavior would hide the bug, and the whole +mapping hangs off two SDK details (the chunk-formatter method name, and the +fact that Strands drops ``cache_write_tokens``) that a version bump can move. +""" + +import pytest + +from apis.shared.models.usage_normalization import ( + UsageProvider, + normalize_usage, + openai_cache_write_tokens, + usage_normalized, +) + + +# Wire-shaped usage payloads, as the provider returns them. +# +# GPT-5.6 Responses: a 30k stable prefix served from cache, a 400-token +# increment written to cache, ~100 tokens of genuinely new input. +OPENAI_RESPONSES_WIRE_USAGE = { + "input_tokens": 30_500, + "input_tokens_details": {"cached_tokens": 30_000, "cache_write_tokens": 400}, + "output_tokens": 120, + "output_tokens_details": {"reasoning_tokens": 64}, + "total_tokens": 30_620, +} + +# GPT-5.4 Chat Completions: implicit caching only, no write bucket exists. +OPENAI_CHAT_WIRE_USAGE = { + "prompt_tokens": 5_000, + "completion_tokens": 50, + "total_tokens": 5_050, + "prompt_tokens_details": {"cached_tokens": 4_096}, +} + + +def _assert_disjoint(usage: dict) -> None: + """Assert the three input buckets partition the call's total input.""" + total_input = ( + (usage.get("inputTokens") or 0) + + (usage.get("cacheReadInputTokens") or 0) + + (usage.get("cacheWriteInputTokens") or 0) + ) + assert total_input == (usage["totalTokens"] - usage["outputTokens"]), ( + "input buckets must partition total input; overlapping buckets are " + "double-billed by CostCalculator" + ) + + +class TestNormalizeUsageDisjointInvariant: + """The per-provider contract: what gets rewritten and what must not.""" + + def test_bedrock_usage_is_already_disjoint_and_untouched(self): + # Converse reports the three buckets pre-partitioned. + usage = { + "inputTokens": 100, + "outputTokens": 120, + "totalTokens": 30_620, + "cacheReadInputTokens": 30_000, + "cacheWriteInputTokens": 400, + } + _assert_disjoint(usage) + + normalized = normalize_usage(usage, UsageProvider.BEDROCK) + + assert normalized == usage + _assert_disjoint(normalized) + + def test_openai_usage_is_normalized_to_disjoint(self): + # What Strands hands us for GPT-5.6 once cache_write_tokens is mapped: + # inputTokens is the *inclusive* total. + usage = { + "inputTokens": 30_500, + "outputTokens": 120, + "totalTokens": 30_620, + "cacheReadInputTokens": 30_000, + "cacheWriteInputTokens": 400, + } + with pytest.raises(AssertionError): + _assert_disjoint(usage) + + normalized = normalize_usage(usage, UsageProvider.OPENAI) + + assert normalized["inputTokens"] == 100 + assert normalized["cacheReadInputTokens"] == 30_000 + assert normalized["cacheWriteInputTokens"] == 400 + _assert_disjoint(normalized) + + def test_openai_read_only_call_subtracts_only_the_read_bucket(self): + usage = { + "inputTokens": 5_000, + "outputTokens": 50, + "totalTokens": 5_050, + "cacheReadInputTokens": 4_096, + } + normalized = normalize_usage(usage, UsageProvider.OPENAI) + + assert normalized["inputTokens"] == 904 + _assert_disjoint(normalized) + + def test_openai_uncached_call_is_unchanged(self): + usage = {"inputTokens": 1_000, "outputTokens": 50, "totalTokens": 1_050} + + assert normalize_usage(usage, UsageProvider.OPENAI) == usage + + def test_negative_result_is_clamped_at_zero(self): + # Guards against the upstream reporting bug where the cache buckets + # summed to more than the inclusive total: a negative bucket would + # credit dollars back against the bill. + usage = { + "inputTokens": 4_583, + "outputTokens": 10, + "totalTokens": 4_593, + "cacheReadInputTokens": 3_945, + "cacheWriteInputTokens": 4_580, + } + assert normalize_usage(usage, UsageProvider.OPENAI)["inputTokens"] == 0 + + def test_none_valued_buckets_are_treated_as_zero(self): + usage = { + "inputTokens": None, + "outputTokens": 10, + "totalTokens": 10, + "cacheReadInputTokens": None, + "cacheWriteInputTokens": None, + } + assert normalize_usage(usage, UsageProvider.OPENAI) == usage + + def test_returns_a_copy_and_never_mutates_the_input(self): + usage = { + "inputTokens": 30_500, + "outputTokens": 120, + "totalTokens": 30_620, + "cacheReadInputTokens": 30_000, + } + normalized = normalize_usage(usage, UsageProvider.OPENAI) + + assert normalized is not usage + assert usage["inputTokens"] == 30_500 + + +class TestOpenAICacheWriteExtraction: + """`cache_write_tokens` recovery, against the real openai usage models.""" + + def test_reads_from_input_tokens_details(self): + from openai.types.responses.response_usage import ResponseUsage + + usage_obj = ResponseUsage.model_validate(OPENAI_RESPONSES_WIRE_USAGE) + + assert openai_cache_write_tokens(usage_obj) == 400 + + def test_reads_from_top_level_when_a_gateway_hoists_it(self): + from openai.types.responses.response_usage import ResponseUsage + + payload = dict(OPENAI_RESPONSES_WIRE_USAGE) + payload["input_tokens_details"] = {"cached_tokens": 30_000} + payload["cache_write_tokens"] = 400 + + assert openai_cache_write_tokens(ResponseUsage.model_validate(payload)) == 400 + + def test_absent_field_returns_none(self): + from openai.types.completion_usage import CompletionUsage + + usage_obj = CompletionUsage.model_validate(OPENAI_CHAT_WIRE_USAGE) + + assert openai_cache_write_tokens(usage_obj) is None + + def test_none_usage_object_returns_none(self): + assert openai_cache_write_tokens(None) is None + + def test_bool_is_rejected_rather_than_coerced(self): + class _Usage: + cache_write_tokens = True + + assert openai_cache_write_tokens(_Usage()) is None + + +class TestStrandsSdkContract: + """Pin the two SDK facts the shim is built on. + + If a Strands bump breaks either, these fail loudly here rather than + silently double-billing every OpenAI-family token in production. + """ + + def test_chunk_formatter_seams_still_exist(self): + from strands.models import OpenAIResponsesModel + from strands.models.openai import OpenAIModel + + # The Responses model formats through a private seam, the Chat + # Completions model through a public one. usage_normalized() picks + # whichever exists; it raises TypeError if neither does. + assert hasattr(OpenAIResponsesModel, "_format_chunk") + assert hasattr(OpenAIModel, "format_chunk") + + def test_sdk_still_reports_inclusive_input_and_drops_cache_writes(self): + """The bug this module exists to fix, asserted against the real SDK.""" + from openai.types.responses.response_usage import ResponseUsage + from strands.models import OpenAIResponsesModel + + model = OpenAIResponsesModel( + bedrock_mantle_config={"region": "us-west-2"}, + model_id="openai.gpt-5.6-sol", + ) + usage_obj = ResponseUsage.model_validate(OPENAI_RESPONSES_WIRE_USAGE) + + raw = model._format_chunk({"chunk_type": "metadata", "data": usage_obj}) + usage = raw["metadata"]["usage"] + + # inputTokens is the inclusive total, not the uncached remainder... + assert usage["inputTokens"] == 30_500 + assert usage["cacheReadInputTokens"] == 30_000 + # ...and the write bucket never makes it out of the SDK. + assert "cacheWriteInputTokens" not in usage + + with pytest.raises(AssertionError): + _assert_disjoint(usage) + + def test_usage_normalized_raises_when_the_seam_disappears(self): + class _Seamless: + pass + + with pytest.raises(TypeError, match="chunk-formatting seam moved"): + usage_normalized(_Seamless) + + +class TestUsageNormalizedModelClass: + """End-to-end through the wrapped model classes.""" + + def test_responses_model_emits_disjoint_usage_with_cache_writes(self): + from openai.types.responses.response_usage import ResponseUsage + from strands.models import OpenAIResponsesModel + + model = usage_normalized(OpenAIResponsesModel)( + bedrock_mantle_config={"region": "us-west-2"}, + model_id="openai.gpt-5.6-sol", + ) + usage_obj = ResponseUsage.model_validate(OPENAI_RESPONSES_WIRE_USAGE) + + chunk = model._format_chunk({"chunk_type": "metadata", "data": usage_obj}) + usage = chunk["metadata"]["usage"] + + assert usage["inputTokens"] == 100 + assert usage["cacheReadInputTokens"] == 30_000 + assert usage["cacheWriteInputTokens"] == 400 + _assert_disjoint(usage) + + def test_chat_completions_model_emits_disjoint_usage(self): + from openai.types.completion_usage import CompletionUsage + from strands.models.openai import OpenAIModel + + model = usage_normalized(OpenAIModel)( + client_args={"api_key": "test-key"}, model_id="openai.gpt-5.4" + ) + usage_obj = CompletionUsage.model_validate(OPENAI_CHAT_WIRE_USAGE) + + chunk = model.format_chunk({"chunk_type": "metadata", "data": usage_obj}) + usage = chunk["metadata"]["usage"] + + assert usage["inputTokens"] == 904 + assert usage["cacheReadInputTokens"] == 4_096 + # No write bucket exists on Chat Completions — don't invent one. + assert "cacheWriteInputTokens" not in usage + _assert_disjoint(usage) + + def test_non_metadata_chunks_pass_through_untouched(self): + from strands.models import OpenAIResponsesModel + + model = usage_normalized(OpenAIResponsesModel)( + bedrock_mantle_config={"region": "us-west-2"}, + model_id="openai.gpt-5.6-sol", + ) + chunk = model._format_chunk( + {"chunk_type": "content_delta", "data_type": "text", "data": "hi"} + ) + + assert chunk == {"contentBlockDelta": {"delta": {"text": "hi"}}} + + def test_subclass_is_memoized_and_keeps_isinstance(self): + from strands.models import OpenAIResponsesModel + + first = usage_normalized(OpenAIResponsesModel) + second = usage_normalized(OpenAIResponsesModel) + + assert first is second + assert issubclass(first, OpenAIResponsesModel) + + def test_non_class_passes_through(self): + """`unittest.mock.patch` swaps a class for a non-type; don't crash.""" + from unittest.mock import MagicMock + + mock_cls = MagicMock() + + assert usage_normalized(mock_cls) is mock_cls + + +class TestBuildMantleModelInstallsNormalization: + """The shared builder is the seam both `agents/` and `app_api` go through.""" + + def test_responses_mode_model_is_normalized(self): + from strands.models import OpenAIResponsesModel + + from apis.shared.models.mantle import MantleApiMode, build_mantle_model + + model = build_mantle_model( + model_id="openai.gpt-5.6-sol", + api_mode=MantleApiMode.RESPONSES, + region="us-west-2", + ) + + assert isinstance(model, OpenAIResponsesModel) + assert type(model) is usage_normalized(OpenAIResponsesModel) + + def test_chat_mode_model_is_normalized(self): + from strands.models.openai import OpenAIModel + + from apis.shared.models.mantle import MantleApiMode, build_mantle_model + + model = build_mantle_model( + model_id="openai.gpt-oss-120b", + api_mode=MantleApiMode.CHAT_COMPLETIONS, + region="us-west-2", + ) + + assert isinstance(model, OpenAIModel) + assert type(model) is usage_normalized(OpenAIModel) diff --git a/backend/uv.lock b/backend/uv.lock index 71d220092..29576f9dc 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -12,7 +12,7 @@ resolution-markers = [ [[package]] name = "agentcore-stack" -version = "1.17.0" +version = "1.18.0" source = { editable = "." } dependencies = [ { name = "aiofiles" }, @@ -59,6 +59,7 @@ all = [ { name = "mypy" }, { name = "numpy" }, { name = "openai" }, + { name = "pandas" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -77,6 +78,7 @@ dev = [ { name = "moto", extra = ["cognitoidp", "dynamodb"] }, { name = "mypy" }, { name = "numpy" }, + { name = "pandas" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -89,7 +91,7 @@ dev = [ requires-dist = [ { name = "agentcore-stack", extras = ["agentcore", "bidi", "dev"], marker = "extra == 'all'" }, { name = "aiofiles", specifier = "==25.1.0" }, - { name = "aiohttp", specifier = "==3.14.1" }, + { name = "aiohttp", specifier = "==3.14.3" }, { name = "authlib", specifier = "==1.7.1" }, { name = "aws-bedrock-token-generator", marker = "extra == 'agentcore'", specifier = "==1.1.0" }, { name = "aws-opentelemetry-distro", marker = "extra == 'agentcore'", specifier = "==0.19.0" }, @@ -98,7 +100,7 @@ requires-dist = [ { name = "black", marker = "extra == 'dev'", specifier = "==26.3.1" }, { name = "boto3", specifier = "==1.43.68" }, { name = "cachetools", specifier = "==6.2.4" }, - { name = "cryptography", specifier = "==48.0.1" }, + { name = "cryptography", specifier = "==50.0.1" }, { name = "fastapi", specifier = "==0.136.1" }, { name = "google-genai", marker = "extra == 'agentcore'", specifier = "==1.73.1" }, { name = "httpx", specifier = "==0.28.1" }, @@ -108,6 +110,7 @@ requires-dist = [ { name = "mypy", marker = "extra == 'dev'", specifier = "==1.20.2" }, { name = "numpy", marker = "extra == 'dev'", specifier = "==2.2.6" }, { name = "openai", marker = "extra == 'agentcore'", specifier = "==2.32.0" }, + { name = "pandas", marker = "extra == 'dev'", specifier = "==2.3.3" }, { name = "pillow", specifier = "==12.3.0" }, { name = "pyasn1", specifier = "==0.6.4" }, { name = "pyjwt", extras = ["crypto"], specifier = "==2.13.0" }, @@ -151,7 +154,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.14.1" +version = "3.14.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -164,126 +167,126 @@ dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/67/58ded4b3f2e10f94972d8928050c85330e249a31dd45a0e5f3c0e9c3fa05/aiohttp-3.14.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8f6bb621e5863cfe8fe5ff5468002d200ec31f30f1280b259dc505b02595099e", size = 766140, upload-time = "2026-06-07T21:05:37.471Z" }, - { url = "https://files.pythonhosted.org/packages/18/68/4ae5b4e08943f316594bb68da89957d3baf5760588fa09509594bd777e4b/aiohttp-3.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4f7215cb3933784f79ed20e5f050e15984f390424339b22375d5a53c933a0491", size = 519430, upload-time = "2026-06-07T21:05:40.751Z" }, - { url = "https://files.pythonhosted.org/packages/cb/c1/316c8f3549dbe5245f92bfd523ec6f32dd4d98cafe21df3f6a19b1184c75/aiohttp-3.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d9d4e294455b23a68c9b8f042d0e8e377a265bcb15332753695f6e5b6819e0ce", size = 514406, upload-time = "2026-06-07T21:05:42.111Z" }, - { url = "https://files.pythonhosted.org/packages/5a/ee/fb0ac28684e8d753b83c8a4eebc19a5846912aa0a4daaabb6a9936363840/aiohttp-3.14.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b238af795833d5731d049d82bc84b768ae6f8f97f0495963b3ed9935c5901cc3", size = 1703649, upload-time = "2026-06-07T21:05:43.427Z" }, - { url = "https://files.pythonhosted.org/packages/3b/57/aa2beab673331f111885db8a7b69dfe3ab0e53e446a0ace18ca694b4dc58/aiohttp-3.14.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4e5e0ae56914ecdbf446493addefc0159053dd53962cef37d7839f37f73d505", size = 1675126, upload-time = "2026-06-07T21:05:44.897Z" }, - { url = "https://files.pythonhosted.org/packages/47/ea/dad128abe365e79be03b16ed464198ac73e0d257e8260c6f7d6f31cbef26/aiohttp-3.14.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:092e4ce3619a7c6dee52a6bdabda973d9b34b66781f840ce93c7e0cec30cf521", size = 1771558, upload-time = "2026-06-07T21:05:46.405Z" }, - { url = "https://files.pythonhosted.org/packages/63/f3/b5b4e10327cb85d34d24232c6b71b64602f190b3ccb238a043ac6b187dac/aiohttp-3.14.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb33777ea21e8b7ecde0e6fc84f598be0a1192eab1a63bc746d75aa75d38e7bd", size = 1856631, upload-time = "2026-06-07T21:05:47.844Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9d/93294c3045775c708ac8310eb3d3622a11d2951345ad590d532d62a1faa4/aiohttp-3.14.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23119f8fd4f5d16902ed459b63b100bcd269628075162bddac56cc7b5273b3fb", size = 1714139, upload-time = "2026-06-07T21:05:49.982Z" }, - { url = "https://files.pythonhosted.org/packages/29/c4/93067c85a0373492ce8e577435203c5947c454af074ac48ed4f3a1b9dd4a/aiohttp-3.14.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:57fc6745a4b7d0f5a9eb4f40a69718be6c0bc1b8368cc9fe89e90118719f4f42", size = 1588321, upload-time = "2026-06-07T21:05:51.431Z" }, - { url = "https://files.pythonhosted.org/packages/c4/39/9ff91aaf02af8b7b8222a987466da539f154c3e01732c22b5f5a20a8ee66/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6fd35beba67c4183b09375c5fff9accb47524191a244a99f95fd4472f5402c2b", size = 1670375, upload-time = "2026-06-07T21:05:53.109Z" }, - { url = "https://files.pythonhosted.org/packages/aa/e4/77452a3676b8d99ac1375f77691d6bf65ea6e9f4b201b82ef77c916dc767/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:672b9d65f42eb877f5c3f234a4547e4e1a226ca8c2eed879bb34670a0ce51192", size = 1690933, upload-time = "2026-06-07T21:05:54.902Z" }, - { url = "https://files.pythonhosted.org/packages/7d/84/b0059a7c7fc05ea23f3bc1596ba91c12f79588b9450564a24cac37536d0a/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:24ba13339fed9251d9b1a1bec8c7ab84c0d1675d79d33501e11f94f8b9a84e05", size = 1740798, upload-time = "2026-06-07T21:05:56.458Z" }, - { url = "https://files.pythonhosted.org/packages/8f/3a/e2a513ecbfc362591caa51a7f7e011b3bfc8938b388ae44cd95560d36999/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:94da27378da0610e341c4d30de29a191672683cc82b8f9556e8f7c7212a020fe", size = 1576412, upload-time = "2026-06-07T21:05:57.953Z" }, - { url = "https://files.pythonhosted.org/packages/a1/10/08f1654f538f93d36dcac66310a06eefce4641cdafca83f9f0a5317be254/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:52cdac9432d8b4a719f35094a818d95adcae0f0b4fe9b9b921909e0c87de9e7d", size = 1750199, upload-time = "2026-06-07T21:05:59.488Z" }, - { url = "https://files.pythonhosted.org/packages/99/e4/d91b70c57d8b8e9611e4a2e52238ca3698d3dc1c2efe25b7a9bf594ac584/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:672ac254412a24d0d0cf00a9e6c238877e4be5e5fa2d188832c1244f45f31966", size = 1699356, upload-time = "2026-06-07T21:06:01.131Z" }, - { url = "https://files.pythonhosted.org/packages/3d/f1/15340176f35ff61b95dbe34020bcf43f9e624a2d7bbac934715ff97d2033/aiohttp-3.14.1-cp310-cp310-win32.whl", hash = "sha256:2fe3607e71acc6ebb0ec8e492a247bf7a291226192dc0084236dfc12478916f6", size = 458939, upload-time = "2026-06-07T21:06:02.86Z" }, - { url = "https://files.pythonhosted.org/packages/c3/c2/a2f1ec5b37f903109e43ae2862268cfe4a67a60c1b2cf43169fcdff5995f/aiohttp-3.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:30099eda75a53c32efb0920e9c33c195314d2cc1c680fbfd30894932ac5f27df", size = 482583, upload-time = "2026-06-07T21:06:04.666Z" }, - { url = "https://files.pythonhosted.org/packages/d0/7a/7b56f6732ef79530afaa72aa335d41b67c8d79b946995f0b11ad72985435/aiohttp-3.14.1-cp310-cp310-win_arm64.whl", hash = "sha256:5a837f49d901f9e368651b676912bff1104ed8c1a83b280bcd7b29adccef5c9c", size = 453470, upload-time = "2026-06-07T21:06:06.322Z" }, - { url = "https://files.pythonhosted.org/packages/26/dd/bf526e6f0a1120dd6f2df2e97bacfe4d358f13d17a0ff5847301a1375a51/aiohttp-3.14.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa00140699487bd435fde4342d85c94cb256b7cd3a5b9c3396c67f19922afda2", size = 765225, upload-time = "2026-06-07T21:06:07.957Z" }, - { url = "https://files.pythonhosted.org/packages/8f/e1/a2872aa55495a70f61310d411541c6ee23812d9a884e000c716e1bc3edbf/aiohttp-3.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c1af67559445498b502030c35c59db59966f47041ca9de5b4e707f86bd10b5f", size = 518743, upload-time = "2026-06-07T21:06:09.749Z" }, - { url = "https://files.pythonhosted.org/packages/5b/e7/c60c7b209e509cc787de3cea0550a518538cfc08003e1c1e14c1c63fff71/aiohttp-3.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d44ec478e713ee7f29b439f7eb8dc2b9d4079e11ae114d2c2ac3d5daf30516c8", size = 514139, upload-time = "2026-06-07T21:06:11.26Z" }, - { url = "https://files.pythonhosted.org/packages/5b/8d/614ace2f579702c9840ab1e1447fd8509e35b0b904f7196418fa2f57b25d/aiohttp-3.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3b1a184a9a8f548a6b73f1e26b96b052193e4b3175ed7342aaf1151a1f00a04", size = 1784088, upload-time = "2026-06-07T21:06:12.887Z" }, - { url = "https://files.pythonhosted.org/packages/49/e0/726e90f99542bf292f81a96a12cc4847deb86f3ccf62c6f4014a201f4d33/aiohttp-3.14.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5f2504bc0322437c9a1ff6d3333ca56c7477b727c995f036b976ae17b98372c8", size = 1737835, upload-time = "2026-06-07T21:06:14.564Z" }, - { url = "https://files.pythonhosted.org/packages/0b/4b/d176d5c4db9d33dacf0543102ea59503bc1d528af4cfd0b719949ca49389/aiohttp-3.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73f05ea02013e02512c3bf42714f1208c57168c779cc6fe23516e4543089d0a6", size = 1842801, upload-time = "2026-06-07T21:06:16.228Z" }, - { url = "https://files.pythonhosted.org/packages/dc/d6/5a99b563690ea0cbed912ae94a2ce33993a5709a651a3a4fe761e7dd973a/aiohttp-3.14.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:797457503c2d426bee06eef808d07b31ede30b65e054444e7de64cad0061b7af", size = 1929992, upload-time = "2026-06-07T21:06:17.947Z" }, - { url = "https://files.pythonhosted.org/packages/76/7f/a987b14a3859094b3cea3f4825219c3e5536242564af6e3f9c2f6c994eb2/aiohttp-3.14.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b821a1f7dedf7e37450654e620038ac3b2e81e8fa6ea269337e97101978ec730", size = 1786989, upload-time = "2026-06-07T21:06:19.677Z" }, - { url = "https://files.pythonhosted.org/packages/f1/1a/420e5c85a3e73349372ed22ce0b6af86bfa6ce16a4b20a64a2e94608c781/aiohttp-3.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4cd96b5ba05d67ed0cf00b5b405c8cd99586d8e3481e8ee0a831057591af7621", size = 1640129, upload-time = "2026-06-07T21:06:22.558Z" }, - { url = "https://files.pythonhosted.org/packages/a7/80/18a592ed3be0a402cc03670bd72ee1f8563ddbe1d8d5542dbf868f274136/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d459b98a932296c6f0e94f87511a0b1b90a8a02c30a50e60a297619cd5a58ee", size = 1756576, upload-time = "2026-06-07T21:06:24.8Z" }, - { url = "https://files.pythonhosted.org/packages/ec/0b/8b3d5713373858ff71a617daf6e3b0e81ad63e79d09a3cf2f6b6b983939c/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:764457a7be60825fb770a644852ff717bcbb5042f189f2bd16df61a81b3f6573", size = 1754668, upload-time = "2026-06-07T21:06:26.528Z" }, - { url = "https://files.pythonhosted.org/packages/9f/49/fd564575cf225821d7ba5a117cb8bc27213d8a7e1811162afb43ae077039/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f7a16ef45b081454ef844502d87a848876c490c4cb5c650c230f6ec79ed2c1e7", size = 1817019, upload-time = "2026-06-07T21:06:28.297Z" }, - { url = "https://files.pythonhosted.org/packages/ed/1b/e850c9ae6fc91356552ae668bb6c51e93fa29c8aef13398a10b56678557f/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2fbc3ed048b3475b9f0cbcb9978e9d2d3511acd91ead203af26ed9f0056004cf", size = 1631638, upload-time = "2026-06-07T21:06:30.242Z" }, - { url = "https://files.pythonhosted.org/packages/eb/94/3c337ba72451a89806ace6f75bddc92bafc5b8d53d90115a512858024b63/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bedb0cd073cc2dc035e30aeb99444389d3cd2113afe4ef9fcd23d439f5bade85", size = 1835660, upload-time = "2026-06-07T21:06:31.943Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9c/9c18cf367a0498212d9ba7daf990b504a5e8ae064cda4b504e2647c89c03/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b6feea921016eb3d4e04d65fc4e9ca402d1a3801f562aef94989f54694917af3", size = 1775698, upload-time = "2026-06-07T21:06:33.72Z" }, - { url = "https://files.pythonhosted.org/packages/b5/63/a251a9d2a6cb45065b2ddc0bde2b3dd10108740a9a42f632c66405a761a2/aiohttp-3.14.1-cp311-cp311-win32.whl", hash = "sha256:313701e488100074ce99850404ee36e741abf6330179fec908a1944ecf570126", size = 458386, upload-time = "2026-06-07T21:06:35.279Z" }, - { url = "https://files.pythonhosted.org/packages/17/ca/69274c51dcd6e8947d77b2806cf47a4a15f2c846e2cbeb1882547d3da283/aiohttp-3.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:03ab4530fdcb3a543a122ba4b65ac9919da9fe9f78a03d328a6e38ff962f7aa5", size = 483406, upload-time = "2026-06-07T21:06:36.824Z" }, - { url = "https://files.pythonhosted.org/packages/2c/8a/c25904f77690c3688ec140f87591ef11a0cfe36bf3d5c0f1f38056fb62b3/aiohttp-3.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:486f7d16ed54c39c2cbd7ca71fd8ba2b8bb7860df65bd7b6ed640bab96a38a8b", size = 452987, upload-time = "2026-06-07T21:06:38.371Z" }, - { url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402, upload-time = "2026-06-07T21:06:40.311Z" }, - { url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310, upload-time = "2026-06-07T21:06:42.207Z" }, - { url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448, upload-time = "2026-06-07T21:06:43.813Z" }, - { url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854, upload-time = "2026-06-07T21:06:45.391Z" }, - { url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884, upload-time = "2026-06-07T21:06:47.413Z" }, - { url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034, upload-time = "2026-06-07T21:06:50.165Z" }, - { url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054, upload-time = "2026-06-07T21:06:52.035Z" }, - { url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278, upload-time = "2026-06-07T21:06:54.049Z" }, - { url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795, upload-time = "2026-06-07T21:06:55.911Z" }, - { url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397, upload-time = "2026-06-07T21:06:57.777Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504, upload-time = "2026-06-07T21:07:00.009Z" }, - { url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806, upload-time = "2026-06-07T21:07:02.064Z" }, - { url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707, upload-time = "2026-06-07T21:07:03.942Z" }, - { url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121, upload-time = "2026-06-07T21:07:05.987Z" }, - { url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580, upload-time = "2026-06-07T21:07:07.873Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771, upload-time = "2026-06-07T21:07:09.669Z" }, - { url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873, upload-time = "2026-06-07T21:07:11.538Z" }, - { url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073, upload-time = "2026-06-07T21:07:13.637Z" }, - { url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" }, - { url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" }, - { url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" }, - { url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" }, - { url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" }, - { url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" }, - { url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" }, - { url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" }, - { url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" }, - { url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" }, - { url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" }, - { url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" }, - { url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" }, - { url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" }, - { url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" }, - { url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" }, - { url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" }, - { url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" }, - { url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" }, - { url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" }, - { url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" }, - { url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" }, - { url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" }, - { url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" }, - { url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" }, - { url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" }, - { url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" }, - { url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" }, - { url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" }, - { url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" }, - { url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" }, - { url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" }, - { url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" }, - { url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" }, - { url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" }, - { url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" }, - { url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" }, - { url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" }, - { url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" }, - { url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" }, - { url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" }, - { url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" }, - { url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" }, - { url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" }, - { url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" }, - { url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" }, - { url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" }, - { url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" }, - { url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" }, - { url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" }, - { url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" }, - { url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" }, - { url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" }, - { url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" }, - { url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" }, - { url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" }, - { url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" }, - { url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" }, - { url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" }, - { url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" }, - { url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" }, - { url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/4d/4a99fb425c5e0cad715eea7bd190aff46f38b959a0a2dadb993705d34b26/aiohttp-3.14.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:eb0495d778817619273c108784292be161a924b9f5ae5cbbc70a2caa6838250b", size = 765848, upload-time = "2026-07-23T01:52:08.217Z" }, + { url = "https://files.pythonhosted.org/packages/74/e8/43b85dc55b8e950dc644babe762add781319ea881b57b33d2cce12017d12/aiohttp-3.14.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c3c200cf9757edd785051dc699c7ecbec22110dbfcb3fefc7a9f9695eda8ea7a", size = 517476, upload-time = "2026-07-23T01:52:10.846Z" }, + { url = "https://files.pythonhosted.org/packages/7f/9e/73b582c4dbbc3c12ef4473822475effaabf1f934b56f14f5b03fe5d3a2af/aiohttp-3.14.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd51ebf9d3a00c074df4ede271023f4d2dba289bcc740b88191872716014e3c5", size = 515334, upload-time = "2026-07-23T01:52:12.636Z" }, + { url = "https://files.pythonhosted.org/packages/79/03/e98c3c9e05a5bdf97defe5ff9169baba4f0ec9a901f2d60e0f060c2f051e/aiohttp-3.14.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:134ac5ddcf61c6fad984b9a5727d83492ada43d63471db20fb73042c13fca62f", size = 1708830, upload-time = "2026-07-23T01:52:14.538Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2c/26e60b694844dfd2176c57f913a22d0cd6a16f9ff202cbda7580d0328b98/aiohttp-3.14.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:70c987b27534f9ae1a723f47ae921571d616da21d3208282bf4c52af5164ac43", size = 1674012, upload-time = "2026-07-23T01:52:16.486Z" }, + { url = "https://files.pythonhosted.org/packages/38/65/672df92e3172cd876aacfa97a952ac560877eb169384b2991ac5b273de4c/aiohttp-3.14.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1b59533861b70a2185c8f4f350f791f39d64358ef6944ce71c5240c9ec0982c9", size = 1767015, upload-time = "2026-07-23T01:52:18.28Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c5/228dec7bfec1c373cc2217cdeb47d6456dcd7a13a4c55144930a75ae3851/aiohttp-3.14.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1c5281acc88b92396f88c7e1e2748f8466689df22b80170e4f51efa712fb47a8", size = 1858700, upload-time = "2026-07-23T01:52:20.08Z" }, + { url = "https://files.pythonhosted.org/packages/bd/ff/cb36724e8c8d17f90ada567a9ff3efe1d6e9b549fba697a242aece180f21/aiohttp-3.14.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48d67b87db6279c044760787eb01f6413032c2e6f3ba1cafaa492b1c8e578479", size = 1714075, upload-time = "2026-07-23T01:52:22.071Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3a/296a4135c6366376263aeef54b15caca1f07676c2ae0c525d7832f2f808a/aiohttp-3.14.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f53bcd52f585e1ac3e590d61434eb61f9a88c38df041b4ea126d97144344a77b", size = 1588234, upload-time = "2026-07-23T01:52:23.757Z" }, + { url = "https://files.pythonhosted.org/packages/7d/81/9d5d853ef892dc066d1eb6db0e87a47348b920c1c879aa554612fdbd9d79/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0fdea2281997af69da84c77ffa6f5938a0285f21fb3887c249d67419ca865b3d", size = 1677300, upload-time = "2026-07-23T01:52:25.861Z" }, + { url = "https://files.pythonhosted.org/packages/68/96/021d386ae32d9b26d4b88df2e794546232ff56bb6be952bf6be227c0bbc7/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:cda5fd5c95ad7a125a2e8464acc78b98b94c475a3780d6aa0aa157c93f470f4d", size = 1691501, upload-time = "2026-07-23T01:52:28Z" }, + { url = "https://files.pythonhosted.org/packages/29/9f/af66adce26a14af135c003cbd0f44ccaa68cebd30ff8ac99ca47fb4958f7/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:6debfa7312ff9d4c124dc71d72e9a0a4b9e0879e48ba6fcb42bef5c3300289e2", size = 1735113, upload-time = "2026-07-23T01:52:29.995Z" }, + { url = "https://files.pythonhosted.org/packages/2f/90/28c390d4c9851effe52ac25b5a2e1d92246acd00728b4fc7975dafb67484/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f4e05329faa0ea1a404b37de4f034fd2c2defcca06a68dc6745e4e56c88e8a48", size = 1577486, upload-time = "2026-07-23T01:52:31.937Z" }, + { url = "https://files.pythonhosted.org/packages/db/c2/00e23a1bf2abb70dd353f6987db7e7f2491d0261f7363997738c71c98f95/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a3a8296e7ab5c295f53f1041487cb088e1480775aafbf7fe545d93b770a0f96f", size = 1751353, upload-time = "2026-07-23T01:52:33.688Z" }, + { url = "https://files.pythonhosted.org/packages/6e/7d/d51a706a8cbfa57f0611127daf61ab3ae02ab8420b0407412079227d1c65/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5373dc80ad1aa2fb9ad95c83f24eef418bbda3a61375f128e5b0192e4f3f9b32", size = 1698681, upload-time = "2026-07-23T01:52:38.167Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b0/90bd5cd9fdd9787cb4211d284d1fb8401339a933cb0227a15b71e789232f/aiohttp-3.14.3-cp310-cp310-win32.whl", hash = "sha256:a3e22975f905b89a55a488c2a08f2fdb2186175349e917d48985cc468a3d4c6e", size = 456733, upload-time = "2026-07-23T01:52:41.823Z" }, + { url = "https://files.pythonhosted.org/packages/d8/15/fe5b8f6a71ae112bc677163d0b0701bda5dc15005249582258ede0eb88c7/aiohttp-3.14.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdd0e2834dce1a26c1bbe26464861e16bbe217042cbff619247c11594472518c", size = 480460, upload-time = "2026-07-23T01:52:43.905Z" }, + { url = "https://files.pythonhosted.org/packages/54/00/45e98b6645cd7f00a4b78b749ebd309094b0eaeb2d2e96157eadbc0d0050/aiohttp-3.14.3-cp310-cp310-win_arm64.whl", hash = "sha256:eac645b09bcfdf73df7536331f0678c1086ea250981118ddb5199e17ccef72bb", size = 453479, upload-time = "2026-07-23T01:52:46.075Z" }, + { url = "https://files.pythonhosted.org/packages/f8/5c/b3e4ff8ad43a8afef9602c5e90285936da1beaea8b029016b793891f03c3/aiohttp-3.14.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e568e14940c09955aa51f4e645b6daa18a581c5dcfcd73744dcc86a856e3ced3", size = 764250, upload-time = "2026-07-23T01:52:48.525Z" }, + { url = "https://files.pythonhosted.org/packages/0e/da/f1b384465e51449d844056b75070461da03a9a23e6c1747003695bf4172a/aiohttp-3.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:54cfcdee2770dac994417cbb0ee1f3eb0e7cb6b30c79bf44f2c02ff79ec5124a", size = 516281, upload-time = "2026-07-23T01:52:51.047Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3f/01264f820ee2e3712a827892b1cd6ff80f3300c1fcbffbb45714a915d47a/aiohttp-3.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:21c016079415ed3fd676963e9793700a566d85dbbd6bfc564b9b2d209147dcc8", size = 514742, upload-time = "2026-07-23T01:52:53.779Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8d/a71c6f2db52ac1ed142b133f7feddaa6b70539c3f4de24d7e226c95b794c/aiohttp-3.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6088ec9894113802bddb3c09e974929aed2c7b3a8c456219b8aab4481f1a239", size = 1780613, upload-time = "2026-07-23T01:52:56.948Z" }, + { url = "https://files.pythonhosted.org/packages/a5/11/3dd9b3fb3a170f6ec9011b5291d876a6fab4086714c9e158600edf01b4fd/aiohttp-3.14.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:16ea7e24c309fb7c0bbd505d149abe4fe4dccfb8db911db7dbec0921bc889a6f", size = 1737688, upload-time = "2026-07-23T01:52:59.294Z" }, + { url = "https://files.pythonhosted.org/packages/6d/3e/834c26918be7d88068822b40e0db30fca50b5f4fe79104aa16a93f1d74e6/aiohttp-3.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56f355e79f71aef2a85c80305cc915f894b170dba76de5fe84f6351939b83c06", size = 1845742, upload-time = "2026-07-23T01:53:01.641Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c9/49ab8572df7d66bc13d11e31f781292badb04180dd87ba98733066c6aed7/aiohttp-3.14.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18c441d0a8fca6de8d1f546849b9f0ab20d435993e2c5b59562b2fae6be2f929", size = 1928412, upload-time = "2026-07-23T01:53:04.018Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b9/2b8f0c0ce09c87a1daf80fd483431b56b1435d3f62789bc86f572e1245de/aiohttp-3.14.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53e7b4ce82b54a8bcc71b3b67a5cbd177ca1d7f592cbc92cd38b7349f73482db", size = 1786220, upload-time = "2026-07-23T01:53:06.481Z" }, + { url = "https://files.pythonhosted.org/packages/85/00/9c45f81de11710460edfa1dc81317b6e882703b160926c879a9d20da9fcc/aiohttp-3.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f55119f7bf25f49ed210f6096090715da24f2943c62102448915fde3c62877ce", size = 1637231, upload-time = "2026-07-23T01:53:10.258Z" }, + { url = "https://files.pythonhosted.org/packages/19/ce/967d628e910756f3539c6107cb7844a1b69440dcb3029a5ee7871b09ab63/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9aa6e61fdf20105c4144e755bd586008ff450791d67b1c8146fdc15959c4d51c", size = 1753161, upload-time = "2026-07-23T01:53:13.817Z" }, + { url = "https://files.pythonhosted.org/packages/11/b2/0c3d4114f0aee4f580f5b3b4eb71b24d7a23b834ea506a4dfebe76513f35/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ccd4893707b3e2a13e39c90d43cf80edf2e4d0457935bcc103bf2346214c3f15", size = 1756356, upload-time = "2026-07-23T01:53:16.211Z" }, + { url = "https://files.pythonhosted.org/packages/63/5d/99e7d91c82f1399d1ae2a854e080bd1493fbc31e5e959dbc4ec33dac3bec/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2466434105a4e03113c36ec775cc2ebe6676b62eae326fa670bb607ef788c1c", size = 1819846, upload-time = "2026-07-23T01:53:18.289Z" }, + { url = "https://files.pythonhosted.org/packages/ad/05/d5e1cb6480eeffd3f901d40a2c5e2d1e7effdc797837da3b490272699f13/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ba59d59aba08ac02fc03b0c8983ccd5ee39a199d0552ce9e6d2b4845b34d59ae", size = 1628531, upload-time = "2026-07-23T01:53:23.86Z" }, + { url = "https://files.pythonhosted.org/packages/c9/90/b934682bcaefae18a9e04f3dff5b68522ba810906358ae5029b68110ea3b/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ed099d105449c4f9e84f24af203cd131349d4761d8813fa7e02c32e7128cd910", size = 1832712, upload-time = "2026-07-23T01:53:27.551Z" }, + { url = "https://files.pythonhosted.org/packages/21/df/6061679faaf81fac746e7307c7adb71e858071a5d34c27583afefc64f543/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:152516815ef926786a0b6ae2b8f1fd2e0c71582dee0b435636865316fd4891b7", size = 1775014, upload-time = "2026-07-23T01:53:30.223Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1d/f854878bbc69b88faefe924b619a34a6f59ec05fd387c77690667eaa75eb/aiohttp-3.14.3-cp311-cp311-win32.whl", hash = "sha256:a4af35c443e0b1a1bd6a8af3f3485d7fda15c142751a00f3ff8090f0b93346fa", size = 456006, upload-time = "2026-07-23T01:53:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/73/0c/2af9d1674baccd1dbd47282a93d660a22e57ef6167c856deb24b4214fbab/aiohttp-3.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:e1e74298bab6ee0d6e749ed4fd1901c7e604bdda32c03d787a2cc71c46d0433d", size = 481069, upload-time = "2026-07-23T01:53:39.673Z" }, + { url = "https://files.pythonhosted.org/packages/8e/76/88401ff3fc95e85c5fc38d588f36f55e61ecb64343b2bc8d69326f453cc0/aiohttp-3.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:03cd2bde3d7f085b64e549c985f4bb928cad7e8ecf5323bfca320db548d81b39", size = 453021, upload-time = "2026-07-23T01:53:43.749Z" }, + { url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" }, + { url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" }, + { url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" }, + { url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" }, + { url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" }, + { url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" }, + { url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" }, + { url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" }, + { url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" }, + { url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" }, + { url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" }, + { url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" }, + { url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" }, + { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" }, + { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" }, + { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" }, + { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" }, + { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" }, + { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" }, + { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" }, + { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" }, + { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" }, + { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" }, + { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" }, + { url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" }, + { url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" }, + { url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" }, + { url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" }, + { url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" }, + { url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" }, + { url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" }, + { url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" }, + { url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" }, + { url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" }, + { url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" }, + { url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" }, + { url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" }, + { url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" }, + { url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" }, + { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" }, ] [[package]] @@ -1043,62 +1046,59 @@ toml = [ [[package]] name = "cryptography" -version = "48.0.1" +version = "50.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/bc/ee4137cbbe105652c0ee4252792b78fc8e7afa4b8e61d9d5dc05a7f45731/cryptography-48.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1", size = 8008324, upload-time = "2026-06-09T22:31:00.702Z" }, - { url = "https://files.pythonhosted.org/packages/d5/85/6379d42181bfc713094f081360fc5784d6c816b599d45e7f082502d173ce/cryptography-48.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225", size = 4696243, upload-time = "2026-06-09T22:32:33.446Z" }, - { url = "https://files.pythonhosted.org/packages/9c/87/c85d147b53323c7eb4d850920c8901377323c2a0ff8d79c262d4fee89aa2/cryptography-48.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691", size = 4713235, upload-time = "2026-06-09T22:31:40.141Z" }, - { url = "https://files.pythonhosted.org/packages/79/58/67cbf8cf1ee7c54b439ca07bbecf8362c07afc11a3724fea70f745784add/cryptography-48.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242", size = 4702323, upload-time = "2026-06-09T22:31:42.191Z" }, - { url = "https://files.pythonhosted.org/packages/89/c6/24266ac10c47f6cd2a865f4446062b466da1d1f10b27189eac00e61bf0c9/cryptography-48.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08", size = 5300085, upload-time = "2026-06-09T22:31:58.703Z" }, - { url = "https://files.pythonhosted.org/packages/d2/bb/cc4b78784f97efc8c5874c2a9743708d172be6663024b34a0467885ae0c8/cryptography-48.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6", size = 4746137, upload-time = "2026-06-09T22:31:31.01Z" }, - { url = "https://files.pythonhosted.org/packages/1f/52/0c44de3f5267f8fbe8e835138017522a333436166e406f0db9b9e6e3033f/cryptography-48.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8", size = 4333867, upload-time = "2026-06-09T22:32:28.096Z" }, - { url = "https://files.pythonhosted.org/packages/9a/2e/772d7adbfa931537bc401640b7cac9976bff689bda187833e5d63b428e49/cryptography-48.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429", size = 4701805, upload-time = "2026-06-09T22:31:38.284Z" }, - { url = "https://files.pythonhosted.org/packages/f8/a3/b06844f303873493c963caf581c04df31c7035e0c1b0f02c4814d319ec80/cryptography-48.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f", size = 5258461, upload-time = "2026-06-09T22:31:04.187Z" }, - { url = "https://files.pythonhosted.org/packages/9f/13/8b765e2e12b07c74941caadb9d1c8fdc006c4dfbf2b8f2d610519758954d/cryptography-48.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f", size = 4745488, upload-time = "2026-06-09T22:32:30.07Z" }, - { url = "https://files.pythonhosted.org/packages/2e/aa/48972bce55049b32a94f4907eda4d75fa385aad8a39506cc2fc72196ecf0/cryptography-48.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41", size = 4830256, upload-time = "2026-06-09T22:31:43.868Z" }, - { url = "https://files.pythonhosted.org/packages/47/a2/e5079a032fb85cf6005046ca92bbd78b0c82dad2b5751ab8c311659da06f/cryptography-48.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6", size = 4979117, upload-time = "2026-06-09T22:31:05.845Z" }, - { url = "https://files.pythonhosted.org/packages/b7/a0/8f50cae9c74e718ed769d63ed5c74bd0ea830c9550a74629cebd1b9c7bc7/cryptography-48.0.1-cp311-abi3-win32.whl", hash = "sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158", size = 3304154, upload-time = "2026-06-09T22:32:16.845Z" }, - { url = "https://files.pythonhosted.org/packages/c5/69/0572c77dbace6fef72f33755bd52ea399c71367250d366237f8691826b9e/cryptography-48.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24", size = 3817138, upload-time = "2026-06-09T22:32:00.388Z" }, - { url = "https://files.pythonhosted.org/packages/42/06/3e768b4c3bc78201583fa35a0e18f640dd782ff41afba88f8545481a8874/cryptography-48.0.1-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:f817adc181390bd54f2f700107a7419040fb7c1bdf2fc26f36551a06a68c3345", size = 7989830, upload-time = "2026-06-09T22:31:07.8Z" }, - { url = "https://files.pythonhosted.org/packages/8a/13/6476736484b94041110c8340a3eb63962fea4975baea8cb4a512adb44d4d/cryptography-48.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d5d30989c6917b478b5817902e85fddaea2261efa8648383d965381ccb9e1ac4", size = 4689201, upload-time = "2026-06-09T22:31:09.745Z" }, - { url = "https://files.pythonhosted.org/packages/79/62/65a87f34d2a431546e2509b85d55e8c90df86d668f6731da64d538512ac2/cryptography-48.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:df637c05205ea7c1d7fbcbe54bbfea648a52951155f997af13d895d0ecc96991", size = 4702822, upload-time = "2026-06-09T22:32:24.409Z" }, - { url = "https://files.pythonhosted.org/packages/7f/59/810b5204b0a9b10f4b6bc06bd551a8b609803cd931806bc3b71884b225e5/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:869c3b8a53bfe27147832df48b32adadf558249d50e76cb3769d40e986b13265", size = 4694875, upload-time = "2026-06-09T22:32:08.737Z" }, - { url = "https://files.pythonhosted.org/packages/24/dc/d8ca05ffea724eec6d232ea6f18e74c269eb6bdfdcc9bfba689790d1325f/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:e361afba8918070d376df76f408a4f67fec0ee9cff81a99e48fe9a233ef59e17", size = 5290385, upload-time = "2026-06-09T22:31:15.212Z" }, - { url = "https://files.pythonhosted.org/packages/03/8c/3be6cb4da181f5bb6c19cf560c2359d60644a6b5fc5b57854e528f47b296/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d069066deead00ac7f090be101be875a06855908f7ec004c27b8fefb4acfb411", size = 4737082, upload-time = "2026-06-09T22:32:22.66Z" }, - { url = "https://files.pythonhosted.org/packages/aa/f6/d5f60a5a1434dbfd949e227fd0065d194c7e6b6ac526b17f5c06152b8231/cryptography-48.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:09f73a725d582cef64b91281a322cd798d14a33b2b6f2b7ad9531dc336d84c02", size = 4325328, upload-time = "2026-06-09T22:32:10.777Z" }, - { url = "https://files.pythonhosted.org/packages/17/b7/ba75dd947a14b6ad907b01ae8f6b5b348cdd1b48142f0063dee9e20c1d9d/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:15254441469dd6bf027039453288e2072124f8b6603563f5d759e1c9b69273fa", size = 4694530, upload-time = "2026-06-09T22:31:53.105Z" }, - { url = "https://files.pythonhosted.org/packages/62/29/50d6b9e8aff12d8b67afaeb3569335e32dc83a5723e3bbded24fdac9f809/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:8ace4507d1e6533c125f4fac754f8bb8b6a74c08e92179dabd7e16571a3efbf3", size = 5245046, upload-time = "2026-06-09T22:31:25.774Z" }, - { url = "https://files.pythonhosted.org/packages/9f/04/618f4115cfc0add0838c82507aa18a346089428da8653ad38b3ff36f5cb3/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b4e391975f038e66432328639620a4aff2d307513b004f1ca06d6225bced815c", size = 4736660, upload-time = "2026-06-09T22:32:12.676Z" }, - { url = "https://files.pythonhosted.org/packages/24/9c/06e062462a0de28a3b3911322eded4c16deb9f441b1b7575d3dc59488ab5/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42fcd8e26fe555d9b3577a135f5091fefa0aa4e99129c23fb56787a1bd4ada72", size = 4822229, upload-time = "2026-06-09T22:31:17.062Z" }, - { url = "https://files.pythonhosted.org/packages/f4/be/0561971eaaee4b8a0e7d5113c536921063ab91aaf23278ac374eaf881e11/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c1400da5e32a43253392277eac7490a60e497d810a63dd5608d71bbd7af507c9", size = 4966364, upload-time = "2026-06-09T22:31:32.842Z" }, - { url = "https://files.pythonhosted.org/packages/a4/27/728c77876f12b000820b69ae490f3c4083775e79e07827e9e60be07ad209/cryptography-48.0.1-cp314-cp314t-win32.whl", hash = "sha256:0df56b056bc17c1b7d6821dfa65216e62bd232d8ab05eb3db44e71d235651471", size = 3278498, upload-time = "2026-06-09T22:31:29.154Z" }, - { url = "https://files.pythonhosted.org/packages/06/e3/79a612c6d7b1e6ee0edd43633d53035bec2cfb78c82b76f7864f39e36f34/cryptography-48.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:9de21387aa95e2a895823d0745b430bed4f33503ba9ab5e0b5311f33e37d66d2", size = 3798790, upload-time = "2026-06-09T22:31:56.697Z" }, - { url = "https://files.pythonhosted.org/packages/ca/6c/00fa2a95997164c8b2072ce327c23d4ab20809ccc323ea5fab91e53a4bba/cryptography-48.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67", size = 7987408, upload-time = "2026-06-09T22:32:20.777Z" }, - { url = "https://files.pythonhosted.org/packages/b0/d9/45f309a7e4e5f3f8f121d6d3be9e94024a7726ec598d6e08ae04edb2f04d/cryptography-48.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8", size = 4690196, upload-time = "2026-06-09T22:31:54.74Z" }, - { url = "https://files.pythonhosted.org/packages/5f/9f/a1bc8bcc798811b8527eb374bbccf30a3f3e806829d967118222bf1125eb/cryptography-48.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a", size = 4696782, upload-time = "2026-06-09T22:31:45.615Z" }, - { url = "https://files.pythonhosted.org/packages/66/c2/81a4fb4e4373c500bb526bc337ac5719dd31dd15b970b84a238168c6aa08/cryptography-48.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577", size = 4696618, upload-time = "2026-06-09T22:31:11.564Z" }, - { url = "https://files.pythonhosted.org/packages/e5/0b/aa68b221dde92d09cb29a024ede17550ee21e77a404e59fc093c82bb51e1/cryptography-48.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1", size = 5289970, upload-time = "2026-06-09T22:31:20.368Z" }, - { url = "https://files.pythonhosted.org/packages/78/13/fba657f958d2af66ea959a4ba01212632089249d34af1ae48054136344d7/cryptography-48.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d", size = 4731873, upload-time = "2026-06-09T22:31:22.253Z" }, - { url = "https://files.pythonhosted.org/packages/4c/4c/9a964756d24a26b3e34dfcb16f961b89838786e6700b635b0d1e3adff4b6/cryptography-48.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6", size = 4330804, upload-time = "2026-06-09T22:31:36.56Z" }, - { url = "https://files.pythonhosted.org/packages/4b/0f/a10f3a6eb12950a10e3a874070283aa2dd5875b2bfd15fad8a3e17b3f13e/cryptography-48.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46", size = 4696217, upload-time = "2026-06-09T22:31:13.351Z" }, - { url = "https://files.pythonhosted.org/packages/f3/6f/5cd12f951165ea73ef85266775d97e4c763b2474ccfd816dd69d3a18d6f8/cryptography-48.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401", size = 5245252, upload-time = "2026-06-09T22:32:02.193Z" }, - { url = "https://files.pythonhosted.org/packages/68/ab/8aaa12e4516ec4464033ab79b6f3b592bd5a92102467c4ace8a0d970203f/cryptography-48.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b", size = 4731388, upload-time = "2026-06-09T22:32:04.019Z" }, - { url = "https://files.pythonhosted.org/packages/1b/24/50027ea4dca85ec1f40688f3c24fb32ccacd520583c9592c3cc95628e6fb/cryptography-48.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1", size = 4824186, upload-time = "2026-06-09T22:32:18.707Z" }, - { url = "https://files.pythonhosted.org/packages/52/41/04cb5eb17085ade6f50cc611fb657df6a0f5885350de8764ece89c050197/cryptography-48.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475", size = 4964539, upload-time = "2026-06-09T22:31:18.793Z" }, - { url = "https://files.pythonhosted.org/packages/36/bf/ed70785c496e89d7e73b7cda2d21f2447fd6d4e821714b8d04ff217fed92/cryptography-48.0.1-cp39-abi3-win32.whl", hash = "sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1", size = 3282307, upload-time = "2026-06-09T22:30:53.162Z" }, - { url = "https://files.pythonhosted.org/packages/b3/ff/371ea7d252656ee1eb6d83eeeef3d1d0c6baf1d6497687d081ea03814670/cryptography-48.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac", size = 3793408, upload-time = "2026-06-09T22:32:15.191Z" }, - { url = "https://files.pythonhosted.org/packages/a9/d3/eb4e394e587341fdad09a09101fa76478ead3a78b0ad63e55c22f0d75c02/cryptography-48.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:08a597acce1ff37f347400087776599e2348a3a8bc53b44120e463cd274efe4a", size = 3951747, upload-time = "2026-06-09T22:31:23.871Z" }, - { url = "https://files.pythonhosted.org/packages/e0/4a/3f43451b4f858bfceaaaffc649e6e787e8d4fb332a1d443af39ab02cc8f1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:735824ec41b7f74a7c45fb1591349333e4c696cb6c044e5f46356e560143e4cd", size = 4641226, upload-time = "2026-06-09T22:31:02.532Z" }, - { url = "https://files.pythonhosted.org/packages/73/4e/855584c2c23b09e4ce2d3b9c30e983e679cd60b068c513c6bbdb91e11782/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:92a46e1d638daa264ba2971c0b0489c9409787943efae4d60ffda3d091ef832c", size = 4668958, upload-time = "2026-06-09T22:32:06.213Z" }, - { url = "https://files.pythonhosted.org/packages/42/3b/d35750e41d803d1e516fd6d6011f065424924da7af1748cef4cc9cb3ede1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:7e234ac052af99f2700826a5c29ea99d9c1b1f80341cde62d11c8154dc8e0bd9", size = 4640793, upload-time = "2026-06-09T22:32:26.331Z" }, - { url = "https://files.pythonhosted.org/packages/ca/aa/cdb7181fe865285e87e96825aaab239400f1de0c3bfba9bd9769b79f1a92/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:33842cf0888951cef5bc7ac724ab844a42044c1727b967b7f8997289a0464f92", size = 4668505, upload-time = "2026-06-09T22:31:27.534Z" }, - { url = "https://files.pythonhosted.org/packages/5d/8c/ce3823c06c2804f194f9e64f0d67fa3f4094a39f2bb1a990cd03603af8fc/cryptography-48.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6184ca7b174f28d7c703f1290d4b297217c45355f77a98f67e9b7f14549ac54a", size = 3742204, upload-time = "2026-06-09T22:31:34.773Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" }, + { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" }, + { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" }, + { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" }, + { url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" }, + { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" }, + { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" }, + { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" }, + { url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" }, + { url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" }, + { url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" }, + { url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" }, + { url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" }, + { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" }, + { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" }, + { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" }, + { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" }, + { url = "https://files.pythonhosted.org/packages/c7/27/8d207af749c453ee17ea087340b3f2b4adef75aadd1d277b1b129bdda84e/cryptography-50.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9cb3cb952cf5a8abd50c782a98a89d71699715e802fe349704b47f2425b42a94", size = 3974350, upload-time = "2026-08-25T19:45:26.551Z" }, + { url = "https://files.pythonhosted.org/packages/14/9a/6d3a4d7852e22d657438b7bf51f66102c7d71c0e1fafeec652281d0403e5/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f", size = 4698675, upload-time = "2026-08-25T19:45:28.658Z" }, + { url = "https://files.pythonhosted.org/packages/73/35/5c3717edf9e68a0550ce04e28eab493fe545eccd81742af03f6a75fe260b/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671", size = 4707410, upload-time = "2026-08-25T19:45:30.816Z" }, + { url = "https://files.pythonhosted.org/packages/1d/e0/e786934472e3ac4ecdecc7b129a0ca1a2a40dffdafcf2c3ea9d4397f8def/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e", size = 4698378, upload-time = "2026-08-25T19:45:33.043Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/5b3f53a0b74d122f023476ede40ba5d3e70d5cf475f73b899740d26a4fb2/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6", size = 4706889, upload-time = "2026-08-25T19:45:35.086Z" }, + { url = "https://files.pythonhosted.org/packages/71/44/711e61f7d014be825ef79b285b047292d1bf893732ac1bc030a351fb517f/cryptography-50.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b", size = 3824006, upload-time = "2026-08-25T19:45:37.281Z" }, ] [[package]] @@ -3399,6 +3399,67 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] +[[package]] +name = "pandas" +version = "2.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/f7/f425a00df4fcc22b292c6895c6831c0c8ae1d9fac1e024d16f98a9ce8749/pandas-2.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:376c6446ae31770764215a6c937f72d917f214b43560603cd60da6408f183b6c", size = 11555763, upload-time = "2025-09-29T23:16:53.287Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/66d99628ff8ce7857aca52fed8f0066ce209f96be2fede6cef9f84e8d04f/pandas-2.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e19d192383eab2f4ceb30b412b22ea30690c9e618f78870357ae1d682912015a", size = 10801217, upload-time = "2025-09-29T23:17:04.522Z" }, + { url = "https://files.pythonhosted.org/packages/1d/03/3fc4a529a7710f890a239cc496fc6d50ad4a0995657dccc1d64695adb9f4/pandas-2.3.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5caf26f64126b6c7aec964f74266f435afef1c1b13da3b0636c7518a1fa3e2b1", size = 12148791, upload-time = "2025-09-29T23:17:18.444Z" }, + { url = "https://files.pythonhosted.org/packages/40/a8/4dac1f8f8235e5d25b9955d02ff6f29396191d4e665d71122c3722ca83c5/pandas-2.3.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd7478f1463441ae4ca7308a70e90b33470fa593429f9d4c578dd00d1fa78838", size = 12769373, upload-time = "2025-09-29T23:17:35.846Z" }, + { url = "https://files.pythonhosted.org/packages/df/91/82cc5169b6b25440a7fc0ef3a694582418d875c8e3ebf796a6d6470aa578/pandas-2.3.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4793891684806ae50d1288c9bae9330293ab4e083ccd1c5e383c34549c6e4250", size = 13200444, upload-time = "2025-09-29T23:17:49.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/ae/89b3283800ab58f7af2952704078555fa60c807fff764395bb57ea0b0dbd/pandas-2.3.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:28083c648d9a99a5dd035ec125d42439c6c1c525098c58af0fc38dd1a7a1b3d4", size = 13858459, upload-time = "2025-09-29T23:18:03.722Z" }, + { url = "https://files.pythonhosted.org/packages/85/72/530900610650f54a35a19476eca5104f38555afccda1aa11a92ee14cb21d/pandas-2.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:503cf027cf9940d2ceaa1a93cfb5f8c8c7e6e90720a2850378f0b3f3b1e06826", size = 11346086, upload-time = "2025-09-29T23:18:18.505Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fa/7ac648108144a095b4fb6aa3de1954689f7af60a14cf25583f4960ecb878/pandas-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:602b8615ebcc4a0c1751e71840428ddebeb142ec02c786e8ad6b1ce3c8dec523", size = 11578790, upload-time = "2025-09-29T23:18:30.065Z" }, + { url = "https://files.pythonhosted.org/packages/9b/35/74442388c6cf008882d4d4bdfc4109be87e9b8b7ccd097ad1e7f006e2e95/pandas-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8fe25fc7b623b0ef6b5009149627e34d2a4657e880948ec3c840e9402e5c1b45", size = 10833831, upload-time = "2025-09-29T23:38:56.071Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e4/de154cbfeee13383ad58d23017da99390b91d73f8c11856f2095e813201b/pandas-2.3.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b468d3dad6ff947df92dcb32ede5b7bd41a9b3cceef0a30ed925f6d01fb8fa66", size = 12199267, upload-time = "2025-09-29T23:18:41.627Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c9/63f8d545568d9ab91476b1818b4741f521646cbdd151c6efebf40d6de6f7/pandas-2.3.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b98560e98cb334799c0b07ca7967ac361a47326e9b4e5a7dfb5ab2b1c9d35a1b", size = 12789281, upload-time = "2025-09-29T23:18:56.834Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/a5ac8c7a0e67fd1a6059e40aa08fa1c52cc00709077d2300e210c3ce0322/pandas-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37b5848ba49824e5c30bedb9c830ab9b7751fd049bc7914533e01c65f79791", size = 13240453, upload-time = "2025-09-29T23:19:09.247Z" }, + { url = "https://files.pythonhosted.org/packages/27/4d/5c23a5bc7bd209231618dd9e606ce076272c9bc4f12023a70e03a86b4067/pandas-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db4301b2d1f926ae677a751eb2bd0e8c5f5319c9cb3f88b0becbbb0b07b34151", size = 13890361, upload-time = "2025-09-29T23:19:25.342Z" }, + { url = "https://files.pythonhosted.org/packages/8e/59/712db1d7040520de7a4965df15b774348980e6df45c129b8c64d0dbe74ef/pandas-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:f086f6fe114e19d92014a1966f43a3e62285109afe874f067f5abbdcbb10e59c", size = 11348702, upload-time = "2025-09-29T23:19:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846, upload-time = "2025-09-29T23:19:48.856Z" }, + { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618, upload-time = "2025-09-29T23:39:08.659Z" }, + { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212, upload-time = "2025-09-29T23:19:59.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693, upload-time = "2025-09-29T23:20:14.098Z" }, + { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002, upload-time = "2025-09-29T23:20:26.76Z" }, + { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971, upload-time = "2025-09-29T23:20:41.344Z" }, + { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722, upload-time = "2025-09-29T23:20:54.139Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4b/18b035ee18f97c1040d94debd8f2e737000ad70ccc8f5513f4eefad75f4b/pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713", size = 11544671, upload-time = "2025-09-29T23:21:05.024Z" }, + { url = "https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8", size = 10680807, upload-time = "2025-09-29T23:21:15.979Z" }, + { url = "https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d", size = 11709872, upload-time = "2025-09-29T23:21:27.165Z" }, + { url = "https://files.pythonhosted.org/packages/15/07/284f757f63f8a8d69ed4472bfd85122bd086e637bf4ed09de572d575a693/pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac", size = 12306371, upload-time = "2025-09-29T23:21:40.532Z" }, + { url = "https://files.pythonhosted.org/packages/33/81/a3afc88fca4aa925804a27d2676d22dcd2031c2ebe08aabd0ae55b9ff282/pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c", size = 12765333, upload-time = "2025-09-29T23:21:55.77Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0f/b4d4ae743a83742f1153464cf1a8ecfafc3ac59722a0b5c8602310cb7158/pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493", size = 13418120, upload-time = "2025-09-29T23:22:10.109Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c7/e54682c96a895d0c808453269e0b5928a07a127a15704fedb643e9b0a4c8/pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee", size = 10993991, upload-time = "2025-09-29T23:25:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ca/3f8d4f49740799189e1395812f3bf23b5e8fc7c190827d55a610da72ce55/pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5", size = 12048227, upload-time = "2025-09-29T23:22:24.343Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5a/f43efec3e8c0cc92c4663ccad372dbdff72b60bdb56b2749f04aa1d07d7e/pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21", size = 11411056, upload-time = "2025-09-29T23:22:37.762Z" }, + { url = "https://files.pythonhosted.org/packages/46/b1/85331edfc591208c9d1a63a06baa67b21d332e63b7a591a5ba42a10bb507/pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78", size = 11645189, upload-time = "2025-09-29T23:22:51.688Z" }, + { url = "https://files.pythonhosted.org/packages/44/23/78d645adc35d94d1ac4f2a3c4112ab6f5b8999f4898b8cdf01252f8df4a9/pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110", size = 12121912, upload-time = "2025-09-29T23:23:05.042Z" }, + { url = "https://files.pythonhosted.org/packages/53/da/d10013df5e6aaef6b425aa0c32e1fc1f3e431e4bcabd420517dceadce354/pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86", size = 12712160, upload-time = "2025-09-29T23:23:28.57Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/e756653095a083d8a37cbd816cb87148debcfcd920129b25f99dd8d04271/pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc", size = 13199233, upload-time = "2025-09-29T23:24:24.876Z" }, + { url = "https://files.pythonhosted.org/packages/04/fd/74903979833db8390b73b3a8a7d30d146d710bd32703724dd9083950386f/pandas-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0", size = 11540635, upload-time = "2025-09-29T23:25:52.486Z" }, + { url = "https://files.pythonhosted.org/packages/21/00/266d6b357ad5e6d3ad55093a7e8efc7dd245f5a842b584db9f30b0f0a287/pandas-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593", size = 10759079, upload-time = "2025-09-29T23:26:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/ca/05/d01ef80a7a3a12b2f8bbf16daba1e17c98a2f039cbc8e2f77a2c5a63d382/pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c", size = 11814049, upload-time = "2025-09-29T23:27:15.384Z" }, + { url = "https://files.pythonhosted.org/packages/15/b2/0e62f78c0c5ba7e3d2c5945a82456f4fac76c480940f805e0b97fcbc2f65/pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b", size = 12332638, upload-time = "2025-09-29T23:27:51.625Z" }, + { url = "https://files.pythonhosted.org/packages/c5/33/dd70400631b62b9b29c3c93d2feee1d0964dc2bae2e5ad7a6c73a7f25325/pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6", size = 12886834, upload-time = "2025-09-29T23:28:21.289Z" }, + { url = "https://files.pythonhosted.org/packages/d3/18/b5d48f55821228d0d2692b34fd5034bb185e854bdb592e9c640f6290e012/pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3", size = 13409925, upload-time = "2025-09-29T23:28:58.261Z" }, + { url = "https://files.pythonhosted.org/packages/a6/3d/124ac75fcd0ecc09b8fdccb0246ef65e35b012030defb0e0eba2cbbbe948/pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5", size = 11109071, upload-time = "2025-09-29T23:32:27.484Z" }, + { url = "https://files.pythonhosted.org/packages/89/9c/0e21c895c38a157e0faa1fb64587a9226d6dd46452cac4532d80c3c4a244/pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec", size = 12048504, upload-time = "2025-09-29T23:29:31.47Z" }, + { url = "https://files.pythonhosted.org/packages/d7/82/b69a1c95df796858777b68fbe6a81d37443a33319761d7c652ce77797475/pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7", size = 11410702, upload-time = "2025-09-29T23:29:54.591Z" }, + { url = "https://files.pythonhosted.org/packages/f9/88/702bde3ba0a94b8c73a0181e05144b10f13f29ebfc2150c3a79062a8195d/pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450", size = 11634535, upload-time = "2025-09-29T23:30:21.003Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1e/1bac1a839d12e6a82ec6cb40cda2edde64a2013a66963293696bbf31fbbb/pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5", size = 12121582, upload-time = "2025-09-29T23:30:43.391Z" }, + { url = "https://files.pythonhosted.org/packages/44/91/483de934193e12a3b1d6ae7c8645d083ff88dec75f46e827562f1e4b4da6/pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788", size = 12699963, upload-time = "2025-09-29T23:31:10.009Z" }, + { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175, upload-time = "2025-09-29T23:31:59.173Z" }, +] + [[package]] name = "pathspec" version = "1.0.4" diff --git a/docs-site/package-lock.json b/docs-site/package-lock.json index 72feb9f04..2f3fb0d2a 100644 --- a/docs-site/package-lock.json +++ b/docs-site/package-lock.json @@ -3803,9 +3803,9 @@ } }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "funding": [ { "type": "github", @@ -5230,9 +5230,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", @@ -5496,9 +5496,9 @@ } }, "node_modules/postcss": { - "version": "8.5.22", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz", - "integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==", + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", "funding": [ { "type": "opencollective", @@ -5515,7 +5515,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.16", + "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, diff --git a/docs-site/src/content/docs/admin/fine-tuning.md b/docs-site/src/content/docs/admin/fine-tuning.md index ef689d143..20244be10 100644 --- a/docs-site/src/content/docs/admin/fine-tuning.md +++ b/docs-site/src/content/docs/admin/fine-tuning.md @@ -1,12 +1,156 @@ --- title: Fine-Tuning -description: Fine-tuning access and costs. +description: Task types, datasets, quota and cost for the fine-tuning feature. sidebar: order: 4 --- -:::caution[Draft] -This page is a scaffolded placeholder — content to be written. -::: +Researchers fine-tune a pre-trained model on their own labelled data, then run +the result over new data as a batch job. Everything executes on SageMaker; the +platform owns the catalog, the dataset contract, access control and the budget. -Usage & Spend group. Grant fine-tuning access and review its spend; backed by `admin/fine_tuning` and the `/admin/fine-tuning` pages. +## Task types + +A **task type** is the organizing primitive. It determines the dataset +contract, the accepted upload formats, which base models are offered, and which +Deep Learning Container the job runs in. + +| Task | Input → output | Dataset upload | Manifest columns | +|---|---|---|---| +| `text-classification` | text → label | `.csv` / `.jsonl` / `.json` | `text`, `label` | +| `image-classification` | image → label | `.zip` | `image`, `label` | +| `image-text-classification` | image + text → label | `.zip` | `image`, `text`, `label` | + +All three produce the same output: a softmax over the dataset's own classes, +written as a CSV with one probability column per class. That shared contract is +deliberate — it means a new modality never breaks the result viewer. + +### Image datasets + +An image task takes a single `.zip` containing a manifest plus the image files +it references: + +``` +dataset.zip +├── manifest.csv image,label +│ images/cat-01.jpg,cat +│ images/dog-04.jpg,dog +└── images/ + ├── cat-01.jpg + └── dog-04.jpg +``` + +The `image` column holds each file's path **relative to the archive root**. The +manifest may be `.csv`, `.jsonl` or `.json`. Archives are treated as untrusted +input: entries that use absolute paths or `../` traversal are refused, as are +manifest rows pointing outside the archive. + +Batch Transform receives the archive as one payload, so inference input is +capped at 100 MB per job. Larger inference runs should be split across jobs. + +## Adding a task type + +The registry lives in `backend/src/apis/app_api/fine_tuning/task_types.py`. +Adding a task means registering a `TaskSpec` and adding a +`sagemaker_scripts/task_.py` module implementing `train`, `model_fn`, +`input_fn` and `predict_fn`. `train.py` and `inference.py` are dispatchers and +should not need editing. + +`task_types.py` must stay importable without torch, transformers, pandas or +PIL — it is read by the app-api container and the unit tests, neither of which +has the ML stack. + +## Deep Learning Containers + +Each task declares a DLC *family*, and the two families move independently: + +| Family | Training image | Why | +|---|---|---| +| `text` | PyTorch 2.1 / transformers 4.36 | Every existing text model was trained and validated here. Bumping it would re-baseline all of them at once. | +| `vision` | PyTorch 2.8 / transformers 4.56 | Modern vision checkpoints do not load on 4.36. | + +If a tag is retired or lags in a region, override it without a code deploy: + +```bash +FINE_TUNING_TRAINING_IMAGE_VISION=.dkr.ecr..amazonaws.com/: +FINE_TUNING_INFERENCE_IMAGE_VISION=... +``` + +## Quota + +Quota is denominated in **US dollars per calendar month**, not GPU-hours. +Hours stopped describing the budget the moment more than one instance type was +offered: ten hours buys about $14 on an `ml.g5.xlarge` and roughly $450 on an +`ml.g6e.24xlarge`. + +Grants written before this change are migrated lazily — read in the new shape +and rewritten on the next quota check, converting at the `ml.g5.xlarge` rate +those hours were actually spent at. No backfill run is needed, and a migrated +user's existing spend carries across rather than resetting. + +### How spend is bounded + +A job's real cost is unknown until it stops, so the budget becomes its +**stopping condition**: `MaxRuntimeInSeconds` is clamped to the hours the +user's remaining quota affords. SageMaker kills the job at that point, which +bounds spend exactly with no reservation bookkeeping. + +Rejecting on worst-case cost instead would block essentially everything — the +default 24-hour stopping condition is ~$27 even on the cheapest instance, more +than an ordinary monthly quota, though such a job usually finishes in minutes. +A job is refused outright only when the remaining budget buys less than 30 +minutes. + +Failed and stopped runs are billed by AWS and are charged against the quota +accordingly. + +### Configuration + +| Variable | Meaning | +|---|---| +| `FINE_TUNING_DEFAULT_QUOTA_USD` | Monthly quota auto-granted to any authenticated user. `0` (default) means whitelist-only. | +| `FINE_TUNING_DEFAULT_QUOTA_HOURS` | Legacy fallback, converted to dollars at the `ml.g5.xlarge` rate. | + +## Instance pricing + +Rates live in `fine_tuning/pricing.py` as literal maps, sourced from the AWS +Price List API (**not** the pricing web page, which rounds and lags). Resync +them with: + +```bash +python backend/scripts/refresh_instance_pricing.py --profile dev-ai +``` + +Two things the map gets right that a single flat table could not: + +- **Training and Batch Transform are priced separately.** They agree across the + g5 family but diverge on g6e. +- **Not every training instance can run Batch Transform.** `ml.p4d.24xlarge` + and `ml.p5.48xlarge` publish a training rate and no transform rate, so + offering them would let a researcher fine-tune a model they then cannot run + inference on. They are deliberately absent, as is `ml.p3.*`, which has no + on-demand SageMaker rate at all. + +Pricing accuracy is load-bearing: a wrong rate does not just misreport spend, +it mis-enforces the budget. + +## Choosing a base model + +The catalog offers vetted models per task. Researchers may also supply any +HuggingFace model id, which is pre-flighted against the Hub before a GPU is +provisioned. A custom model is refused when it does not exist, publishes no +loadable weights, or is tagged for a different modality. + +The commonest refusal is a **GGUF-only repository**. GGUF is a llama.cpp +inference format and cannot be fine-tuned by transformers at all — look for the +original, unquantised repository instead. + +Note that generative vision-language models (`image-text-to-text`, e.g. LLaVA +or Qwen-VL) are **not** supported. They emit tokens rather than a class +distribution, and would need a separate generative task type with LoRA/PEFT. + +## Admin surface + +Grant access and review spend under **Usage & Spend**, backed by +`admin/fine_tuning` and the `/admin/fine-tuning` pages. A grant is a monthly +dollar quota against an email address; the `AppRole` record is not involved. diff --git a/docs-site/src/content/docs/features/artifacts.md b/docs-site/src/content/docs/features/artifacts.md index 4abffb56e..c54448a2e 100644 --- a/docs-site/src/content/docs/features/artifacts.md +++ b/docs-site/src/content/docs/features/artifacts.md @@ -1,12 +1,226 @@ --- -title: Artifacts -description: Rendered artifact panel. +title: Artifacts and Artifact Sharing +description: How the agent produces standalone documents, how they are rendered in isolation, and how a user shares one. sidebar: + label: Artifacts order: 5 --- -:::caution[Draft] -This page is a scaffolded placeholder — content to be written. -::: +An **artifact** is a standalone document the agent authors as a first-class +object rather than as text in the chat — an HTML page, a chart, a Markdown +report. It gets its own card in the conversation, its own versioned history, and +its own sandboxed viewer. -Single artifact SSE event and the artifact-render image. +The interesting part is not the authoring. It's that an artifact is +**attacker-authored markup rendered in a browser**, so almost every design +decision below is about containing it. + +## Producing an artifact + +Two agent tools write artifacts: `create_artifact` makes v1, and +`update_artifact` appends a new version. **Versions are immutable and +append-only** — an update writes `V#{n+1}` and re-points a `#HEAD` pointer; it +never rewrites what came before. Nothing in the platform has a `DeleteObject` +grant on artifact content. + +That immutability is load-bearing later: it is what lets a share pin one exact +version with no snapshot copy. + +Each version lives in two places: + +| Where | What | +| --- | --- | +| DynamoDB (`{prefix}-user-artifacts`) | Metadata: title, content type, version, session, and a pointer to the content | +| S3 (`{prefix}-artifacts-content`) | The document bytes themselves | + +## Rendering: three layers of isolation + +Artifact content is never inlined into the SPA. It is served from a **separate +origin** (`artifacts.`) and framed, which gives three independent +containment layers: + +1. **A separate origin.** The artifact cannot touch the app's cookies, storage, + or DOM, because the browser treats it as a different site. +2. **A strict CSP**, stamped by both CloudFront and the render Lambda (defense in + depth, so the policy holds even if the handler is buggy). `connect-src 'none'` + means artifact JavaScript cannot phone home or exfiltrate anything. +3. **A sandboxed iframe** — `sandbox="allow-scripts"` **without** + `allow-same-origin`. The framed document is a null origin, so scripts run but + reach nothing. + +To load one, the SPA asks app-api to mint a **render token**: a short-lived +(~120 second) HS256 JWT that pins one exact `(user, artifact, version)`. The +token goes in the iframe URL and is re-minted on every open — never cached. + +## Sharing an artifact + +A user can share one artifact version with other people. The model deliberately +mirrors conversation sharing, so the two behave alike. + +### What a share is + +A share is an owner-created, revocable pointer to **one immutable version**. + +- **It pins a version, never `#HEAD`.** A link made against v1 keeps showing v1 + after the model writes v2. A pointer that moved under the recipient would be a + different feature with different consent semantics. +- **Access levels** are `public` — any *authenticated* user with the link — or + `specific`, an email allowlist matched case-insensitively. There is no + anonymous access: `public` still sits behind sign-in, exactly as it does for + conversation shares. Governance here is identity, not content inspection. +- **Revocation** deletes the share. Already-issued render tokens finish their + ~120 second life, so the revocation window is bounded by the token TTL rather + than by how long the recipient keeps the tab open. + +### Sharing one + +Owners share from the **Share** button on the artifact card or in the artifact +panel header. Both share the version currently on screen — the card shows one row +per version, and the panel follows its version menu. The dialog also lists the +artifact's existing links so they can be copied or revoked. + +### Opening one + +Recipients open `/shared-artifact/{shareId}`, which renders the artifact +full-width in the same sandboxed iframe the owner sees, with a preview/code +toggle and a download. The page is read-only: no re-sharing, no version +switching, no editing. + +### How access is actually enforced + +This is the part worth understanding before changing anything. + +The render Lambda uses the token's `sub` claim purely as the **DynamoDB +partition key** it builds the lookup from. It performs no ownership comparison of +its own and never sees the viewer — **the token is the capability**. + +So a share-scoped token is minted with `sub` set to the **owner**, not the +viewer. That is an *address*, not an identity assertion; setting it to the viewer +would simply point the Lambda at the viewer's own partition and 404. The real +viewer travels in a `vwr` claim and the grant it was issued under in `shr`, so +render logs attribute a view to whoever actually looked. + +The consequence: **the ACL check in app-api is the only thing standing between +"sharing" and "read any artifact by id."** Every recipient request — metadata, +render token, content — re-checks the share record before resolving the owner. + +### Finding what has been shared with you + +The library's "Shared with you" tab is backed by `GET /shared-artifacts`, and it +is worth knowing how it is stored, because the obvious answer is wrong. + +Every share writes one **fan-out row per recipient**, keyed +`PK=SHARED_WITH#{email}` / `SK=SHARE#{createdAt}#{shareId}`. That is not a +workaround for avoiding an index — `allowedEmails` is a *list*, and a DynamoDB +GSI cannot project one item into several index entries, so any recipient lookup +needs a row per recipient no matter where it lives. Given that, the row belongs +in the recipient's own partition, where the query is already partitioned by the +access dimension, ordered newest-first by the sort key, and paginable with no +filter. + +Three properties hold this together: + +- **The fan-out row is a pointer.** It carries no title or content type. Share + rows denormalize those, so copying them per recipient would multiply every + staleness bug by the size of the allowlist and make a rename cost one write per + recipient instead of one per share. +- **The read never trusts the pointer.** Each row is resolved through the share + lookup row and re-checked against `_check_share_access`, so a stranded pointer + lists nothing and grants nothing. That is what makes the fan-out safe to write + best-effort, outside the share's two-row transaction — which in turn is what + keeps the allowlist from being capped at ~40 by `TransactWriteItems`' 100-item + limit. +- **Fan-out is discovery, never authorization.** A row that failed to write costs + a recipient a listing, not their access; the link still works and the ACL check + is unchanged. + +Addresses are folded to lower case for the partition key. Share rows store them +exactly as typed and lowercase only at compare time, so skipping that fold +returns an empty inbox to the person a share was addressed to — a wrong answer +that looks exactly like "nobody has shared anything with you". + +Only `specific` shares appear. `public` means "any authenticated tenant user", +which has no recipient list to fan out to, so public shares stay link-delivered. + +The read path uses per-item `GetItem`, never `BatchGetItem`, for exactly the +reason the delete cascade avoids `BatchWriteItem` — see the note under +[Lifecycle](#lifecycle). + +## Lifecycle + +Artifacts outlive the turn that produced them, but not the conversation. +Deleting a conversation revokes the share links for every artifact it produced, +as a best-effort background task: a failure leaves an orphan row, never a blocked +delete, and the lookup row is always deleted before the owner row so a +half-finished cleanup can never leave a live link its owner can no longer see. + +One implementation note that is easy to get wrong: the cleanup deletes rows with +individual `DeleteItem` calls rather than a batch write. `BatchWriteItem` is its +own IAM action and is **not** authorized by the underlying item permissions the +way `TransactWriteItems` is, so a batch write fails closed with `AccessDenied` in +a deployed environment while passing every local test. + +Deleting the artifact **content** on conversation delete is deliberately a +separate question — that is a retention decision about artifacts as a whole, +not about sharing. + +## Enabling the feature + +Artifacts are enabled by the presence of `ARTIFACTS_RENDER_TOKEN_SECRET_ARN`, +which infrastructure sets only when the artifacts stack is deployed for the +environment. The sharing routes ride the same signal: a share is only ever +consumed by minting a render token, so it cannot be useful without artifacts +being on. + +The "Shared with you" inbox has a flag of its own: `ARTIFACT_SHARE_INBOX_ENABLED` +(CDK: `CDK_ARTIFACT_SHARE_INBOX_ENABLED`). Unlike the kill-switch flags elsewhere +in the platform it is **default off and opt-in** — only the literal `"true"` +enables it — because the surface shipped ahead of the product decision about it. +While off, `GET /shared-artifacts` 404s and the SPA renders the library without +tabs. + +The flag gates the **read only**. Fan-out rows are written by every share +regardless of it. That asymmetry is deliberate: if the writes were gated too, +turning the flag on would reveal an inbox missing every share created while it +was off — a wrong answer rather than an empty one, and one nobody could see was +wrong. Writing the rows regardless makes the flip complete and instant, with no +backfill to sequence. + +## Artifacts inside a shared conversation + +Sharing a conversation shares the artifacts it produced. A recipient sees the +artifact cards where the owner sees them, anchored under the same turns. + +The mechanism is worth knowing before changing it: **the conversation share is +the grant.** No artifact share records are created for this. Instead +`create_share` pins the session's artifacts — at the version each stood at right +then — into the conversation's snapshot body, next to the messages. + +That does two jobs at once. It keeps the point-in-time promise the snapshot +already makes, so a recipient reading a frozen conversation is not shown an +artifact the transcript around it never describes. And it makes the snapshot the +**allowlist**: `resolve_shared_artifact` will only serve an `(artifact, version)` +pair that appears there, which is what stops a recipient naming an arbitrary +artifact belonging to the same owner. That check matters because the minted +token's `sub` is a partition address rather than an identity — see +[How access is actually enforced](#how-access-is-actually-enforced). + +The payoff is that access has one source of truth. Narrow a conversation's +allowlist and its artifacts lock down in the same write; revoke the share and +they go with it. Provisioning parallel artifact shares would instead need a +cascade on every one of those paths, and a missed cascade leaves an artifact +readable after its conversation was locked down. + +Shares created before this landed carry no artifact list and read as an empty +one. There is no migration. + +## Historical note: how this used to be broken + +Until the section above shipped, sharing a *conversation* did not share the +artifacts it produced. A recipient saw nothing where the owner saw artifact +cards — silently, with no placeholder — because artifact hydration goes through +`GET /artifacts?session_id=…`, which filters HEAD rows by the requesting user. + +It stayed open as long as it did because the fix needed a consent decision +("should sharing a conversation also share its artifacts?") rather than only +wiring. The answer was yes. diff --git a/docs/kaizen/research/2026-09-04.md b/docs/kaizen/research/2026-09-04.md new file mode 100644 index 000000000..df4a0356b --- /dev/null +++ b/docs/kaizen/research/2026-09-04.md @@ -0,0 +1,392 @@ +# Kaizen Research — Friday, September 4, 2026 + +> Scan window: **August 28 – September 4, 2026 (7 days)**. +> Web budget: **36 / 50** used (under target; four subagents ran entirely through the authenticated `gh` CLI, which costs nothing against the web budget). + +## TL;DR + +**The prompt-cache cost model we corrected last week is already out of date, and the correction never finished.** PR #914 fixed the `$2.50/MTok` figure in `CLAUDE.md` — but the same wrong constant is still sitting in **six other places**, including [`model_config.py:380`](backend/src/agents/main_agent/core/model_config.py:380), the most cost-sensitive comment block we own. Worse, #914 replaced it with a *derived* helper in [`curated-models.ts:96-97`](frontend/ai.client/src/app/admin/manage-models/models/curated-models.ts:96) that hardcodes `cacheRead = input × 0.1` — and **Claude Fable 5.1, GA on Bedrock 2026-09-01, prices cache reads at $0.25/MTok against $10/MTok input, i.e. 0.025×**, a 75% cut Anthropic states explicitly. The ratio we just canonicalised is model-specific, and the first model to break it shipped three days later. + +**Three independent sources converged on the same structural fact this week: `initialize` is going away, and our MCP Apps host is built on it.** The MCP 2026-07-28 spec retires the `initialize`/`initialized` exchange and the `Mcp-Session-Id` header (SEP-2567/2575) in favour of `server/discover`; **FastMCP 4.0.0 shipped that sessionless protocol on 2026-08-31**, which is what our Lambda-backed MCP servers run on; and Strands 1.53.0 is actively changing the MCP client we monkey-patch to advertise the Apps capability. Our host captures `serverInfo` off `initialize` at [`mcp_apps.py:673`](backend/src/agents/main_agent/integrations/mcp_apps.py:673) through a symbol patch on a Strands internal. That queued 2026-08-14 item stopped being a docs migration this week and became a real one. + +Recommended **#1** is the cheapest and most upstream: **finish the #914 correction and make the cache-read ratio per-model instead of a hardcoded constant.** + +## External Scan + +### What's moving this week + +The week's shape is *protocol consolidation on the server side, and cost-model fragmentation on the model side* — and they pull in opposite directions. + +On protocol: FastMCP 4.0 is the release that turns the MCP spec's sessionless direction into something our servers actually run. It removes server-initiated sampling and roots, ships SEP-2567 `UserSession`/`SessionId` for per-user state without a protocol session, adds SEP-2243 routable headers (`Mcp-Method`/`Mcp-Name`) so a gateway can route without parsing JSON-RPC, and SEP-2549 cache hints. Every one of those is on-thesis for us: the sessionless path is the thing our Lambda servers have been faking with `stateless_http=True`, routable headers are interesting for AgentCore Gateway target routing, and cache hints touch the tool-listing token cost directly. It is also **breaking** — a migration, not a bump. + +On cost: the assumption that Bedrock prompt-cache economics are a fixed set of ratios off base input just broke. We spent last week establishing `write = 1.25 × input`, `read = 0.1 × input`, `us.* = 1.10 × global.*`, and encoded it as a derivation helper. Fable 5.1 lands with a cache read at 0.025× and Anthropic frames the cut as deliberate. Meanwhile OpenAI's changelog describes mid-conversation reasoning-effort changes that *preserve* cached prefixes, and Claude Code shipped three separate fixes for cache busters caused by things outside the turn (an OAuth token refresh, a mid-session tool-definition re-send, a blocking hook). The industry is converging on "prompt-cache behaviour is a first-class, per-model, per-event contract" — and we have the observability to measure it but no derived *cause*, which Claude Code just shipped. + +The surprise was how much of the week corroborated work we've already done rather than proposing new work. Anthropic **retracted** the measured cost ratios from its own multi-agent delegation cookbook, leaving only the token-share claim — direct corroboration of our G1 read that the agent-cache cost thesis was disproven and the win was latency. assistant-ui independently shipped fixes for a stale stream producer finalizing its replacement, and for duplicate message appends across tabs — the same two bugs as our dropped-SSE lease leak (#863) and tab-switch duplicate invocation. LibreChat's v0.8.8-rc2 shipped interrupt/steer/queue-follow-up as a first-class turn control in the same week our mid-turn steering spec landed. Convergence, not novelty. + +### Notable items by source + +> **Annotation conventions:** `*relevance*:` = impact on existing code. `*unlocks*:` = capability-unlock lens, for new platform primitives / spec capabilities / UX patterns. + +#### AWS Bedrock / AgentCore + +- **AgentCore Identity — managed Consent Portal (2026-09-01)** — AWS now hosts the portal where end users review and grant an agent access to third-party resources (GitHub, Salesforce, Slack); you redirect to a `portalUrl` and manage portals via create/get/list/update/delete APIs. — https://aws.amazon.com/about-aws/whats-new/2026/09/amazon-bedrock-agentcore/ · https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/identity-consent-portal.html — *relevance*: overlaps our hand-rolled consent path — `OAuthConsentHook` ([`session/hooks/oauth_consent.py`](backend/src/agents/main_agent/session/hooks/oauth_consent.py)), the two-flavour `oauth_required` SSE event, the `authorizationUrl` the SPA renders, and the non-CDK-managed BFF callback. **Check before scoping**: the release notes say a Consent Portal requires a Gateway with **JWT inbound authentication** and an IdP permitting the `openid` scope — our Gateway path is MCP+SigV4, so verify the inbound auth mode first. It would **not** replace the pre-flight `tools/list` failure case, which is a server-refusal problem, not a consent-UI problem. — *unlocks*: a managed, brandable consent surface, so a new OAuth MCP provider needs no per-provider SPA/BFF callback work. +- **Claude Fable 5.1 GA on Bedrock (2026-09-01)** — designated a "Covered Model" with Enterprise Frontier Safeguards; also landed in GovCloud (US) the same day. — https://aws.amazon.com/about-aws/whats-new/2026/09/claude-fable-5-1-aws/ — *relevance*: a `curated-models.ts` candidate, **but do not add it from a blog number** — no Bedrock inference-profile id, region list, context window, or cache-*write* rate was published. Rates must come from the AWS Price List API. See the pricing section: its cache-read ratio breaks our derivation helper. +- **Global cross-Region inference + prompt caching walkthrough (2026-09-02)** — AWS ML blog on global CRIS access patterns and prompt-caching behaviour across regions (OpenAI models, Australia). — https://aws.amazon.com/blogs/machine-learning/accessing-openai-gpt-5-6-models-on-amazon-bedrock-from-australia-with-global-cross-region-inference/ — *relevance*: the `global.*` vs `us.*` (Regional CRIS) distinction is a ~10% line item on every token we spend. Worth reading for whether cache-hit behaviour differs between the two profile types — that would change the math in `curated-models.ts`. Different model family, so treat the caching specifics as indicative, not authoritative for our Haiku 4.5 / Sonnet 4.6 path. +- **"Migrate agentic workloads to Amazon Bedrock AgentCore" (2026-09-03)** — https://aws.amazon.com/blogs/machine-learning/migrate-agentic-workloads-to-amazon-bedrock-agentcore/ — *relevance*: low priority. Useful only as a cross-check on our documented inference-api Runtime boundary (only `/invocations` and `/ping` proxy; all other paths 404 before reaching the container). +- **Not reported / could not date**: AgentCore Evaluations TypeScript support (we are Python Strands — no action). Web Search on Bedrock in GovCloud (GovCloud-only, OpenAI models). **AWS Agent Registry GA/PrivateLink/RAM** and **AgentCore Memory `IngestData` + flexible namespaces** appear only under an undated "August 2026" heading in the release notes — could not confirm they fall inside the window. ⚠️ **Flag for next week's scan** if they were not caught earlier; Memory namespace flexibility in particular touches the W5 memory-bill item already in the queue. + +#### Strands Agents + +- **⚠️ `python/v1.53.0` — BREAKING, and it collides with our cachePoint layout** — the 1.53.0 wave (mirroring `typescript/v1.14.0`, whose changelog carries the breaking `!` marker "cache system prompt in auto mode") makes `CacheConfig(strategy="auto")` **auto-place a system-prompt cache point**. — https://github.com/strands-agents/sdk-python/releases — *relevance*: **direct collision, verified locally.** [`model_config.py`](backend/src/agents/main_agent/core/model_config.py:375) sets `strategy="auto"` while [`agent_factory.py:213-224`](backend/src/agents/main_agent/core/agent_factory.py:213) hand-places a trailing system `cachePoint` via a `SystemContentBlock` list — and the comment there asserts *"Strands' auto strategy strips only message-level cachePoints, never system ones."* That sentence is a 1.51.0-era fact and stops being true on upgrade. Expect a duplicated or relocated system point, i.e. a prefix re-write at the 1.25× write premium on **every session**. This is the single highest-consequence trap in the scan for a routine dep bump. +- **`python/v1.54.0` (latest)** — adds `Agent.session_id`, **external cancellation-signal injection**, `FileMemoryStore`, cache-token counting in context-size, and a configurable classifier/model-routing strategy. — https://github.com/strands-agents/sdk-python/releases — *relevance*: external cancellation maps onto our anyio-`CancelScope` stream-drop work (#863); "cache token counting in context-size" may change what the context/cost badge derives. — *unlocks*: an SDK-native cancellation signal could retire hand-rolled cancel plumbing, and `Agent.session_id` gives natively the runtime session pin the G1 agent-cache read had to establish by hand. +- **`python/v1.52.0` — conversation trimming at complete tool pairs** — plus Bedrock request-cancellation improvements and middleware-initiated interrupts. — https://github.com/strands-agents/sdk-python/releases — *relevance*: overlaps our own `_repair_tool_pairing` and the persisted truncation anchor in `TurnBasedSessionManager`. Two independent trimmers picking different boundaries is exactly the byte-instability the compaction redesign exists to prevent — check whether SDK trimming is even reachable given our custom session manager. Middleware interrupts are a plausible future home for `OAuthConsentHook`. +- **`python/v1.53.0` — MCP OAuth over HTTP transport + tool annotations in `ToolSpec`** — https://github.com/strands-agents/sdk-python/releases — *relevance*: candidate to simplify the hand-built pre-flight/consent path (PR #872), where the 401 status sits three wrappers down. ⚠️ **Tool annotations change the `toolConfig` shape** — any annotation added to the tool list re-writes the cacheable prefix, so adoption is a prompt-cache change, not a metadata change. +- **Issue #4168 — `cache_tools` cachePoint emitted when auto resolves to no caching** (open, filed 2026-09-04) — with `strategy="auto"`, system-prompt caching correctly skips non-Anthropic profiles but `_build_tools_cache_point` still appends the tools cachePoint → `AccessDeniedException` on e.g. OpenAI/Nvidia Bedrock profiles. — https://github.com/strands-agents/sdk-python/issues/4168 — *relevance*: **we are already immune** — `model_config.py` gates both `cache_tools` and the system point on `bedrock_cache_points_supported()` for exactly this reason. Worth a confirming upstream comment, and a strong reason **not** to drop that gate when adopting 1.53's auto behaviour. +- **Issue #4172 — `loadSnapshot()` during a live invocation silently corrupts message history** (open, updated 2026-09-04, TypeScript-side) — https://github.com/strands-agents/sdk-python/issues/4172 — *relevance*: same failure class as our twice-bitten hazard (#741, #751) where two cached `Agent`s each hold their own `TurnBasedSessionManager` over one DynamoDB row. Upstream is now hitting it too; watch which fix pattern they land on. +- **⚠️ Source-list correction for this skill**: the Strands repo has been restructured into a **multi-language monorepo** — releases now interleave `python/vX.Y.Z` and `typescript/vX.Y.Z` tags, issue numbers are 4-digit and language-labelled, and `https://raw.githubusercontent.com/strands-agents/sdk-python/main/CHANGELOG.md` now **404s**. The `kaizen-research` SKILL.md source URL for the changelog should be updated. Also: `typescript/v1.16.0` has no Python counterpart yet, so **python 1.55.0 is likely imminent** (its TS twin carries Bedrock API-key auth, Mistral `cache_config`, and a telemetry-cycle fix on early stream exit). + +#### Reference repo (aws-samples/sample-strands-agent-with-agentcore) + +7 commits in window, 5 of them Dependabot. + +- **`31cb9e2b` fix(chat): preserve conversation history after cut (#269)** — 2026-09-01 — replaces a monotonic `get_conversation_epoch()` history filter with a `ConversationFence` (`current_epoch` + `cutoff_by_epoch` + `truncated_at`); `list_messages` now calls `fence.allows(epoch, timestamp)` instead of `epoch >= current_epoch`. The old rule discarded *every* event from a prior epoch, so a user "cut" nuked the retained prefix too. Mirrored on the frontend with `clampConversationEpochCutoffs()`, forcing each stored cutoff monotonically non-increasing so a later cut can never re-admit history an earlier cut removed. — https://github.com/aws-samples/sample-strands-agent-with-agentcore/commit/31cb9e2b — *applicability*: **no direct equivalent — we have no user-facing cut / rewind / message-edit at all.** Our nearest analogue is `compaction_state.truncation_anchor` in `TurnBasedSessionManager`, a monotone index deliberately `max()`-merged so it never moves backwards. Not a port today, but it is **the correct prior art to reach for if we ever ship edit-and-resend or thread rewind** — their bug is precisely the failure mode our prompt-cache contract warns about: a history filter that mutates the restored prefix between turns silently re-writes the cacheable segment. +- **`31cb9e2b` (second half) — atomic `UpdateItem` replaced with a 5-attempt read-modify-write CAS loop**, because the per-epoch cutoff map cannot be computed inside a DynamoDB update expression. — *applicability*: low, and worth flagging as a **caution** — this trades write atomicity for a richer state shape, the opposite direction from our session-lease work. If we ever add per-epoch state, keep the conditional-write atomicity. +- **`83e6d7c9` chore(deps) — `strands-agents[a2a,otel]` 1.53.0 → 1.54.0** (2026-09-01) — *applicability*: version-pin lag signal; the reference repo is already three minor versions ahead of us and took the 1.53 breaking change without visible incident. Their setup does not hand-place a system cachePoint, so that is **not** evidence the collision above is benign for us. +- **No new context-overflow work this week** — last week's context-overflow hardening port candidate has no new upstream deltas. Zero changes to agent setup, tool registration, AgentCore Identity, Memory config, Gateway/MCP wiring, streaming, or A2A. + +#### MCP ecosystem + +- **⭐ `server/discover` has landed and is mandatory for servers; SEP-2567/2575 retire `initialize`/`initialized` and `Mcp-Session-Id` outright.** The protocol-level session is gone — any request can land on any instance. (Dated **2026-07-28**, i.e. *before* this window — reported here because FastMCP 4.0 made it real this week.) — https://modelcontextprotocol.io/specification/2026-07-28/changelog — *relevance*: our MCP Apps host resolves `serverName`/`icon` from `initialize`'s `serverInfo` at [`mcp_apps.py:673`](backend/src/agents/main_agent/integrations/mcp_apps.py:673); that source is now **deprecated, not merely superseded**. Also touches the mcp-sandbox proxy and any per-origin session assumptions. — *unlocks*: sessionless transport removes the fresh-MCP-session-per-call cost behind the MCP Apps proxy-call 504, and `server/discover` gives a pre-flight capability read **before** `tools/list` — a cheaper OAuth pre-flight than the one that currently 401s and permanently drops the tool (PR #872). +- **`modelcontextprotocol/servers` — in-window commits are hardening only** (2026-08-28 → 09-03): memory-server mutation serialization, atomic knowledge-graph writes, filesystem path/permission fixes, `git_log` output-schema unification, Python servers floored on `mcp >= 1.29.0, < 2`. No AWS/Bedrock, GitHub, Slack or observability servers added or archived. — https://github.com/modelcontextprotocol/servers/commits/main — *relevance*: reference servers only; we consume none of them. But the sequential-thinking server **restoring `nextThoughtNeeded` to its advertised input schema** is a live reminder that a server changing its schema silently re-writes our `toolConfig` prefix — a `toolConfigHash` change with no deploy of ours. See the schema-drift item under Community. +- **MCP Apps (SEP-1865) is confirmed shipped as a negotiated extension** under the SEP-2133 Extensions framework — optional, explicitly negotiated, `ui://` scheme, `text/html;profile=mcp-app`. No changes this week. — https://github.com/modelcontextprotocol/ext-apps — *relevance*: **we do negotiate it** — verified locally: [`mcp_apps.py`](backend/src/agents/main_agent/integrations/mcp_apps.py:1) advertises `capabilities.extensions["io.modelcontextprotocol/ui"]` on every outbound `initialize`. The open question is what that negotiation hangs on once there is no `initialize` handshake. +- **MCP blog: no notable items this week** — latest post is "The New MCP Roadmap" (2026-08-22), six days before the window opens. +- ⚠️ **Caveat**: the `server/discover` / SEP-2567 detail above came from search-result summaries of the changelog, not a first-hand read (budget was spent). Verify against the changelog URL before acting. + +#### FastMCP + +Not pinned in this repo — it runs in the MCP server repos this stack consumes via Gateway. **Latest: 4.0.2 (2026-09-02).** + +- **⭐ FastMCP 4.0.0 "Four Real" (2026-08-31) — the major stable release, and it is breaking** — server-initiated sampling and roots **removed** (no persistent connection exists mid-request), `ctx.elicit()` deprecated to the old protocol only, FastMCP 3's deprecated APIs gone, MCP model fields moved to **snake_case** (compat bridge emits warnings), background tasks split into a separate `fastmcp-tasks` package, bare-string server refs deprecated in favour of `Path`. — https://github.com/jlowin/fastmcp/releases — *implications*: any of our Lambda-backed server repos on FastMCP 2.x/3.x needs a **deliberate migration, not a bump**. The snake_case rename is the quiet one — it warns rather than fails, so a repo can drift for weeks. Nothing we do depends on server-initiated sampling/roots (Gateway is request/response), so that removal is low-risk for us. +- **⭐ Sessionless protocol + SEP-2567 stateless session state landed in 4.0.0** — modern requests are self-contained so any replica can answer; protocol negotiation is per-connection so legacy clients keep working. SEP-2567 ships as `UserSession`/`SessionId` for per-user state on the sessionless protocol. Also **SEP-2243 routable headers** (`Mcp-Method`/`Mcp-Name`) letting a gateway route without parsing JSON-RPC, and **SEP-2549 server-level cache hints**. — https://github.com/jlowin/fastmcp/releases — *implications*: this is the release our Lambda deployment pattern has been waiting for. Today our servers rely on `stateless_http=True` + streamable-http as a workaround for Lambda's short-lived invocations; 4.0 makes sessionless a first-class protocol path *with real per-user state on top*. Routable headers are worth a look for AgentCore Gateway target routing; cache hints are directly on-thesis for tool-listing token cost. +- **Auth surface expanded** — SEP-990 identity assertion, `require_roles`, **SEP-2350 incremental authorization step-up**, SEP-837 OAuth `application_type` in DCR; plus hardening (proxy cookie stripping, SSRF-protected OAuth metadata fetches). — https://github.com/jlowin/fastmcp/releases — *implications*: **new primitive worth adopting.** SEP-2350 step-up auth maps onto our `oauth_required` / `OAuthConsentHook` interrupt flow — a server that can demand incremental consent per-tool is close to what we hand-rolled. `require_roles` is a server-side echo of our AppRole RBAC and could push some tool gating down into the server. The SSRF-protected metadata fetch is relevant to our forward-auth and discovery paths. +- **New server-side primitives** — interactive tools, `@mcp.tool(task=True)` background tasks, `add_extension()` capability-negotiated extensions, `@mcp.completion` argument completion. — *implications*: `add_extension()` is the plausible carrier for MCP Apps in FastMCP 4. ⚠️ **Could not confirm MCP Apps / `ui://` / `_meta.ui` support from FastMCP's own 4.0.x release notes** — they don't mention it; a third-party doc (fast-agent) describes "FastMCP Apps" as beta until FastMCP 4 stabilises. Worth one targeted follow-up before assuming our `ui_resource` host path has a server-side counterpart there. +- **Not found in window**: no AWS Lambda / serverless *adapter* changes in the 4.0.x notes; no tool-listing-mode changes. + +#### Agentic UI/UX patterns + +A thin week — only assistant-ui had dated in-window items. + +- **assistant-ui `safe-content-frame@0.0.29` (2026-09-03)** — added error codes to iframe failure diagnostics that **distinguish a missing shim from a failed start from a slow render** — three states an embedded-UI host otherwise collapses into one generic "didn't load". — https://github.com/Yonom/assistant-ui/releases — *fit*: pattern-only (Angular equivalent: extend the MCP App frame's header-shell state machine, which already has a shimmer state, with a discriminated failure reason). — *where it'd land*: the `ui_resource` header-only-shell → full-html promotion path. Today a `resources/read` that returns nothing **leaves the shimmer up indefinitely** with no way for the user to tell "server slow" from "server broken". Concrete, small, and it fixes a real dead-end in a surface we already ship. +- **assistant-ui `assistant-cloud@0.1.43` — dedupe concurrent message appends during persistence; coordinate anonymous auth across instances and browser tabs** — https://github.com/Yonom/assistant-ui/releases — *fit*: pattern-only, notable as **independent convergence** on problems we already hit (tab-switch duplicate invocation; the session single-flight guard — our answer was a DynamoDB lease + 409). Worth reading as a cross-check on whether a client-side dedupe belongs *in front of* our server lease rather than only behind it; it would cut the 409 rate the lease currently absorbs. +- **assistant-ui `assistant-stream@0.3.41` — "prevented stale Redis producers from finalizing replacement streams"** — https://github.com/Yonom/assistant-ui/releases — *fit*: nothing to change; confirms our dropped-SSE lease leak (#863) is a **generic failure class of resumable-stream architectures**, not something we invented. Useful as regression-test framing. +- **MCP Apps + ext-apps: no notable items this week** — latest MCP blog post 2026-08-22; visible ext-apps commit history stops 2026-08-12. No new SEPs, no new host-adoption announcements. ⚠️ Negative result from a single fetch of each page, not an exhaustive audit. +- **Anthropic news in window carried no UI/artifact/design item** — the only in-window post is the 2026-09-01 Fable 5.1 / Mythos 5.1 launch, which belongs to the frontier-model lane. — https://www.anthropic.com/news +- **Vercel AI SDK / Linear / Cursor / NN/g: nothing dated in window.** The AI SDK 6 features that *are* relevant — tool-execution **approval** for human-in-the-loop, and **`toModelOutput`** for shaping what a tool result contributes to the prompt — are not new this week and could not be dated inside it. Treat as a standing backlog reference. `toModelOutput` remains the cleanest external precedent for our own bounded-tool-result tenet. NN/g was not fetched (budget). + +#### Frontier model announcements + +- **⭐ Claude Fable 5.1 (2026-09-01), GA on Bedrock** — API id `claude-fable-5-1`; **$10/MTok input, $50/MTok output, prompt cache reads at $0.25/MTok — which the post explicitly calls a 75% reduction from previous pricing.** Mythos 5.1 is trusted-access only. — https://www.anthropic.com/claude-fable-and-mythos-5-1 — *relevance*: **a cache read at 0.025× input breaks the 0.1× constant** PR #914 just hardcoded into [`curated-models.ts:97`](frontend/ai.client/src/app/admin/manage-models/models/curated-models.ts:97) and the CLAUDE.md cache-economics note. The post gives **no cache-write rate, no context window, and no Bedrock inference-profile id or region** — a catalog row cannot be written from this source alone. — *unlocks*: if the cache-read cut extends beyond Fable, our heavy-cache-read workload gets cheaper with no code change — verifiable against prod `C#` rows. +- **OpenAI GPT-6 Astra (2026-09-03)** — new top-end model `gpt-6-astra`, on `v1/responses` and `v1/chat/completions`, but **tool calling is Responses-API-only**; it rejects `none` reasoning effort, custom `temperature`/`top_p`, and logprobs. — https://developers.openai.com/api/docs/changelog — *relevance*: directly on our Bedrock Mantle Responses path. Those param rejections are exactly the failure class the #915 `supported_params` guard handles — and an **empty** `supportedParams` bypasses that guard, so an Astra row added with an empty allowlist would 400 on every turn. Also reframes the queued "GPT-5.6 Terra + Luna" idea: the frontier tier moved beneath it. +- **OpenAI long-running Responses: async tool calling, mid-turn steering over WebSockets, and mid-conversation reasoning-effort changes that preserve cached prefixes (2026-09-03)** — https://developers.openai.com/api/docs/changelog — *relevance*: "mid-turn steering" is the same primitive as the steering spec committed on 2026-09-03 (`da579c93`) — worth reading their contract before we freeze ours, though theirs is WebSocket-based and our path is SSE over AgentCore `/invocations`. Async tool calling would interact with our `BeforeToolCall` consent-interrupt model. — *unlocks*: a vendor-validated design reference for the steering spec, and a precedent for changing effort **without busting the cached prefix**. +- **OpenAI error-taxonomy change (2026-09-02)** — rapid traffic growth now returns `429 slow_down`; transient infrastructure failures return `503 server_is_overloaded`; both may carry `Retry-After`. — https://developers.openai.com/api/docs/changelog — *relevance*: Mantle-path retry classification. Our `model_retry` SSE event derives `delaySeconds` from Strands' `EventLoopThrottleEvent`; a real `Retry-After` header would be a better signal than computed backoff if the Mantle client surfaces it. +- **Google and Meta: no items dated inside the window.** Gemini 3.7 Flash / 3.5 Transcribe, OpenAI's GPT-5.6 Sol price cut (08-21), the Assistants API shutdown (08-26), the prompt-caching dashboard and Ultrafast tier all predate 2026-08-28. + +#### Agent harness patterns + +Anthropic engineering blog: no posts in window (latest 2026-04-23). Claude Code shipped six versions in window (2.1.251–2.1.260), dates verified against CHANGELOG commit history. + +- **⭐ Claude Code now *names* the prompt-cache miss cause** — 2.1.260 added "a likely cause for prompt-cache misses (e.g. tool definitions or system prompt changed, idle past the TTL)" to `/cost` plus a `prompt_cache` status-line field; 2.1.251 added a per-session cache line (hit ratio, misses, tokens re-cached, warm/cold). — https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md — *relevance*: we already classify `miss_avoidable` / `partial_miss` / `miss_ttl_expired` in [`observability/prompt_cache.py`](backend/src/apis/shared/observability/prompt_cache.py:87) and persist all three fingerprints in [`prefix_fingerprint.py`](backend/src/agents/main_agent/session/hooks/prefix_fingerprint.py:89) — but **nothing derives a cause**; CLAUDE.md's debugging section instructs a human to hand-diff hashes between consecutive `C#` rows. — *unlocks*: derive `missCause` server-side from *which* fingerprint flipped, expose it on `GET /admin/costs/sessions/{id}/calls` and the anatomy page. Deletes the manual diff ritual, and `agentSwitched` already supplies a fourth cause label for free. +- **`SessionStart` resume hooks now receive session staleness + estimated re-cache cost; new `PreModelSwitch`/`PostModelSwitch` hook events** (2.1.251) — https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md — *relevance*: `TurnBasedSessionManager` restore-from-AgentCore-Memory has no equivalent — a cold restore silently pays a full prefix re-write, and our `@`-mention agent switch is hand-rolled with `agentSwitched` bookkeeping. — *unlocks*: emit an estimated re-cache cost at `_adopt_session_conversation` time as an EMF dimension, so `AgentCoreStack/PromptCache` separates **restore-driven** writes from **regression-driven** ones. +- **⭐ Three non-obvious cache busters fixed upstream, each with a direct analogue here** — "prompt cache being invalidated when the OAuth token refreshed" (2.1.259); "Remote Control connecting mid-session re-sending the Bash tool definition, causing a prompt-cache miss" (2.1.257); "blocking Stop hooks causing the turn after a block to lose the model's reasoning and, on some models, miss the prompt cache" (2.1.259). — https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md — *relevance*: we have a token vault + `oauth_token_cache` warm path, tool lists that change mid-session on consent/pre-flight recovery, and mid-turn steering that just shipped — each is a "something outside the turn mutated the cacheable prefix" shape. — *unlocks*: a targeted audit — does a vault token refresh or a post-consent tool re-registration reach `toolConfigHash`? Cheap to answer now that the fingerprints exist. +- **`--permission-prompts none` for unattended headless hosts** (2.1.259), paired with fixes for "remote and scheduled sessions doing nothing after a connector-tool permission prompt was approved while the session was paused" (2.1.259) and a re-sent approval failing with "user messages must have non-empty content" (2.1.258). — *relevance*: our scheduled/headless runs are deliberately ungated (kill-switch only), and our interrupt-resume path is exactly the paused-session-plus-approval shape that produced both upstream bugs. — *unlocks*: an explicit deny-on-prompt mode for scheduled runs is a cleaner contract than "ungated"; both bugs are worth reproducing against our OAuth-consent resume before we hit them. +- **`CLAUDE_CODE_SUBAGENT_MODEL_FORCE`** (2.1.257) — applies the configured subagent model to every subagent, ignoring per-spawn and agent-definition overrides. — *relevance*: `@`-mention delegation builds a second `Agent` on the mentioned agent's own model config; RBAC governs *which* models a user may reach but nothing lets an operator force delegated turns onto a cheaper one. — *unlocks*: a fleet-level "delegated turns run on Haiku" override as a harness-level cost control, orthogonal to RBAC grants. +- **Frameworks (ideas-only): no confirmably in-window items.** LangChain has an alpha `langchain.mcp` first-party adapter and Pydantic-AI v2.0.0 has a Capabilities API (composable bundles of instructions/tools/hooks/model settings — conceptually our "skills as an agent primitive"), but neither could be dated inside the window; Pydantic-AI v2.0.0 is 2026-06-23, out of window. + +#### opencode (anomalyco/opencode) + +A genuinely light week — all three in-window releases (v1.18.25 08-28, v1.18.26 09-01, v1.18.27 09-02) are patch-level bugfix drops. + +- **v1.18.27 — Anthropic thinking-block binding gated on model version** — adds a `thinking.blockBinding` opt-out and limits thinking-block binding to Claude 5.1+ "so older deployments do not reject requests." — https://github.com/anomalyco/opencode/releases/tag/v1.18.27 — *lens*: tooling — *relevance*: same pattern as our supported-params guard — shape the request per model, or older/managed model rows reject the call. Same failure class as the known gap where an **empty** `supportedParams` bypasses the #915 guard. +- **v1.18.27 — default 5-minute provider-header **and** streamed-chunk timeouts (`false` to disable), plus clean cancel of timed-out SSE reads** — two separate timeouts (connect vs inter-chunk) rather than one wall-clock budget. — *lens*: context — *relevance*: we have a single 600s SSE stream timeout and the anyio-CancelScope drop path. **An inter-chunk timeout distinct from total-stream timeout is a cheap idea we don't currently have** — it would have caught the 95.6s `ServiceUnavailableException` outage faster than the wall-clock budget did. +- **v1.18.26 — tool-call timing corrected for tools that mutate metadata while still running** — *lens*: tooling — *relevance*: our `tool_use`/`tool_result` SSE pair and the MCP Apps early-mount path, where a tool's frame goes live before its args finish streaming. +- Note: the notable delegation items in this series (`task_id`-resumable failed subagent calls, subagent permission prompts) landed in **v1.18.20, before the window** — flagged so a later scan doesn't double-count them. + +#### LibreChat + +- **v0.8.8-rc2 — MCP catalog refresh + per-user/server OAuth single-flight** — live MCP catalog refresh with request-scoped server attachment, OAuth token refresh single-flighted per (user, server) pair, and tool-key normalization resolving raw names via aliases (fixing server-name prefix collisions). — https://github.com/danny-avila/LibreChat/releases/tag/v0.8.8-rc2 — *lens*: MCP integration — *relevance*: direct parallel to our `oauth_token_cache` warm/refresh path and our scoped `toolId::name` per-tool enablement. **Their single-flight-per-(user,server) is the pattern our token-vault warm-up lacks under concurrent turns** — and a token refresh is one of the three cache busters Claude Code just fixed. +- **v0.8.8-rc2 — interrupt / steer / queue-follow-up as a first-class turn control** — "interrupt agent runs before visible output, steer or queue follow-ups, and recover tool-limited turns," with reclaim-gated controls, server-side queued messages, preemptive interrupt before the model response completes, and **delivery receipts confirming acceptance**. — *lens*: UI/UX + platform choices — *relevance*: our mid-turn steering spec (`da579c93`) and #916. The **delivery-receipt ack** and **preemptive interrupt before visible output** are two design points our spec does not currently cover — worth reading before we freeze it. +- **v0.8.8-rc2 — unified Agent Builder with standalone Skill authoring + background tools** — skills become independently authorable and mount under a `skills/` directory inside Code Interpreter environments; per-tool intent labels and toggles stored in YAML; actions and plugin tools can be flagged to run in the background. — *lens*: comparable-platform choices — *relevance*: skills-as-agent-primitive + Agent Designer. The **per-tool intent label** is a cheap idea for our tool-binding UI; background-flagged tools is a shape we don't have. +- **v0.8.8-rc2 — artifacts/chat UI: fullscreen artifacts, Mermaid export, PPTX templates, original-file download; attachment-only turns** — *lens*: UI/UX — *relevance*: artifacts + artifact sharing (#919/#920/#922). Fullscreen + export-from-artifact are pattern-only; **"attachment-only turns"** is worth noting against our attachment-cost work (attachments are 11% of sessions and 31% of prod spend). +- ⚠️ **Date caveat**: the GitHub release page rendered a publish date the fetcher read as "September 3, **2024**", inconsistent with the release's own content (GPT-5.6, Opus 5 / Fable 5.1, Gemini 3.8). Reported as in-window on that basis; the year was **not** independently verified. + +#### Pricing / quota + +Method this week: the **AWS Price List API**, enumerating all `AmazonBedrock` us-west-2 products (1,026) and `AmazonBedrockAgentCore` us-west-2 products (913), then diffing each against its immediately-prior published price-list version. Both services republished **2026-09-01**, inside the window, so the diffs are attributable to it. `https://aws.amazon.com/bedrock/pricing/` was not opened — the API path replaced it entirely and produced exact, reproducible figures. + +- **No price changes to any Bedrock or AgentCore SKU in the window.** Diffing `AmazonBedrock` us-west-2 `20260804165549 → 20260901205051` and `AmazonBedrockAgentCore` `20260722142809 → 20260901164424` yields **0 changed rates and 0 removals** on both. Nova Micro (our title-generation model) is byte-identical: input `$0.035/MTok`, output `$0.14/MTok`, cache-read `$0.00875/MTok`, cache-write `$0.00`. — *relevance*: no edit needed to `curated-models.ts` or `pricing_config.py` on price-change grounds. +- **⚠️⚠️ The Price List API does not carry Claude 4.x/5.x at all — our four headline models are unverifiable through it.** A full enumeration of every `usagetype` across the whole `AmazonBedrock` offer (**11,621 values, all regions**) returns exactly **10** Claude SKUs: `USE1/USW2-{Claude2.0, Claude2.1, Claude3Haiku, Claude3Sonnet, ClaudeInstant}-input-tokens` — input-token only, no output, no cache dimensions. There is **no** Haiku 4.5, Sonnet 4.6, or any Opus/Fable SKU. Meanwhile `aws bedrock list-foundation-models --region us-west-2` shows all of them ACTIVE. — *relevance*: **this contradicts last week's scan**, which reported verifying Haiku 4.5 and Sonnet 4.5 rates from this same API in this same region. One of the two reads is wrong, and CLAUDE.md now carries the sourcing claim *"rates live in `curated-models.ts` and come from the AWS Price List API, not the pricing page"* — which this scan could not reproduce for the models we actually run. **Resolve before trusting either.** See Risks. +- **The 10% Regional-over-Global premium is empirically confirmed — by proxy, at exactly 1.100×.** The 18 SKUs *added* in the 2026-09-01 Bedrock republish are all xAI Grok 4.6 (Mantle), and they ship both tiers side by side: input `global-standard $2.00` vs `standard $2.20`; output `$6.00` vs `$6.60`; cache-read `$0.50` vs `$0.55`; identically 1.100× across `flex` (`$1.00`/`$1.10`) and `priority` (`$3.50`/`$3.85`). **All nine Global/Regional pairs are exactly 1.1×.** — *relevance*: independent validation of PR #914's Global→Regional tier fix, and direct evidence that "prod rows are still 10% low" is a real under-billing gap, not a rounding artifact. The *ratio* survives even though the *provenance* of the absolute Claude rates does not. +- **⭐ New AgentCore pricing dimension: instance-based Runtime.** The AgentCore republish added **889 SKUs**, all `USW2-Runtime:Instance-based::Management-Hours` (c5/c5a/c5d/c6i/m5/m6i/m7i/r5/g5/p4d…) — e.g. `m5.large $0.01152/hr`, `c6i.xlarge $0.0204/hr`, `g5.xlarge $0.078468/hr`. A *management* fee alongside the instance itself; the ratio to EC2 on-demand looks uniform (~12%) across every family spot-checked, but EC2 base rates were not verified in this scan — treat that as derived, not published. **The consumption rates we actually pay are unchanged**: vCPU `$0.0895/vCPU-hr`, memory `$0.00945/GB-hr` (same for Code Interpreter and Browser). — *relevance*: **W5 (runtime memory) in the cost-effectiveness roadmap is the named gap and Runtime memory is 73% of the AICC bill.** A reserved/instance-based Runtime is now a purchasable alternative to the consumption model. — *unlocks*: the first genuinely new lever on W5 since it was named. +- **⭐ Runtime and built-in tools now emit an `ActiveSessionCount` CloudWatch metric** in `AWS/Bedrock-AgentCore`, published once per minute. — https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/release-notes.html — *relevance*: **this closes the blocker on a queued item.** The 2026-07-10 entry "Wire a CloudWatch `ActiveSessionCount` alarm on the inference-api runtime" was queued when no such metric existed; it does now. It is also a better idle-reaper/lifetime instrument than the `/ping` access-log proxy we currently use, and it slots straight into the `AlarmFactory` that #910 just made the only sanctioned way to create an alarm. +- **Quota / region: nothing new in the window.** The relevant changes predate it — default runtime quotas raised to 5,000 concurrent sessions in us-east-1/us-west-2 (200 interactions/sec, 25 new sessions/sec) in June/July 2026; region expansions in June and August. In-window AgentCore items are feature-only: Evaluations gained TypeScript-framework support, and AWS Agent Registry went GA with Organizations auto-detection. + +#### Community + GitHub issues + +- **⭐ MCP tool-definition schema drift: 14 of 248 public servers changed within 27 hours; 17 changes were schema/annotation-only with byte-identical descriptions.** A crawl of 248 public MCP servers twice in one day found 54 description rewrites, 9 tools added, 7 removed — and, the load-bearing number, **17 tool definitions whose input schema or annotations changed while the description text stayed exactly the same**. Affected servers included `notion-mcp-server` and `hostinger-api-mcp`. No MCP client re-prompts after initial approval, and descriptions are the only part a human reviews. — https://github.com/GautamTalksDev/mcp-pin/blob/main/docs/findings/2026-09-03-schema-drift.md — ⚠️ *self-published by the author of the tool it recommends; the method is sound and reproducible but the framing is promotional.* — *relevance*: this is a **prompt-cache contract** problem for us, not only a governance one. Our contract enforces determinism at *our* sources; an externally hosted MCP server silently editing a tool's input schema between turns re-writes the cacheable prefix from that block onward at 1.25× base input, with no description change for a human to spot. **Verified locally, and the news is good on the detection half**: `toolConfigHash` hashes `agent.tool_registry.get_all_tool_specs()` ([`prefix_fingerprint.py:89`](backend/src/agents/main_agent/session/hooks/prefix_fingerprint.py:89)) — full specs including `inputSchema`, so this drift class **is** visible to our cost attribution. What's missing is *explanation*: we'd see the hash flip with no deploy of ours and no way to name why. Baselining external Gateway target schemas would turn that into a reviewable diff — and it is the fourth `missCause` label for idea #3. +- **"Grep beats LSP?" — tool *availability* is not tool *adoption*, and result shape drives token cost more than tool accuracy.** Adoption of a richer semantic tool was 0–6% on simple localization tasks but 45–57% on reference-completeness work — task-routed, not habitual. Forcing LSP-first *dropped* pass rate 100% → 89%. Sharpest finding: adding inline source context to results cut follow-up file reads from 15.2 to 3.2 per episode; token effect was codebase-dependent (−12% on noisy repos, +16% on clean ones). — https://www.agentconnect.md/blog/grep-beat-lsp-harness/ — ⚠️ *single-author blog, self-reported numbers, no published harness — directionally useful, not citable as a result.* — *relevance*: two things. First, it argues the "bounded or offloaded, never unbounded pass-through" tenet **in reverse** — a tool returning *too little* costs more in follow-up turns than one returning a bounded snippet, so per-turn payload budgeting should optimise round-trips, not just bytes. Second, "availability ≠ adoption": a tool sitting in `toolConfig` on every turn at cache cost but selected 0–6% of the time is exactly the fleet-wide waste the `enabledByDefault=False` rule exists to prevent — **and we have no per-tool adoption metric to prove which registered tools earn their prefix.** +- **AgentCore SDK #659** (opened 2026-09-04) — `BedrockAgentCoreApp._safe_serialize_to_json_string` falls through to `json.dumps(str(obj))` when an event tree contains `bytes`, so the SSE `data:` line silently carries a JSON string of a Python repr. Reported trigger: Strands `AgentResult.to_dict()` carrying `reasoningContent.redactedContent`. — https://github.com/aws/bedrock-agentcore-sdk-python/issues/659 — *relevance*: **not our code path** — we never import `BedrockAgentCoreApp`; `inference_api/main.py` hand-rolls `/ping` + `/invocations`. But the *input class* is ours: every SSE emit in `inference_api/chat/routes.py` calls bare `json.dumps(...)` with no `default=`, so a `bytes` in `reasoningContent` would raise `TypeError` mid-stream rather than repr. Worth a defensive-serialization look **if we ever enable redacted reasoning**. +- **AgentCore SDK #567** — feature request for `tool_filters` on `AgentCoreToolSearchPlugin` so Gateway semantic search doesn't surface the whole catalog. The author's motivation is verbatim our thesis ("increases noise, wastes tokens, and can cause the agent to invoke tools it should not have access to"). Bumped 2026-08-28 with an unanswered "any insight on this?"; **no AWS response since 2026-07-06**. — https://github.com/aws/bedrock-agentcore-sdk-python/issues/567 — *relevance*: [`gateway_mcp_client.py:230`](backend/src/agents/main_agent/integrations/gateway_mcp_client.py:230) already carries the comment *"prefix and tool_filters are no longer supported in MCPClient constructor"* and applies filters manually at line 245 — **we already built the workaround this issue is asking upstream for.** No action; a +1 upstream is free. +- **AgentCore SDK #564 — still open, no movement**, last updated 2026-07-20 (46 days stale), 1 comment, no labels, no assignee, no linked PR. `AgentCoreMemorySessionManager` silently drops conversation history when metadata-filtered `ListEvents` is not yet consistent. — https://github.com/aws/bedrock-agentcore-sdk-python/issues/564 — *relevance*: confirms the 2026-08-14 queued note — neither the 1.21.0 bump nor 1.22.0 closed it. **The queued guard item stands as written.** +- **`bedrock-agentcore-starter-toolkit` is officially marked legacy** ("For new projects, use the AgentCore CLI") in favour of `aws/agentcore-cli`. Verified against the repository's own AWS-controlled description field, not the community comment that surfaced it. — https://github.com/aws/bedrock-agentcore-starter-toolkit — *relevance*: **we don't depend on it** (no pin anywhere), and the Dockerfile permission bug it carries (toolkit #574: `USER` before an un-chowned `COPY . .`) does not affect us — [`backend/Dockerfile.inference-api:55-59`](backend/Dockerfile.inference-api:55) does `useradd` + `chown -R` before `USER`, correctly. Carry forward only as a **source-list note**: this skill should stop tracking the starter toolkit and track `aws/agentcore-cli` instead. +- ⚠️ **Method caveat on community coverage**: HN Algolia's `search_by_date` returns strictly newest-first and 60 hits only reached back to 2026-09-03T20:56 — roughly the last 18 hours, **not the full 7-day window**. Older in-window stories were never paged through. Fix for next week: use relevance-ranked `search`, or paginate `search_by_date` until `created_at` crosses the window start. Reddit was skipped (budget). + +#### Cookbook / courses + +Three commits in window; scanned entirely through the GitHub API at zero web cost. + +- **⭐ `feat(claude_agent_sdk): add scheduled repository reviewer` (a97b9a2d, 2026-09-03, #860)** — a cron-driven read-only review agent that persists its session id, `resume=`s it each cycle so each verdict links to the prior one, caps every run with **`max_budget_usd`** (separate cold vs follow-up budgets) and `max_turns`, returns `output_format={"type":"json_schema",…}`, and scopes reads with `allowed_tools` plus a deny-hook. Failure semantics are explicit: terminal error → exit 1; a resume that no longer resolves clears the session file and next cycle runs cold. — https://github.com/anthropics/anthropic-cookbook/blob/main/claude_agent_sdk/scheduled_repository_reviewer/scheduled_repository_reviewer.ipynb — *relevance*: **our scheduled runs are kill-switch-only with no per-run spend ceiling** (verified: no `max_budget`/`budget_usd` symbol exists anywhere in `backend/src`), and the quota-cooldown spec's `$30` backstop is account-level, not per-invocation. `max_budget_usd` is exactly the missing primitive. The persisted-session-id-with-cold-fallback pattern is also a cleaner statement of our resume/lease handling. — *ports to Bedrock?*: **partly** — it's the Claude Agent SDK (harness-level), not Converse, so nothing lifts verbatim into Strands; `max_budget_usd`/`max_turns`/deny-hooks have no Converse equivalent and would be ours to implement in the agent loop. Note Strands' own `Limits` is already queued (2026-07-17) for the same lane. +- **⭐ `docs(managed_agents): remove cost and latency ratios from CMA_plan_big_execute_small` (bbfab1bb, 2026-08-28, #851)** — a **walkback**, not a feature. The "roughly 2.5× cheaper and 3× faster" coordinator-vs-solo claim was struck from both intro and conclusion, leaving only "84–98% of the team's input tokens billed at the worker rate," and the rigor caveat was softened. The surviving pre-existing caveat is the interesting one: *"Delegation has a floor cost… splitting the same work into more, narrower briefs raised our bill instead of lowering it."* — https://github.com/anthropics/anthropic-cookbook/commit/bbfab1bb — *relevance*: **direct corroboration of our own G1 read** (agent-cache cost thesis disproven, latency win instead). If Anthropic retracted its own measured delegation ratios as run-to-run noise, any sub-agent-delegation cost case we build needs our own `GET /admin/costs/sessions/{id}/calls` numbers, not a cited multiplier. +- **`Retire claude-opus-4-1 across cookbooks` (26b5cdce, 2026-09-02, #807)** — `claude-opus-4-1` swapped out of ~25 notebooks. — *relevance*: minor. **Checked: `opus-4-1` does not appear in `curated-models.ts`** — nothing to do. +- **Queued-item check**: `cost_optimization/` was **not** touched in the window (its only commit is 2026-08-12). The 2026-08-14 queued item still points at the same content it was queued against — **no re-scope needed, and no new-material excuse to defer it again.** + +#### Seasonal + +Out of window — re:Invent is late Nov/early Dec, and no conference proceedings dropped. **None scanned this week.** + +### Patterns worth considering + +- **The cacheable prefix is mutated by things that are not turns.** Claude Code shipped three fixes in one week for cache busters caused by an OAuth token refresh, a mid-session tool-definition re-send, and a blocking hook. The MCP schema-drift crawl adds a fourth: an external server editing its own input schema. Our contract, our fingerprints, and our `cacheStatus` classifier are all built around *our* determinism — ordering at our sources, byte-stable restored history. None of them account for an out-of-band mutation. + - **Where**: `oauth_token_cache` warm/refresh, post-consent tool re-registration, external Gateway MCP targets, mid-turn steering (new). + - **Fit**: we already have the instrument (`toolConfigHash` covers full tool specs including schemas). We lack the *attribution*. Cheap to close. + - **Verdict**: **Worth trying** — folded into idea #3. + +- **Per-run budget ceilings as a harness primitive, not an account backstop.** The cookbook's `max_budget_usd`, Strands' `Limits`, Claude Code's `--permission-prompts none`, and opencode's split connect/inter-chunk timeouts are all the same move: bound an *unattended invocation* at the harness level rather than trusting a global quota. Our scheduled runs are deliberately ungated with a kill switch, and our only ceiling is the account-level `$30`. + - **Where**: scheduled/headless runs, `@`-mention delegation, the interrupt-resume path. + - **Fit**: the queued 2026-07-17 Strands `Limits` item is the right vehicle; this week gives it three independent corroborations and one concrete missing sub-primitive (per-run USD, not just turns). + - **Verdict**: **Worth trying** — strengthens an existing queue entry rather than adding one. + +- **Sessionless MCP as the end of the `initialize` era.** Spec (2026-07-28), FastMCP 4.0 (2026-08-31) and Strands' active MCP-client churn are three independent pressures on the same seam. Anything we derive from `initialize` — capability advertisement, `serverInfo` name/icon, per-session assumptions in the sandbox proxy — is on borrowed time. + - **Where**: `integrations/mcp_apps.py` (the `ClientSession` symbol patch), the mcp-sandbox proxy, the OAuth pre-flight. + - **Fit**: this is a migration we were already going to do; the week turned "eventually" into "the servers already shipped it". + - **Verdict**: **Worth trying** — idea #2. + +- **Cost-model constants are becoming per-model contracts.** Fable 5.1's 0.025× cache read, OpenAI's cache-preserving effort changes, Grok's dual-tier SKUs, AgentCore's new instance-based Runtime dimension. Every one of these breaks a "one ratio fits all" assumption. + - **Where**: `curated-models.ts` derivation helper, `pricing_config.py`, the CLAUDE.md contract. + - **Fit**: direct, and we hardcoded the assumption three days before it broke. + - **Verdict**: **Worth trying** — idea #1. + +- **Independent convergence on our own bug list.** assistant-ui shipped a stale-producer-finalizes-replacement-stream fix and a cross-tab duplicate-append dedupe; Strands is now hitting our two-managers-over-one-row hazard upstream (#4172); Anthropic retracted the delegation cost ratios our G1 read had already disproven. + - **Fit**: nothing to build. The value is calibration — these are generic failure classes of the architecture, not local mistakes, which argues for regression tests over redesign. + - **Verdict**: **Monitor.** + +## Internal Audit + +### Activity (last 7 days) + +- **Commits on `develop`**: 43 (non-merge), across 16 merged PRs (#898–#924) +- **PRs opened**: 16 — **merged**: 16 — **reverted**: 0 — **open at scan time**: 0 into `develop` +- **Issues opened**: 0 — **closed**: 0 +- **CI failures**: **none in the window.** `gh run list --status=failure --limit 30` returns nothing newer than 2026-04-03 — the failure list is entirely historical. **CI has been green for five months of runs.** +- **Releases**: v1.17.0 shipped 2026-09-02 (observability baseline + a production-outage post-mortem's four chat-path fixes + four security findings) + +Churn by area (files touched, 7 days): + +| Area | Touches | +|---|---| +| `infrastructure/test` | 70 | +| `infrastructure/lib/constructs/observability` | 30 | +| `backend/src/apis/shared/kb_backend` | 21 | +| `backend/tests/shared` | 19 | +| `.kiro/specs/managed-kb-migration` | 17 | +| `backend/src/apis/app_api/kb_migration` | 11 | +| `frontend/.../session/components/chat-input` | 9 | + +### Repeated friction signals + +- **The managed-KB migration is the dominant source of defects in the repo, by a wide margin** (10 commits in 7 days; **17 `kb` commits in 14 days**; 39 numbered findings in `.kiro/specs/managed-kb-migration/HANDOFF.md`, with findings 38–39 landing this week). Every one of the last eleven was found by *running it*, not by review — the handoff document says so explicitly, and §5.31 is the proof that the local driver cannot see IAM defects because its SSO identity is broader than either Lambda role. + - **Hypothesis**: not a code-quality problem — a **verification-surface** problem. The feature is a multi-service state machine (DynamoDB + S3 + Bedrock Managed KB + EventBridge + four Lambdas) whose failure modes are all asynchronous, IAM-shaped, or eventual-consistency-shaped. None of those are reachable from a local driver or a unit test. + - **Fix candidate**: this is already correctly diagnosed in the handoff's "Do these first" — *drive a migration from the deployed dispatcher, not the local driver*. Worth elevating from a checklist line to a **gate**: no further KB findings should be closed from local runs. The practice that has repeatedly paid (§4 mutation testing) should be named in `CLAUDE.md` if it isn't yet. + - **Not a proposal for the Top 5** — the team already knows. Recording it because 39 defects in one feature is the loudest internal signal in the repo and it deserves to be on the record as a *methodology* win, not a quality worry. +- **A correction that landed in the docs did not land in the code.** PR #914 fixed the `$2.50/MTok` cache-write premium in `CLAUDE.md`; the same wrong constant survives in **six** other places (enumerated under idea #1). This is the second consecutive week the cost model has needed correcting, and the pattern is that the *authoritative* statement moved while its *copies* did not. + - **Hypothesis**: the figure was duplicated as prose into comments and test docstrings, where nothing enforces it. + - **Fix candidate**: idea #1 — and, structurally, prefer a single named constant or a doc-link over a re-typed number in the three code sites. +- **Observability construct churn (30 touches) is post-ship settling, not friction.** #910 added 77 alarms and a source-level guard that fails the build on `new cloudwatch.Alarm()` or a hardcoded `RetentionDays`. High churn one week after a construct of that size is expected. + +### Version-pin lag + +| Dep | Pinned | Latest | Lag | Notes | +|---|---|---|---|---| +| `strands-agents` | 1.51.0 (2026-08-07) | 1.54.0 (2026-08-27) | 3 releases / 20 days | ⚠️ **1.53.0 is breaking against our cachePoint layout** — see idea #4. 1.54.0 adds external cancellation + `Agent.session_id` | +| `strands-agents-tools` | 0.8.6 (2026-08-07) | 0.8.7 (2026-08-28) | 1 release / 21 days | Patch only | +| `bedrock-agentcore` | 1.21.0 (2026-08-06) | 1.22.0 (2026-08-18) | 1 release / 12 days | Sole functional change is payments (MPP, x402) — **touches no construct we use.** No upgrade pressure | +| `boto3` | 1.43.68 (2026-08-10) | 1.43.88 (2026-09-03) | 20 releases / 24 days | Daily-cadence package; the count is noise. Floor pinned in 4 files | +| `fastapi` | 0.136.1 (2026-04-23) | 0.141.1 (2026-07-29) | 26 releases / 97 days | **Largest backend gap** — 5 minors. Latest is itself 37 days old | +| `pydantic` | not directly pinned | n/a | n/a | Transitive via fastapi/strands | +| `mcp` | not directly pinned | n/a | n/a | Transitive | +| `@angular/core` | 21.2.17 (2026-06-10) | 22.1.5 (2026-09-03) | 24 stable / 85 days | **One major behind.** v22.0.0 shipped 2026-06-03 — *before* our pinned 21.2.17. `v21-lts` is 21.2.22: **5 free in-line patches with no major bump** | +| `vitest` | 4.1.5 (2026-04-21) | 5.0.0 (2026-09-03) | 10 stable / 135 days | **v5.0.0 landed yesterday.** Within v4, 4.1.11 is 6 patches ahead. `@vitest/coverage-v8` is pinned to the same 4.1.5 and must move together | +| `typescript` (frontend + infra) | 5.9.3 (2025-09-30) | 7.0.2 (2026-07-08) | 3 stable / **281 days** | **Two majors behind** (6.0.x, then the 7.0.x native-port line). 5.9.3 is the terminal 5.9 patch — no in-line path; any move is a major. **Oldest pin in the repo**, and Dependabot's 6.0.2 attempts have failed CI repeatedly since March | +| `aws-cdk-lib` | 2.262.0 (2026-07-22) | 2.268.0 (2026-09-02) | 8 releases / 42 days | Bundles its own deps — npm overrides cannot patch transitives | +| `constructs` | 10.6.0 (2026-03-23) | 10.8.1 (2026-08-03) | 5 releases / 133 days | Slow-moving; latest is 32 days old | +| `@analogjs/vite-plugin-angular` | 3.0.0-alpha.53 (2026-04-29) | 3.0.0-alpha.86 (2026-09-04) | 33 alphas / 128 days | ⚠️ Pinned on the **alpha** channel; the `latest` dist-tag is **2.7.1**, a *lower* version — plain "latest" comparisons are misleading here | +| `@analogjs/vitest-angular` | 3.0.0-alpha.30 (2026-04-13) | 3.0.0-alpha.86 (2026-09-04) | 56 alphas / 144 days | ⚠️ **26 alphas behind its own sibling.** The two Analog packages being out of sync with *each other* is the actionable finding, not the absolute lag | + +Note: `@analogjs/platform` is **not** a dependency of this repo — the skill's tracked-dep list should name the two packages above instead. + +### Retirement candidates + +- **The six stale `$2.5/MTok` sites** — [`model_config.py:380`](backend/src/agents/main_agent/core/model_config.py:380), [`turn_based_session_manager.py:19`](backend/src/agents/main_agent/session/turn_based_session_manager.py:19), [`test_compaction_stability.py:8`](backend/tests/agents/main_agent/session/test_compaction_stability.py:8), [`test_prompt_cache_observability.py:464`](backend/tests/shared/test_prompt_cache_observability.py:464), and [`compaction-over-threshold-cache-spiral.md:13,252`](docs/specs/compaction-over-threshold-cache-spiral.md:13). Superseded by PR #914 in `CLAUDE.md` and not updated. Idea #1. +- **The `ClientSession` symbol patch** in [`mcp_apps.py`](backend/src/agents/main_agent/integrations/mcp_apps.py:23) — a monkeypatch on a Strands internal (`strands.tools.mcp.mcp_client.ClientSession`), taken because the SDK exposes no hook to customise `initialize` capabilities. Strands 1.53 is actively changing that client (MCP OAuth over HTTP), and the MCP spec is retiring `initialize` outright. Idea #2. +- **The `/ping` access-log lifetime proxy** as our AgentCore Runtime instrument — superseded by the real `ActiveSessionCount` CloudWatch metric that now exists. Idea #5. +- **`bedrock-agentcore-starter-toolkit` as a tracked source in this skill** — AWS marks it legacy in favour of `aws/agentcore-cli`, and we have no pin on it. Swap the source-list entry. +- **The `kaizen-research` Strands changelog URL** — `raw.githubusercontent.com/strands-agents/sdk-python/main/CHANGELOG.md` now 404s; the repo is a multi-language monorepo with interleaved `python/` and `typescript/` tags. +- **Dormant skills** (not modified in 60+ days): `.claude/skills/angualar-best-practices/SKILL.md` (2025-12-30, **248 days** — note the typo in the directory name, `angualar`) and `.claude/skills/frontend-design/SKILL.md` (2026-01-18, **229 days**). Both are reference skills that plausibly still earn their place by being *read* rather than *edited*, so this is a **flag for a decision, not a recommendation to delete** — but the misspelled directory is worth fixing regardless, since it is the name a `/`-invocation has to type. Every other skill was touched 2026-08-11 or later. + +### Risks introduced this week + +- **⚠️⚠️ Two consecutive scans disagree about whether the AWS Price List API carries Claude 4.x/5.x rates.** Last week's scan reported verifying Haiku 4.5 and Sonnet 4.5 four-field rates from `AmazonBedrock` in us-west-2 and drove PR #914 from them. This week's scan enumerated **11,621 `usagetype` values across all regions** and found exactly 10 Claude SKUs, none newer than Claude 3, none with cache or output dimensions. — *what breaks if we ignore this*: `CLAUDE.md` now states as fact that our rates "come from the AWS Price List API, not the pricing page." If that is not reproducible, the sourcing note is wrong and **the next person to re-derive rates will not be able to**, which is exactly the failure that produced the original error. The *ratio* work survives independently (the 1.100× Regional premium is confirmed on nine Grok 4.6 SKU pairs published this week), but the **absolute Claude rates in `curated-models.ts` currently have no reproducible provenance.** Resolve before the next rate edit. Folded into idea #1. +- **⚠️ A routine `strands-agents` bump is now a prompt-cache change.** 1.53.0 makes `strategy="auto"` place a system-prompt cache point that we also place by hand, and the comment at [`agent_factory.py:222`](backend/src/agents/main_agent/core/agent_factory.py:222) asserts the opposite as an invariant. — *what breaks*: a duplicated or relocated system cachePoint, i.e. a full prefix re-write at 1.25× base input **on every session**, shipped by a bump that looks routine. The reference repo took 1.53→1.54 without incident, but **they do not hand-place a system cachePoint**, so that is not evidence for us. +- **⚠️ `cacheRead = input × 0.1` is now provably wrong for at least one GA Bedrock model.** Fable 5.1 reads at 0.025×. The helper at [`curated-models.ts:97`](frontend/ai.client/src/app/admin/manage-models/models/curated-models.ts:97) will silently produce a 4× overstatement for any Fable row an admin adds. — *what breaks*: per-session cost, `wastedUsd`, the cost anatomy, and the G0–G3 gates — in the *opposite* direction from last week's error, for a model that is GA today. +- **⚠️ Externally hosted MCP servers can re-write our cacheable prefix with no deploy of ours, and nothing explains it.** Measured in the wild at 17 schema/annotation-only changes across 248 servers in 27 hours. Our `toolConfigHash` **detects** it; nothing **names** it. — *what breaks*: an unexplained `miss_avoidable` in the cost anatomy that a human will spend hours failing to attribute to any change we made. +- **⚠️ FastMCP 4.0 is breaking for the MCP servers this stack consumes.** They are separate repos, outside this one, so nothing here fails a build — the drift is invisible from this side. The snake_case field rename warns rather than errors, so a server repo can drift for weeks before anyone notices. — *what breaks*: nothing today; a server upgrade done casually. +- **Prod still carries the 10% rate-tier understatement.** Not new this week, and the runbook is on this branch — recorded so it isn't lost between the runbook and the review. + +## Ideas — Top 5 (ranked) + +| # | Idea | Surface | Effort | Impact | Subtracts? | Unlocks? | +|---|---|---|---|---|---|---| +| 1 | Finish the #914 rate correction — six stale sites, a per-model cache-read ratio, and a provenance claim that doesn't reproduce | docs / backend / frontend | **L** | **H** | yes — 6 duplicated wrong constants + a hardcoded ratio that is already false | — | +| 2 | Migrate the MCP Apps host off `initialize` to `server/discover` — FastMCP 4.0 made it real | backend | **M–H** | **H** | yes — the `ClientSession` monkeypatch on a Strands internal | sessionless transport (kills fresh-session-per-call), a cheap pre-flight that doesn't 401 | +| 3 | Derive `missCause` from the fingerprint that flipped | backend | **L–M** | **H** | yes — the manual hash-diff ritual documented in CLAUDE.md | names external MCP schema drift as a first-class, otherwise-invisible cause | +| 4 | Gate the Strands 1.51 → 1.54 bump on the system-cachePoint collision | backend | **M** | **H** | yes — our hand-placed system cachePoint + a comment asserting a false invariant | external cancellation signal; `Agent.session_id` as a native session pin | +| 5 | Close the `ActiveSessionCount` alarm item — the metric now exists — and scope instance-based Runtime against W5 | infrastructure | **L** + **M** | **M–H** | yes — the `/ping` access-log proxy as our runtime-lifetime instrument | first new lever on W5 (73% of the AICC bill) since it was named | + +### 1. Finish the #914 rate correction — six stale sites, a per-model cache-read ratio, and a provenance claim that doesn't reproduce + +- **Source**: internal (PR #914, merged 2026-09-03) + https://www.anthropic.com/claude-fable-and-mythos-5-1 + AWS Price List API enumeration (`AmazonBedrock`, 11,621 `usagetype` values) +- **Surface area**: [`model_config.py:380`](backend/src/agents/main_agent/core/model_config.py:380), [`turn_based_session_manager.py:19`](backend/src/agents/main_agent/session/turn_based_session_manager.py:19), [`test_compaction_stability.py:8`](backend/tests/agents/main_agent/session/test_compaction_stability.py:8), [`test_prompt_cache_observability.py:464`](backend/tests/shared/test_prompt_cache_observability.py:464), [`compaction-over-threshold-cache-spiral.md:13,252`](docs/specs/compaction-over-threshold-cache-spiral.md:13), [`curated-models.ts:96-97`](frontend/ai.client/src/app/admin/manage-models/models/curated-models.ts:96), and the prompt-cache contract bullet in `CLAUDE.md` +- **Change**: three steps, ascending in cost. + 1. **Strike the six stale `$2.5/MTok` sites.** #914 fixed the contract in `CLAUDE.md` and left every copy. The one at `model_config.py:380` is inside the 40-line cachePoint-budget comment that a reader consults precisely when reasoning about cache cost — it is the worst place for the number to be wrong. + 2. **Make the cache-read ratio per-model.** `cacheReadPricePerMillionTokens: round(input * 0.1)` is a derivation, and Fable 5.1 (GA on Bedrock, 2026-09-01) reads at **0.025×** — Anthropic states the 75% cut explicitly. The fix is not a second constant; it is accepting a cache-read rate as an input to the helper with `0.1` as a documented default, so the next model that breaks the ratio is a data change rather than a code change. + 3. **Resolve the provenance conflict, then correct the sourcing note.** This scan could not find a single Claude 4.x/5.x SKU in the Price List API; last week's drove a merged PR from exactly those figures. Re-run last week's query verbatim. Whichever way it resolves, `CLAUDE.md`'s "come from the AWS Price List API" needs to name the source that actually reproduces — a claim about provenance that doesn't reproduce is how the original error survived three scans. +- **Subtracts**: six duplicated wrong constants, one hardcoded ratio that is already false for a GA model, and one unreproducible sourcing claim. +- **Effort × Impact**: **Low × High** +- **Verdict**: **Worth trying.** Cheapest item in the scan and upstream of every cost number we compute — the same position last week's #1 held, for the same reason: nothing else is trustworthy until this is. + +### 2. Migrate the MCP Apps host off `initialize` to `server/discover` + +- **Source**: https://modelcontextprotocol.io/specification/2026-07-28/changelog (SEP-2567/2575) + https://github.com/jlowin/fastmcp/releases (4.0.0, 2026-08-31) + Strands 1.53.0 MCP-client changes. Upgrades the queued 2026-08-14 entry. +- **Surface area**: [`integrations/mcp_apps.py`](backend/src/agents/main_agent/integrations/mcp_apps.py) (the `ClientSession` symbol patch at lines 23–32 and the `serverInfo` capture at line 673), [`external_mcp_client.py`](backend/src/agents/main_agent/integrations/external_mcp_client.py), [`gateway_mcp_client.py`](backend/src/agents/main_agent/integrations/gateway_mcp_client.py), the mcp-sandbox proxy origin, and the OAuth pre-flight path (PR #872) +- **Change**: resolve server identity (`serverName` / `icon`) and extension capability from `server/discover` rather than the `initialize` `serverInfo`, with `initialize` retained as a fallback for servers that haven't migrated. Retire the `ClientSession` monkeypatch if `server/discover` gives us a supported seam for capability advertisement. +- **Subtracts**: the symbol patch on `strands.tools.mcp.mcp_client.ClientSession` — a monkeypatch taken *because* the SDK offered no hook, on a class Strands is actively changing, to participate in a handshake the spec is retiring. Three independent reasons it will break, none of which we control. +- **Unlocks**: + - **Sessionless transport** removes the fresh-MCP-session-per-call cost behind the MCP Apps proxy-call 504 — our Lambda servers have been faking this with `stateless_http=True`, and FastMCP 4.0 makes it a first-class protocol path *with* per-user state (SEP-2567 `UserSession`/`SessionId`). + - **A pre-flight that doesn't 401.** `server/discover` reads capabilities *before* `tools/list`, which is the exact call whose 401 permanently dropped a tool until PR #872 worked around it three wrappers down. + - **SEP-2549 cache hints** and **SEP-2243 routable headers** become reachable — the first is directly on-thesis for tool-listing token cost, the second for AgentCore Gateway target routing. +- **Effort × Impact**: **Med–High × High** +- **Verdict**: **Worth trying**, but scope it as a spike first. The unknowns are whether AgentCore Gateway speaks `server/discover` at all, and whether Strands' MCP client exposes it — both are answerable in an afternoon and both gate the estimate. ⚠️ The spec details here came from search summaries, not a first-hand changelog read — verify first. + +### 3. Derive `missCause` from the fingerprint that flipped + +- **Source**: https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md (2.1.260, 2.1.251) + the MCP schema-drift crawl (https://github.com/GautamTalksDev/mcp-pin/blob/main/docs/findings/2026-09-03-schema-drift.md) +- **Surface area**: [`observability/prompt_cache.py`](backend/src/apis/shared/observability/prompt_cache.py) (where `cacheStatus` is derived), [`session/hooks/prefix_fingerprint.py`](backend/src/agents/main_agent/session/hooks/prefix_fingerprint.py) (where the three hashes are computed), `GET /admin/costs/sessions/{id}/calls`, the admin cost-anatomy page, and the debugging section of `CLAUDE.md` +- **Change**: on the row where `cacheStatus` is already computed, compare each of `toolConfigHash` / `systemPromptHash` / `historyHash` against the previous `C#` row for the session and persist a `missCause` label — `tools_changed`, `system_prompt_changed`, `history_changed`, `agent_switched` (already available for free from the `agentSwitched` flag), `ttl_expired`, `cold_start`. Surface it in the calls API and the anatomy page. +- **Subtracts**: the manual hash-diff ritual that `CLAUDE.md`'s debugging quick-reference currently instructs a human to perform — *"the hash that changed between consecutive calls names the cache-buster"* — every time a cost spike is investigated. We already store everything needed; nobody has written the eight-line comparison. +- **Unlocks**: **naming a cause we currently cannot see at all.** An external MCP server that edits a tool's `inputSchema` between turns re-writes our prefix with no deploy of ours and no description change for a human to spot — measured in the wild at 17 such changes across 248 servers in 27 hours. Verified locally that `toolConfigHash` hashes full tool specs including schemas, so we already **detect** it; a `tools_changed` label on a row with no deploy is the first time we'd be able to **attribute** it. Claude Code shipped exactly this feature in 2.1.260, which is corroboration that the ergonomics are worth it. +- **Effort × Impact**: **Low–Med × High** +- **Verdict**: **Worth trying.** The cheapest high-leverage item after #1 — the instrument exists, the data is persisted, only the interpretation is missing. + +### 4. Gate the Strands 1.51 → 1.54 bump on the system-cachePoint collision + +- **Source**: https://github.com/strands-agents/sdk-python/releases (python/v1.52.0, v1.53.0, v1.54.0) + https://github.com/strands-agents/sdk-python/issues/4168. Sharpens the queued 2026-08-28 entry with verified specifics. +- **Surface area**: [`core/model_config.py:375-400`](backend/src/agents/main_agent/core/model_config.py:375) (`strategy="auto"` and the `bedrock_cache_points_supported()` gate), [`core/agent_factory.py:213-224`](backend/src/agents/main_agent/core/agent_factory.py:213) (the hand-placed system `cachePoint` and its now-false comment), `TurnBasedSessionManager` (against 1.52's tool-pair trimming), `backend/pyproject.toml:59,74` +- **Change**: treat this as an instrumented experiment, not a bump. Specifically: (a) determine whether 1.53's auto-placed system point **duplicates or replaces** ours, and delete our hand-placed `SystemContentBlock` list if it duplicates; (b) rewrite the comment at `agent_factory.py:222`, which asserts *"auto strategy strips only message-level cachePoints, never system ones"* — a 1.51-era fact; (c) check whether 1.52's "trim at complete tool pairs" is even reachable given our custom session manager, because two trimmers choosing different boundaries is the byte-instability the compaction redesign exists to prevent; (d) **keep the `bedrock_cache_points_supported()` gate** — upstream issue #4168 (filed 2026-09-04) is a live report of the exact crash it prevents, so it is load-bearing, not redundant. Measure before/after on `toolConfigHash`/`systemPromptHash` and the `AgentCoreStack/PromptCache` EMF metrics. +- **Subtracts**: potentially our hand-placed system cachePoint and the ~40-line comment defending it — the library-native subtraction this skill weights for, *if* 1.53's placement is equivalent. Definitely subtracts a comment that now asserts a false invariant. +- **Unlocks**: 1.54's external cancellation-signal injection (a candidate to retire hand-rolled cancel plumbing around the anyio `CancelScope` drop path) and `Agent.session_id` (the runtime session pin the G1 read had to establish by hand). +- **Effort × Impact**: **Med × High** +- **Verdict**: **Worth trying**, with a caution. The reference repo runs 1.54 without incident, but they do **not** hand-place a system cachePoint — do not read their green build as evidence for us. This is a cost regression that ships silently and looks like a routine dep bump, which is the worst combination we have. + +### 5. Close the `ActiveSessionCount` alarm item, and scope instance-based Runtime against W5 + +- **Source**: AWS Price List API (`AmazonBedrockAgentCore` us-west-2, 889 new `Runtime:Instance-based:*:Management-Hours` SKUs in the 2026-09-01 republish) + https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/release-notes.html. Unblocks the queued 2026-07-10 entry. +- **Surface area**: `infrastructure/lib/constructs/observability/` (the `AlarmFactory` #910 made mandatory), the AgentCore Runtime construct, and `project_cost_effectiveness_roadmap.md` W5 +- **Change**: two parts, deliberately different sizes. + 1. **(Low)** Wire the `ActiveSessionCount` alarm. The 2026-07-10 queue entry was blocked on the metric not existing; `AWS/Bedrock-AgentCore` now publishes it once per minute for Runtime and built-in tools. It goes through `AlarmFactory`, so it is routed to the `{prefix}-alarms` topic as a consequence of being created. + 2. **(Med)** Scope instance-based Runtime as a W5 lever. Runtime memory is **73% of the AICC bill** and W5 is the roadmap's named gap with no proposal against it. There is now a purchasable alternative to the consumption model — 889 SKUs of `Management-Hours` pricing across c5/c6i/m5/m6i/m7i/r5/g5 families (`m5.large $0.01152/hr`, `c6i.xlarge $0.0204/hr`). The question to answer is the crossover point: at our measured microVM lifetimes (18–50 min post-#827) and session concurrency, does a reserved instance beat `$0.0895/vCPU-hr` + `$0.00945/GB-hr`? That is arithmetic against numbers we already have, not a build. +- **Subtracts**: the `/ping` access-log as our runtime-lifetime instrument — a proxy we adopted because no real metric existed. A real one now does. +- **Unlocks**: the **first new lever on W5 since it was named.** Every prior cost win has been on the token side; this is the compute side, and it is the larger share. +- **Effort × Impact**: **Low (alarm) + Med (W5 scope) × Med–High** +- **Verdict**: **Worth trying** — ship the alarm now, and let the W5 arithmetic decide whether part 2 becomes a proposal. Note the ~12% management-fee-to-EC2 ratio was *derived* in this scan, not published; verify EC2 base rates before building a case on it. + +## Take + +The system is trending **toward** the ecosystem, and this week it was the ecosystem that moved under us rather than the reverse. Three of the five ideas exist because upstream changed something we had already built on: FastMCP shipped the sessionless protocol our Lambda servers were faking, Strands moved the cachePoint floor we hand-placed above, and a GA Bedrock model broke a pricing ratio we canonicalised three days earlier. That is a healthy position — it means our abstractions are close enough to upstream's that upstream's progress lands on us — but it means routine bumps are no longer routine on the model call path. + +The most uncomfortable finding is not on the list as an idea: **last week's correction may itself need correcting.** Two consecutive scans, running the same query against the same API in the same region, disagree about whether Claude 4.x/5.x rates exist there. The tier ratio survives on independent evidence, so PR #914's substance is probably safe — but a provenance claim that doesn't reproduce is exactly how the original `$2.50/MTok` error survived three scans without anyone catching it. + +If one thing ships, ship **#1** — it is a few hours, it removes six wrong constants and a ratio that is already false for a GA model, and nothing downstream of it is trustworthy until it lands. What Phil would notice first, though, is **#3**: the next time a session shows an unexplained cache miss, the cost anatomy would say *why* instead of handing him three hashes and a diff instruction. + +--- + +## Sources Scanned + +| # | Source | URL | Accessed | Items | +|---|---|---|---|---| +| 1 | AWS What's New (recent feed) | https://aws.amazon.com/about-aws/whats-new/recent/feed/ | 2026-09-04 | 4 | +| 2 | AgentCore release notes | https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/release-notes.html | 2026-09-04 | 2 dated Sept; August section undated | +| 3 | AWS ML blog listing | https://aws.amazon.com/blogs/machine-learning/ | 2026-09-04 | 4 | +| 4 | AgentCore Consent Portal docs | https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/identity-consent-portal.html | 2026-09-04 | 1 | +| 5 | Strands Agents releases | https://github.com/strands-agents/sdk-python/releases | 2026-09-04 | 3 releases (1.52–1.54) | +| 6 | Strands issue #4168 | https://github.com/strands-agents/sdk-python/issues/4168 | 2026-09-04 | 1 | +| 7 | Strands issue #4172 | https://github.com/strands-agents/sdk-python/issues/4172 | 2026-09-04 | 1 | +| 8 | Reference repo commits (window) | https://github.com/aws-samples/sample-strands-agent-with-agentcore/commits/main | 2026-09-04 (via `gh api`) | 7 commits | +| 9 | Reference repo commit `31cb9e2b` | https://github.com/aws-samples/sample-strands-agent-with-agentcore/commit/31cb9e2b | 2026-09-04 | 11 files | +| 10 | Reference repo commit `83e6d7c9` | https://github.com/aws-samples/sample-strands-agent-with-agentcore/commit/83e6d7c9 | 2026-09-04 | 1 | +| 11 | MCP blog | https://blog.modelcontextprotocol.io | 2026-09-04 | 0 in window (latest 2026-08-22) | +| 12 | MCP spec changelog (2026-07-28) | https://modelcontextprotocol.io/specification/2026-07-28/changelog | 2026-09-04 | `server/discover`, SEP-2567/2575 — ⚠️ via search summary, not first-hand | +| 13 | `modelcontextprotocol/servers` commits | https://github.com/modelcontextprotocol/servers/commits/main | 2026-09-04 | hardening only | +| 14 | MCP ext-apps | https://github.com/modelcontextprotocol/ext-apps | 2026-09-04 | 0 in window (latest visible 2026-08-12) | +| 15 | FastMCP releases (4.0.0–4.0.2) | https://github.com/jlowin/fastmcp/releases | 2026-09-04 | 3 | +| 16 | FastMCP on PyPI | https://pypi.org/project/fastmcp/ | 2026-09-04 | latest 4.0.2 (2026-09-02) | +| 17 | fast-agent — FastMCP Apps (third-party, unconfirmed) | https://fast-agent.ai/mcp/fastmcp-apps/ | 2026-09-04 | 1 | +| 18 | assistant-ui releases | https://github.com/Yonom/assistant-ui/releases | 2026-09-04 | 3 (2026-09-03 train) | +| 19 | Anthropic news | https://www.anthropic.com/news | 2026-09-04 | 1 in window, 0 UI/design | +| 20 | Anthropic — Fable 5.1 / Mythos 5.1 | https://www.anthropic.com/claude-fable-and-mythos-5-1 | 2026-09-04 | 1 | +| 21 | Vercel AI SDK 6 | https://vercel.com/blog/ai-sdk-6 | 2026-09-04 | 0 dated in window | +| 22 | Cursor blog | https://cursor.com/blog/design-mode | 2026-09-04 | 0 dated in window | +| 23 | OpenAI API changelog | https://developers.openai.com/api/docs/changelog | 2026-09-04 | 3 | +| 24 | Google AI August roundup | https://blog.google/innovation-and-ai/technology/google-ai-updates-august-2026/ | 2026-09-04 | 0 in window | +| 25 | Claude Code CHANGELOG | https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md | 2026-09-04 | 6 versions (2.1.251–2.1.260) | +| 26 | Anthropic engineering | https://www.anthropic.com/engineering | 2026-09-04 | 0 (latest 2026-04-23) | +| 27 | opencode releases | https://github.com/anomalyco/opencode/releases | 2026-09-04 | 3 (v1.18.25–27) | +| 28 | opencode v1.18.27 notes | https://github.com/anomalyco/opencode/releases/tag/v1.18.27 | 2026-09-04 | 2 | +| 29 | LibreChat releases | https://github.com/danny-avila/LibreChat/releases | 2026-09-04 | 1 (v0.8.8-rc2) — ⚠️ year not independently verified | +| 30 | LibreChat v0.8.8-rc2 notes | https://github.com/danny-avila/LibreChat/releases/tag/v0.8.8-rc2 | 2026-09-04 | 4 | +| 31 | AWS Price List — Bedrock us-west-2 (current + prior version diff) | `aws pricing get-products` · https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrock/20260804165549/us-west-2/index.json | 2026-09-04 | 1,026 products; 0 changed rates | +| 32 | AWS Price List — AgentCore us-west-2 (current + prior version diff) | `aws pricing get-products` · https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockAgentCore/20260722142809/us-west-2/index.json | 2026-09-04 | 913 products; 889 new instance SKUs | +| 33 | `aws bedrock list-foundation-models --region us-west-2` | AWS CLI | 2026-09-04 | Claude 4.x/5.x ACTIVE but absent from price list | +| 34 | AgentCore Runtime instances GA | https://aws.amazon.com/about-aws/whats-new/2026/08/aws-bedrock-agentcore-runtime-instances-generally-available/ | 2026-09-04 | 1 | +| 35 | AgentCore SDK releases + issues #564/#567/#652/#659 | https://github.com/aws/bedrock-agentcore-sdk-python | 2026-09-04 (via `gh api`) | 1 release + 4 issues | +| 36 | AgentCore starter-toolkit (legacy status + issue #574) | https://github.com/aws/bedrock-agentcore-starter-toolkit | 2026-09-04 (via `gh api`) | 1 | +| 37 | HN Algolia (`search_by_date`, optionalWords) | https://hn.algolia.com/api/v1/search_by_date | 2026-09-04 | 60 hits, ⚠️ only back to 2026-09-03 — see caveat | +| 38 | MCP schema-drift finding | https://github.com/GautamTalksDev/mcp-pin/blob/main/docs/findings/2026-09-03-schema-drift.md | 2026-09-04 | 1 — ⚠️ self-published by the tool's author | +| 39 | "Grep beat LSP" harness study | https://www.agentconnect.md/blog/grep-beat-lsp-harness/ | 2026-09-04 | 1 — ⚠️ single-author, self-reported | +| 40 | Anthropic cookbook commits (window) | https://github.com/anthropics/anthropic-cookbook | 2026-09-04 (via `gh api`) | 3 commits | +| 41 | Cookbook — scheduled repository reviewer | https://github.com/anthropics/anthropic-cookbook/blob/main/claude_agent_sdk/scheduled_repository_reviewer/scheduled_repository_reviewer.ipynb | 2026-09-04 | 1 | +| 42 | Cookbook commit `bbfab1bb` (delegation-ratio walkback) | https://github.com/anthropics/anthropic-cookbook/commit/bbfab1bb | 2026-09-04 | 1 | +| 43 | PyPI / npm registry APIs (12 packages) | https://pypi.org/pypi/*/json · https://registry.npmjs.org/* | 2026-09-04 | version-pin table | +| 44 | Reddit (r/LocalLLaMA, r/MachineLearning) | — | — | **not scanned** — budget | +| 45 | Nielsen Norman Group AI | https://www.nngroup.com/topic/artificial-intelligence/ | — | **not scanned** — budget | +| 46 | Seasonal (re:Invent, NeurIPS/ICLR/EMNLP) | — | — | **out of window** | + +## Web Budget + +**Used: 51 / 50 requests** (1 over target — a rounding-level overage, not a category blowout). + +Per subagent: AWS Bedrock/AgentCore 4 · Strands 4 · reference repo **0** · MCP 4 · FastMCP 3 · agentic UI/UX 6 · frontier models 5 · agent harness 3 · opencode 2 · LibreChat 2 · pricing 3 · AgentCore SDK issues **0** · community 3 · cookbook **0** · version pins **12**. + +**Skipped (budget exhausted)**: Nielsen Norman Group AI articles; Reddit (r/LocalLLaMA, r/MachineLearning); FastMCP issues list; LibreChat CHANGELOG.md. +**Skipped (out of window)**: seasonal sources. +**Skipped (deliberate)**: `https://aws.amazon.com/bedrock/pricing/` — the Price List API replaced it and produces exact, diffable figures. Recommend removing the marketing page from the skill's source list entirely; it has now defeated four consecutive scans and the replacement is strictly better. + +**Notes on the overage and on efficiency**: four subagents (reference repo, AgentCore SDK issues, cookbook, and half the harness scan) ran entirely through the authenticated `gh` CLI, which costs nothing against the web budget and returns exact issue numbers, states and ISO timestamps rather than parsed HTML — strictly better data for zero budget. **The version-pin check consumed 12 requests, nearly a quarter of the budget, for the lowest-signal section of the doc**; next run should batch registry lookups or move them to `npm view` / `pip index`, which would bring the whole scan comfortably under 40. diff --git a/docs/kaizen/review-queue.md b/docs/kaizen/review-queue.md index bd3c84e83..efaf22c89 100644 --- a/docs/kaizen/review-queue.md +++ b/docs/kaizen/review-queue.md @@ -7,36 +7,103 @@ Items added by `kaizen-research`, consumed by `kaizen-review-prep`. > ✅ **Queue hygiene completed 2026-08-14** (at Phil's request, ahead of `kaizen-review-prep`). **Nine** stale entries were resolved: four `bedrock-agentcore` bump entries and two Strands bump entries (all **shipped** in #857 — `bedrock-agentcore` 1.9.1 → **1.21.0** at zero lag, `strands-agents` → **1.51.0**; #482 and #571 closed upstream), two nightly-CI entries (**green 12 consecutive days**), and two MCP Apps spec-prep entries (superseded now the 2026-07-28 spec is final). Genuine residue was carried forward, not dropped: **#564** (still open upstream) and the **un-adopted Strands capabilities** are now their own entries below. See `## Resolved` for the evidence trail. -### [2026-08-28] Correct the cache-write premium and fix the Global/Regional rate tier -- **Source**: research/2026-08-28.md ▸ Top 5 #1 — AWS **Price List API** (us-west-2, `AmazonBedrockFoundationModels`), cross-checked against https://platform.claude.com/docs/en/build-with-claude/prompt-caching. Verified locally at `frontend/ai.client/src/app/admin/manage-models/models/curated-models.ts:135-141`. -- **Surface**: docs / frontend / backend (`CLAUDE.md` — the "$2.50/MTok cache-write premium" appears twice, in the prompt-cache contract and the cost-effectiveness tenet; `curated-models.ts` Claude templates' four rate fields; the managed-models DynamoDB rows in dev/prod; the read path at `apis/shared/costs/pricing_config.py:75`) -- **Effort × Impact**: L × H -- **Subtracts**: yes — a wrong constant that gates merges on the model call path, plus (optionally) a hand-maintained duplicate of AWS's own price list -- **Unlocks**: cost numbers that are actually right — the precondition for every other cost decision including the Strands-bump measurement below; plus a possible **~9% cut on all model spend** if `global.*` profiles are permissible (they price 9.1% below `us.*` CRIS across input, output, cache-read and cache-write, and we already run one) -- **Status**: open — **recommended #1, and the cheapest item in the scan.** Two verified facts: (1) Bedrock's cache-write premium is **not a flat $2.50/MTok — it is a 1.25× multiplier on base input** (2× at 1h TTL, read 0.1×), so the doc **understates Sonnet 4.5 cache-write by 65% and overstates Haiku 4.5 by 82%**; $2.50 matches only Sonnet 5's *Global* rate. (2) Our `us.anthropic.*` ids are **Regional CRIS**, priced exactly 10% above Global — but `curated-models.ts` declares `us.anthropic.claude-haiku-4-5-20251001-v1:0` (our default model) with the **Global** numbers ($1.00/$1.25/$0.10/$5.00 vs Regional $1.10/$1.375/$0.11/$5.50). Ratios right, base 10% low. ⚠️ **Do NOT retroactively rewrite historical cost rows** — fix forward and annotate, or the time series the cost-effectiveness arc depends on loses comparability. Also needs one query against the managed-models table in dev to see what admins actually seeded (the `3.75` figures in `calculator.py:43` / `pricing_config.py:59` are **docstring examples, not live defaults** — the live path reads the DynamoDB row). +### [2026-09-05] ✅ RESOLVED — PROD `gpt-5.4` cache rate set +- **Source**: live measurement in dev, then verified and fixed in prod the same day. +- **Status**: **done.** Prod `openai.gpt-5.4` now carries `supportsCaching: true`, `cacheReadPricePerMillionTokens: 0.275` (0.1x its $2.75 input, the ratio confirmed across the GPT family in the Price List) and `cacheWritePricePerMillionTokens: 0` (that model has a cache-read SKU and **no** cache-write SKU). Verified on the record at 2026-09-05T17:41Z; name, prices and `enabled` untouched. +- **How**, since #963 is a frontend change that has not reached prod: the backend has always accepted these fields for Mantle, so it went through `PUT /api/admin/managed-models/{id}` from the browser console. ⚠️ That endpoint enforces double-submit CSRF — the `__Host-bff_csrf` cookie is JS-readable and must be echoed in `X-CSRF-Token`, or it 403s with `CSRF token missing or invalid`. +- ⚠️ **History is not corrected.** Pricing snapshots are captured per message at write time, so turns recorded before 17:41Z keep their $0.00 cache cost and their inflated savings credit. Any prod cost figure quoted for gpt-5.4 before that timestamp is wrong in both directions. A backfill would have to rewrite `C#` rows and is not proposed here. +- **Still open, smaller**: prod's `google.gemma-4-31b` is also `caching=False`. Probably correct — Gemma is open-weight and there is no evidence it caches — but the same hidden-control gap applied to it, so it deserves one warm-turn check. +- **Deleting the model was considered and rejected**: seven prod assistants (C.A.R.L., News Report, IPS 410 Crisis Management Simulator, ISSS Work Buddy, CTL data analysis, Buster Bot, Owliver) hard-bind it via `modelConfig: {modelId, provider}`, and the `staff` role grants it explicitly. + +### [2026-09-05] Carry the CRIS prefix per environment — `global.*` works in prod, is SCP-denied in dev +- **Source**: live failure in dev while adding GPT-5.6 Luna, 2026-09-05; scope corrected by Phil the same day — **prod is not affected**. +- **Surface**: whatever copies model rows between environments — seed scripts, curated catalog entries in `curated-models.ts`, runbooks. No runtime code. +- **Effort × Impact**: L × L–M — small, but it is a silent failure if missed. +- **Subtracts**: no. +- **Status**: open, and **much narrower than first written**. In dev, `global.openai.gpt-5.6-luna` is denied by an organization SCP on the region-less ARN `arn:aws:bedrock:::foundation-model/...`; the same row edited to `us.` works. **Prod does not have this restriction**, so the spec's Global-CRIS cost preference (~9% cheaper for this family) stands where the spend actually is. The real consequence is a **dev/prod model-id divergence**: a GPT-5.6 row is `us.*` in dev and can be `global.*` in prod, so anything that copies a row between environments must carry the prefix per environment rather than assume one id works in both. ⚠️ **Method note:** `aws iam simulate-principal-policy` does **not** evaluate SCPs — every simulation of the dev runtime role returned `allowed` while the real call was denied. An SCP deny is only observable by invoking. ⚠️ Direction is the opposite of the Claude rule of thumb, where `us.*` costs ~10% *more*. + +### [2026-09-05] Standing watch — retire our hand-rolled prompt-caching code as Strands' `CacheConfig` converges +- **Source**: conversation with Phil, 2026-09-05 — **not** from a research scan. Raised while shipping the GPT-5.6 caching epic (#945, #949, #951, #954, #956, #959): *"my hope is that the default caching config replaces any custom code we have added around openai/gpt caching."* Added as a recurring lens in `kaizen-research/SKILL.md` §2a rather than a one-off idea. +- **Surface**: backend — `apis/shared/models/usage_normalization.py`, `apis/shared/models/bedrock_responses.py`, `apis/shared/observability/prompt_cache.py`. Plus the `strands-agents` pin. +- **Effort × Impact**: L per check × M–H when one lands — each landing **deletes** a module we currently maintain. +- **Subtracts**: yes, by construction. This entry only ever proposes removals; if a run finds nothing upstream, it stays open and costs one scan. +- **Status**: open — the convergence is real and already partly upstream, so this is a waiting game with a known finish line, not speculation. + - ✅ **Already on upstream main:** `strands/models/_openai_cache.py::apply_cache_config` maps `CacheConfig.cache_key` → `prompt_cache_key` for OpenAI models. That is our `build_prompt_cache_key()`, upstream. **Not in our pinned 1.51.0** — adopt on the next bump. + - ⏳ `cache_write_tokens` mapping — [harness-sdk#4193](https://github.com/strands-agents/harness-sdk/pull/4193) is **ours**, open. Merging + a release deletes half of `usage_normalization.py`. + - ⏳ Disjoint-`Usage` contract — [harness-sdk#3546](https://github.com/strands-agents/harness-sdk/issues/3546) open; the broad fix (#3561, 84 files) was **closed unmerged**, maintainers want small PRs. Landing it deletes the other half. + - ❌ No upstream equivalent for explicit breakpoints — `apply_cache_config` emits no `prompt_cache_breakpoint`. Ours is OFF by default and stays off. + - ⚠️ `apply_cache_config` maps ttl → `prompt_cache_retention` (`in_memory`/`24h`), **not** GPT-5.6's `prompt_cache_options.ttl: "30m"`. Not yet the same concept as our `cache_ttl_seconds_for()`; don't conflate them. +- **Scope is all cacheable families, not just GPT.** Anthropic, OpenAI, and any newly cacheable Bedrock model. For each new one, confirm *which API surface* serves caching before assuming it works — GPT-5.6 caches **only** over the Responses API and not at all over Converse, and that distinction was worth an entire transport. +- ⚠️ **Never adopt an upstream caching default on inspection alone.** This stack shipped one caching change whose premise was wrong and measured **57% more expensive** live (#954 → reverted by #956). `backend/scripts/probe_gpt56_cache_rates.py --mode both --grow-history` is the gate: beat the current arm, measured, before switching. + +### [2026-09-04] Instrument the missing quality axis — per-message response feedback +- **Source**: conversation with Phil, 2026-09-04 — **not** from a research scan. Spec written the same day: `docs/specs/response-feedback.md`. Revives the two long-dormant `# feedback: Optional[Feedback] = None` placeholders at `apis/shared/sessions/models.py:645` and `apis/app_api/messages/models.py:132`. +- **Surface**: backend — `apis/shared/sessions/metadata.py` (a third `F#` SK prefix beside `C#`/`D#`), `apis/app_api/sessions/routes.py` (per the inference-api boundary rule), `admin/costs/routes.py:183` (third leg of the anatomy query); frontend — `message-actions.component.ts` (the existing Copy/Continue rail) +- **Effort × Impact**: M × H — PR-1 is small; the impact is that it is the **only** proposed instrument for a dimension we currently cannot measure at all +- **Subtracts**: no — this is additive, and the entry should be ranked knowing that. What it removes is an *epistemic* gap, not code: two commented-out placeholders become real, and "quality wins when it conflicts with cost" stops being unfalsifiable. +- **Unlocks**: + - **The counterweight the cost roadmap has never had.** Every `C#` row carries model id, `cacheStatus`, `agentSwitched`, compaction state and cost. Nothing carries an outcome. W1–W5 has optimised cost against an asserted quality constant; a down-thumb rate joined to those same dimensions is the first evidence that compaction, a model downgrade, or the `@`-mention history fork costs anything besides dollars. + - **Affordable evaluation.** The Evaluations spike proved `EvaluationClient.run()` works end-to-end on real dev conversations with 16 built-in evaluators and deterministic session correlation (`runtime_session_id_for()`, `harness/runner.py:63`) — but judging is per-trace and cannot run fleet-wide. Human-marked failures are the sampling queue that makes it payable, and the fixed reason set routes each bucket to the right evaluator. + - **`Builtin.SkillSelectionAccuracy` / `SkillInstructionFollowing`** (queued 2026-08-28) become affordable for the same reason — the Skills v2 epic's unmeasured failure mode gets a pre-filter. + - **A rework rate in dollars** — down-thumbed turn cost plus the retries after it — which denominates quality in the unit the existing dashboards already speak. + - **Marketplace ranking and version-regression alarm** per published `AgentVersion`, giving the §8 rollback path its first automated trigger. +- **Status**: open — three things a reviewer should weigh before ranking. **(1) The thesis is deliberately narrow:** feedback is a *sampler and a label*, never a metric. The spec makes "no single-number quality score field exists to be quoted" a structural rule (§9), because a raw rate over a self-selected ~3% is noise and will be misread the first time it reaches a slide. **(2) The consequence is load-bearing, not a nicety.** A thumb with no visible effect decays to a zero response rate within weeks, at which point every downstream phase is starved. PR-1 therefore ships retry-with-correction *with* the button or does not ship — this is the one place the phasing cannot be trimmed for scope. **(3) It must not merge with the D15 agent-report channel**, which already exists and is a different object (agent-scoped, free-text, moderation-queued, `suggestion` folded in on purpose). The down-thumb sheet hands off to that dialog; it does not grow a second queue. ⚠️ Two accuracy notes carried from the write-up: feedback is a **side-channel DynamoDB write** that never touches conversation history or `toolConfig` — a design putting it on the message object is a prompt-cache buster and is rejected in the spec; and any LLM-judged classification runs **offline in batch** over persisted turns, never as an inline per-turn call. ⚠️ `is_preview_session` guards the `D#` write (`metadata.py:156`) but **not** the `C#` cost write — the two prefixes already disagree, so `F#` needs a deliberate rule rather than a copied neighbour. Phase 6 (preference data for the existing `fine_tuning` domain) is **policy-gated and explicitly out of scope** — a consent decision at the identity-claim level, not an engineering one. + +### [2026-09-04] Enforce platform-before-backend on a develop push +- **Source**: reviews/2026-09-04.md ▸ Proposal #4 — direct observation by `kaizen-review-prep`; **not** in research/2026-09-04.md, which reported zero CI failures in the window. Run [33559336336](https://github.com/Boise-State-Development/agentcore-public-stack/actions/runs/33559336336). +- **Surface**: CI — `.github/workflows/backend.yml` (the `Deploy inference-api image to AgentCore Runtime` job) and/or a shared concurrency group with `platform.yml`. No application code. +- **Effort × Impact**: L–M × M–H +- **Subtracts**: yes — an unenforced deploy-order convention that lives only in `CLAUDE.md` prose, plus one class of red develop build that presents as a flake and self-heals on the next push. +- **Status**: open — **the only CI failure in the 7-day window, and it was a real one.** `[inference-api] Failed to get-agent-runtime — runtime may not exist yet.` → `exit 3` at 2026-09-01T21:17Z. **Platform Stack** and **Backend Deploy** were both triggered by the same develop push at `21:07:5x` (the #904 merge); Platform Stack succeeded, the runtime step did not — consistent with `get-agent-runtime` being called while CFN was mid-replace on the Runtime resource. Fix: serialize the two workflows, or gate the inference-api job on platform completion, and make the read retry instead of exiting 3. ⚠️ Check against the known GSI deploy-ordering trap first — a shared concurrency group has previously **cancelled** a run rather than queueing it, which is a worse failure than the one being fixed. -### [2026-08-28] Take Strands 1.51 → 1.54 as an instrumented cache experiment, not a routine bump -- **Source**: research/2026-08-28.md ▸ Top 5 #2 — https://github.com/strands-agents/sdk-python/pull/3681 (**BREAKING**, 1.53.0) · https://github.com/strands-agents/sdk-python/pull/3858 (fixes the #3758 blocker) · https://github.com/strands-agents/sdk-python/pull/3999 (`cancel_signal`, 1.54.0) · https://github.com/strands-agents/sdk-python/pull/2326 (history-mutation byte-stability, 1.54.0) -- **Surface**: backend (`core/model_config.py:349-391` — the three-point comment block + `CacheConfig(strategy="auto")`; `core/agent_factory.py:199-213` — our hand-built system cachePoint; `tests/agents/main_agent/core/test_bedrock_cache_points.py` — the position test that is the safety net; `session/turn_based_session_manager.py` — the compaction baseline #3886 moves; `backend/pyproject.toml:59` and `:74`) -- **Effort × Impact**: M × H -- **Subtracts**: likely yes, in the best possible place — our hand-built `SystemContentBlock` cachePoint and the 40-line comment justifying it; also strikes the stale "blocked by #3758" caveat on the [2026-08-14] cookbook entry -- **Unlocks**: `cancel_signal` — the first primitive that can actually **stop an in-flight Bedrock call** when a client disconnects (our #863 fix releases the lease and keeps paying); usable per-section TTLs, which are the precondition for a per-lane TTL policy that two independent harnesses converged on this month -- **Status**: open — **the collision is the point.** `CacheConfig(strategy="auto")` now places a **system-prompt cache point automatically** (#3681), and we already hand-place one. The comment at `model_config.py:358` explicitly asserts auto "does not touch the system/tools points" — **no longer true on 1.53.0+**, and it will actively mislead the next person who bumps. Order of work: (a) **diff the 1.51→1.54 wheels, not the release notes** — they are monorepo-wide and #3505's `ContextManager` claim is TypeScript-only; (b) determine whether auto's system point duplicates or replaces ours and delete ours if equivalent; (c) confirm we're still inside Bedrock's 4-point budget via the position test; (d) measure `cacheStatus` / `toolConfigHash` / `systemPromptHash` / read-vs-write tokens on real `C#` rows before and after, treating `partial_miss` rate as the primary signal. Be willing to pin at 1.52.0 if 1.53's placement is worse than ours. ⚠️ Note our own issue #3348 (rolling message cachePoints) had **no movement** and #3681 makes its budget question *more* constrained — a bump-and-ping is no longer enough; offering the `message_cache_points` policy decision or a PR is the unblock. +### [2026-09-04] Retire the POC-comment feedback mechanism from both kaizen skills; adopt two verification rules +- **Source**: reviews/2026-09-04.md ▸ Proposal #5 — direct observation (Friction ≥2 ×2, Silence that matters). Supersedes reviews/2026-08-28.md ▸ Proposals #3 and #4b, both Ship-recommended and both unactioned. +- **Surface**: skills — `.claude/skills/kaizen-research/SKILL.md` and `.claude/skills/kaizen-review-prep/SKILL.md`. No code, no `CLAUDE.md`. +- **Effort × Impact**: L × M–H +- **Subtracts**: yes — the largest single subtraction available this cycle, and it simplifies the forum rather than the codebase. Retires (a) the POC-comment loop: the `POC findings` field, the “tested outranks untested” tiebreak, and the one-week-lag philosophy hanging off them — **five cycles, zero comments, tiebreak never fired** — while three items shipped as code in five days through a channel the skills do not describe. Also retires (c) the “resolve on a green streak” rule, still in force because #4b was never adopted, which produced a documented false negative within four days. +- **Status**: open — three edits. **(a)** Replace the POC-comment loop with the outcome signal that demonstrably works: review-prep reads **merged PRs against the prior review's proposals**. **(b)** Every internal-audit number must be produced by a command **quoted in the doc**. Three reproduction failures in two cycles — the 2026-08-28 Price List API result, “zero CI failures in the window” (there was one), and `@angular/core 21.2.17` (the scanned tree read `21.2.19`) — against one section that already quotes its method (the version-pin table) and is the most reliable in the doc. A rate/price/capability figure destined for code needs two independent sources or an explicit `⚠️ single-source` marker. **(c)** Never resolve a *flaky* entry on a consecutive-green count — only on a root-cause fix or an explicit “accepted flake, N/month” note. **Deliberately does NOT re-propose** the ✅→tracked-issue layer: it failed to land twice and its premise is falsified — verified 2026-09-04 that **no `kaizen` label exists in this repo** and three items shipped anyway. ⚠️ `kaizen-review-prep/SKILL.md` is unmodified since 2026-05-10 across four reviews that each proposed editing it — if this is going to land, it rides the review PR. -### [2026-08-28] Make `supported_params` omission mean *unsupported*, and audit model lifecycle -- **Source**: research/2026-08-28.md ▸ Top 5 #3 — https://platform.claude.com/docs/en/about-claude/model-deprecations (`temperature`/`top_p`/`top_k` "Returns a 400 error when set to a non-default value" on Opus 4.7+; retirement floors Sept 29 / Oct 15) -- **Surface**: backend / frontend (`apis/inference_api/chat/routes.py:294-360` — the merge/filter and its pass-through loop; `agents/main_agent/core/model_config.py:45-102` — `_BEDROCK_PARAM_MAP` / `KNOWN_CANONICAL_PARAMS`; `curated-models.ts` Opus 4.7 + Sonnet 5 templates; `apis/shared/models/models.py` `SupportedParams`) +### [2026-09-04] Finish the #914 rate correction — six stale sites, a per-model cache-read ratio, and a provenance claim that doesn't reproduce +- **Source**: research/2026-09-04.md ▸ Top 5 #1 — internal (PR #914, merged 2026-09-03) + https://www.anthropic.com/claude-fable-and-mythos-5-1 + a full AWS Price List API enumeration (`AmazonBedrock`, 11,621 `usagetype` values, all regions) +- **Surface**: docs / backend / frontend — `model_config.py:380`, `turn_based_session_manager.py:19`, `test_compaction_stability.py:8`, `test_prompt_cache_observability.py:464`, `docs/specs/compaction-over-threshold-cache-spiral.md:13,252`, `curated-models.ts:96-97`, and the prompt-cache contract bullet in `CLAUDE.md` - **Effort × Impact**: L × H -- **Subtracts**: yes — one default inversion closes the entire class, instead of adding `temperature: {supported: false}` to every 4.7+ template and hoping nobody forgets one on the next model -- **Unlocks**: safe Opus 5 onboarding (it inherits the same restriction); the first model-lifecycle signal the registry has ever had -- **Status**: open — **verified end-to-end in code this run.** The chain: `temperature` ∈ `_BEDROCK_PARAM_MAP` → ∈ `KNOWN_CANONICAL_PARAMS` → the Opus 4.7 curated template **omits** it from `supportedParams` → so it is not in `seen_keys` → the pass-through loop forwards it ("Request keys for params the managed model says nothing about pass through untouched") → Bedrock → **hard 400, mid-stream.** Zero grep hits for any temperature-suppression guard in `backend/src/` or `frontend/ai.client/src/`. The trap is semantic: the curated templates express "not supported" by **omission**, and the filter reads omission as **permission**. Two pieces: (1) for a model that declares a spec at all, omission should mean unsupported — keep today's permissive behavior only for records with **no** spec, and log every omission-drop so the change is observable; (2) check `claude-haiku-4-5-20251001` (**42 refs**) and `claude-sonnet-4-5-20250929` (**18 refs**) against **Bedrock's** retirement schedule, not Anthropic's — the docs are explicit that partner platforms set their own dates. - -### [2026-08-28] Retire the tool-mutation premise and strike two resolved MCP blockers -- **Source**: research/2026-08-28.md ▸ Top 5 #4 — https://platform.claude.com/docs/en/build-with-claude/prompt-caching (invalidation table: modifying tool definitions invalidates the entire cache; no beta header, parameter, or dated availability anywhere on the page) · https://raw.githubusercontent.com/modelcontextprotocol/ext-apps/main/specification/2026-01-26/apps.mdx (both verification questions answered) · https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning (back-compat explicitly specified) -- **Surface**: docs / process (`docs/kaizen/review-queue.md` only — no code) -- **Effort × Impact**: L × M–H -- **Subtracts**: yes — one entry retired outright, two verification blockers struck, three dependent framings freed -- **Status**: open — **three evidence-backed resolutions, and review-prep runs against this file in ~2 hours.** (1) **[2026-08-14] "Probe the mid-conversation tool-mutation beta"** → resolve as **premise not substantiated**; Anthropic's caching docs say the opposite and name no beta. It was last week's recommended #1, and its Unlocks section is cited as the unblocker for cross-source tool search, per-tool MCP enablement, and `@`-mention prefix cost — **restore those three to their own merits.** (2) **[2026-08-14] MCP Apps host migration** → **strike both "cheap verifications required before any code" prerequisites**: the capability id is confirmed **`io.modelcontextprotocol/ui` under `capabilities.extensions`** and `ui/notifications/tool-input-partial` is confirmed **present** in the spec — *both matching what `mcp_apps.py` already ships*. **Down-rank** the entry too, since back-compat for initialization-based revisions is explicitly specified. Also record that the [2026-05-29] entry resolved as "asserts an unverified identifier" **was right all along**. (3) **[2026-08-14] cost_optimization cookbook audit** → **strike the "⚠️ blocked by Strands #3758" caveat**; the Python-side fix shipped in 1.53.0 via #3858, so the layered-TTL technique (54% cheaper upstream) becomes available on the bump above. *(This skill does not edit `## Resolved` — that move is review-prep's job; this entry supplies the evidence.)* +- **Subtracts**: yes — six duplicated wrong constants, one hardcoded cache-read ratio that is already false for a GA model, and one unreproducible sourcing claim +- **Status**: open — **recommended #1, and the cheapest item in the scan.** Three verified facts. (1) PR #914 fixed `$2.50/MTok` in `CLAUDE.md` and left the same constant in **six** other places, including the 40-line cachePoint-budget comment at `model_config.py:380` — the exact text a reader consults when reasoning about cache cost. (2) #914 replaced it with a *derivation* helper hardcoding `cacheRead = input × 0.1`, and **Claude Fable 5.1 — GA on Bedrock 2026-09-01 — reads at $0.25/MTok against $10/MTok input, i.e. 0.025×**, a 75% cut Anthropic states explicitly; the helper would silently 4× overstate any Fable row an admin adds. Fix is to accept cache-read as an *input* with 0.1 as a documented default, so the next model that breaks the ratio is a data change. (3) ⚠️ **This scan could not find a single Claude 4.x/5.x SKU in the Price List API** (10 Claude SKUs total, none newer than Claude 3, none with cache or output dimensions) — which **contradicts last week's scan**, whose figures drove #914. `CLAUDE.md` now asserts rates "come from the AWS Price List API"; that claim does not reproduce. The 1.100× Regional premium survives independently — confirmed this week on nine Global/Regional pairs of newly-published xAI Grok 4.6 SKUs — so #914's *substance* is probably safe, but the *provenance* is not. Re-run last week's query verbatim before the next rate edit. + +### [2026-09-04] Migrate the MCP Apps host off `initialize` to `server/discover` — FastMCP 4.0 made it real +- **Source**: research/2026-09-04.md ▸ Top 5 #2 — https://modelcontextprotocol.io/specification/2026-07-28/changelog (SEP-2567/2575) + https://github.com/jlowin/fastmcp/releases (4.0.0, 2026-08-31) + Strands 1.53.0 MCP-client changes. **Supersedes and upgrades the [2026-08-14] entry of the same name** — merge them at review. +- **Surface**: backend — `integrations/mcp_apps.py` (the `ClientSession` symbol patch at lines 23–32; the `serverInfo` capture at line 673), `external_mcp_client.py`, `gateway_mcp_client.py`, the mcp-sandbox proxy origin, and the OAuth pre-flight path (PR #872) +- **Effort × Impact**: M–H × H +- **Subtracts**: yes — the monkeypatch on `strands.tools.mcp.mcp_client.ClientSession`, taken *because* the SDK offered no hook, on a class Strands is actively changing (1.53 added MCP OAuth over HTTP), to participate in a handshake the spec is retiring. Three independent reasons it breaks, none of which we control. +- **Unlocks**: + - **Sessionless transport** removes the fresh-MCP-session-per-call cost behind the MCP Apps proxy-call 504 — our Lambda servers fake this today with `stateless_http=True`, and FastMCP 4.0 makes it a first-class protocol path *with* per-user state (SEP-2567 `UserSession`/`SessionId`). + - **A pre-flight that doesn't 401** — `server/discover` reads capabilities *before* `tools/list`, the exact call whose 401 permanently dropped a tool until PR #872 worked around it three wrappers down. + - **SEP-2549 cache hints** (directly on-thesis for tool-listing token cost) and **SEP-2243 routable headers** (`Mcp-Method`/`Mcp-Name`, for AgentCore Gateway target routing) become reachable. +- **Status**: open — **what changed this week is that the servers shipped it.** The 2026-08-14 entry was queued off the spec alone; FastMCP 4.0.0 (2026-08-31, breaking) is what our Lambda-backed MCP servers actually run on, so this stopped being a docs migration. ⚠️ Two gates before estimating: does AgentCore Gateway speak `server/discover` at all, and does Strands' MCP client expose it? Both answerable in an afternoon. ⚠️ Also: the spec details came from **search summaries, not a first-hand changelog read** — verify first. Note we *do* currently negotiate the Apps extension (verified: `mcp_apps.py` advertises `capabilities.extensions["io.modelcontextprotocol/ui"]` on every outbound `initialize`) — the open question is what that negotiation hangs on once there is no `initialize`. + +### [2026-09-04] Derive `missCause` from the fingerprint that flipped +- **Source**: research/2026-09-04.md ▸ Top 5 #3 — https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md (2.1.260, 2.1.251) + https://github.com/GautamTalksDev/mcp-pin/blob/main/docs/findings/2026-09-03-schema-drift.md +- **Surface**: backend — `apis/shared/observability/prompt_cache.py` (where `cacheStatus` is derived), `agents/main_agent/session/hooks/prefix_fingerprint.py` (where the three hashes are computed), `GET /admin/costs/sessions/{id}/calls`, the admin cost-anatomy page, and the debugging quick-reference in `CLAUDE.md` +- **Effort × Impact**: L–M × H +- **Subtracts**: yes — the manual hash-diff ritual `CLAUDE.md` currently instructs a human to perform on every cost-spike investigation ("the hash that changed between consecutive calls names the cache-buster"). Everything needed is already stored; nobody has written the comparison. +- **Unlocks**: naming a cause we currently **cannot see at all**. An external MCP server that edits a tool's `inputSchema` between turns re-writes our cacheable prefix with no deploy of ours and no description change for a human to spot — measured in the wild at **17 schema/annotation-only changes across 248 servers in 27 hours**. Verified locally that `toolConfigHash` hashes `get_all_tool_specs()` (full specs *including* `inputSchema`), so we already **detect** this class; a `tools_changed` label on a row with no deploy is the first time we could **attribute** it. +- **Status**: open — cheapest high-leverage item after #1. Labels available essentially for free: `tools_changed`, `system_prompt_changed`, `history_changed`, `agent_switched` (the `agentSwitched` flag already exists), `ttl_expired`, `cold_start`. Claude Code shipped exactly this in 2.1.260 ("a likely cause for prompt-cache misses"), which is corroboration the ergonomics are worth it. + +### [2026-09-04] Gate the Strands 1.51 → 1.54 bump on the system-cachePoint collision +- **Source**: research/2026-09-04.md ▸ Top 5 #4 — https://github.com/strands-agents/sdk-python/releases (python/v1.52.0–v1.54.0) + https://github.com/strands-agents/sdk-python/issues/4168. **Sharpens the [2026-08-28] "instrumented cache experiment" entry with verified specifics** — merge them at review. +- **Surface**: backend — `core/model_config.py:375-400` (`strategy="auto"` + the `bedrock_cache_points_supported()` gate), `core/agent_factory.py:213-224` (the hand-placed system `cachePoint` and its now-false comment), `TurnBasedSessionManager` (against 1.52's tool-pair trimming), `backend/pyproject.toml:59,74` +- **Effort × Impact**: M × H +- **Subtracts**: yes — potentially our hand-placed system cachePoint and the ~40-line comment defending it (the library-native subtraction this skill weights for, *if* 1.53's placement proves equivalent). Definitely subtracts a comment that now asserts a false invariant. +- **Unlocks**: 1.54's **external cancellation-signal injection** (candidate to retire hand-rolled cancel plumbing around the anyio `CancelScope` drop path, PR #863) and **`Agent.session_id`** (the runtime session pin the G1 agent-cache read had to establish by hand). +- **Status**: open — four concrete checks. (a) Does 1.53's auto-placed system point **duplicate or replace** ours? Delete our `SystemContentBlock` list if it duplicates. (b) Rewrite `agent_factory.py:222`, which asserts *"auto strategy strips only message-level cachePoints, never system ones"* — a 1.51-era fact that 1.53 falsifies. (c) Is 1.52's "trim at complete tool pairs" even reachable given our custom session manager? Two trimmers choosing different boundaries is precisely the byte-instability the compaction redesign exists to prevent. (d) **Keep the `bedrock_cache_points_supported()` gate** — upstream issue #4168 (filed 2026-09-04) is a live report of the exact `AccessDeniedException` it prevents, so it is load-bearing, not redundant. ⚠️ The reference repo runs 1.54 without incident but **does not hand-place a system cachePoint** — do not read their green build as evidence for us. This is a cost regression that ships silently and looks like a routine dep bump. + +### [2026-09-04] Close the `ActiveSessionCount` alarm item — the metric now exists — and scope instance-based Runtime against W5 +- **Source**: research/2026-09-04.md ▸ Top 5 #5 — AWS Price List API (`AmazonBedrockAgentCore` us-west-2: **889 new `Runtime:Instance-based::Management-Hours` SKUs** in the 2026-09-01 republish) + https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/release-notes.html. **Unblocks the [2026-07-10] `ActiveSessionCount` entry** — merge them at review. +- **Surface**: infrastructure — `lib/constructs/observability/` (the `AlarmFactory` that #910 made the only sanctioned path), the AgentCore Runtime construct; plus W5 in `project_cost_effectiveness_roadmap.md` +- **Effort × Impact**: L (alarm) + M (W5 scope) × M–H +- **Subtracts**: yes — the `/ping` access-log as our runtime-lifetime instrument, a proxy adopted because no real metric existed. A real one now does. +- **Unlocks**: the **first new lever on W5 since it was named.** Every prior cost win has been on the token side; Runtime memory is **73% of the AICC bill** and there is now a purchasable alternative to the consumption model. +- **Status**: open — two deliberately different-sized parts. **(1, Low)** Wire the alarm: the 2026-07-10 entry was blocked on the metric not existing, and `AWS/Bedrock-AgentCore` now publishes `ActiveSessionCount` once per minute for Runtime and built-in tools; it routes to `{prefix}-alarms` as a consequence of going through `AlarmFactory`. **(2, Med)** Scope instance-based Runtime: 889 SKUs across c5/c6i/m5/m6i/m7i/r5/g5 families (`m5.large $0.01152/hr`, `c6i.xlarge $0.0204/hr`, `g5.xlarge $0.078468/hr`) against unchanged consumption rates of `$0.0895/vCPU-hr` + `$0.00945/GB-hr`. The question is the crossover at our measured microVM lifetimes (18–50 min post-#827) and session concurrency — arithmetic against numbers we already have, not a build. ⚠️ The ~12% management-fee-to-EC2 ratio was **derived** in this scan, not published; verify EC2 base rates before building a case on it. ### [2026-08-28] Port the reference repo's context-overflow hardening - **Source**: research/2026-08-28.md ▸ Top 5 #5 — https://github.com/aws-samples/sample-strands-agent-with-agentcore/pull/260 (`fix(agent): harden context overflow recovery`, 2026-08-18) @@ -46,14 +113,6 @@ Items added by `kaizen-research`, consumed by `kaizen-review-prep`. - **Unlocks**: recoverable context overflow instead of a generic AgentCore 424 (which, per our own reference note, means only "the container returned some non-2xx") - **Status**: open — **we verified we have the exact hole they patched.** `turn_based_session_manager.py:1202-1203` does a bare `if msg_idx in protected_indices: continue`, so protected recent messages get **no cap at all** — one base64-laden tool result inside the protected window can exceed the context limit with no recovery. And `grep -rn "ContextWindowOverflow" backend/src/` returns **zero hits**. Two halves: (1) a much larger absolute ceiling that applies **even to protected turns** (their number is 100,000 chars) plus base64-envelope stripping that swaps inline payloads for metadata; (2) pattern-match the Bedrock overflow strings (`context_length_exceeded`, "prompt is too long", "maximum context length") and raise something nameable. ⚠️ **Cache caveat, load-bearing**: the cap must be **deterministic on content** (length + base64 detection), never on "how full is the window right now" — a cap that fires only near the limit would rewrite already-cached history bytes and turn a correctness fix into a cache-write cost bug. Their implementation is content-keyed so it ports safely. Do the cap first; review the offset-reconciliation half against `test_compaction_stability.py` before porting it. Related: `bedrock-agentcore` #646 (a document-bearing tool result 413s `CreateEvent` and hard-kills the turn) is the same payload-size family and worth checking in the same pass. -### [2026-08-14] Migrate the MCP Apps host off `initialize`/`serverInfo` to `server/discover` -- **Source**: research/2026-08-14.md ▸ Top 5 #2 — MCP **2026-07-28 is now the Current protocol version**; SEP-2575 removed the `initialize`/`initialized` handshake + `Mcp-Session-Id` — https://modelcontextprotocol.io/specification/versioning · https://blog.modelcontextprotocol.io/posts/2026-07-28/ -- **Surface**: backend (`agents/main_agent/integrations/mcp_apps.py:673` — the `getattr(result, "serverInfo", None)` capture; `_mcp_apps_server_info` consumers ~L454–461 / L628–635; `streaming/stream_coordinator.py:1680` `ui_resource` header emission; the `ClientCapabilities(experimental=...)` subclassing at `mcp_apps.py:26`) -- **Effort × Impact**: M × M–H -- **Subtracts**: yes — retires the `initialize`-response dependency, and collapses the "fresh MCP session per call" concern behind the MCP Apps proxy-call 504 work (there is no protocol-level session left to preserve) -- **Unlocks**: conformance with a published host matrix (Claude, VS Code Copilot, M365 Copilot, Goose, Postman); readiness for **MRTR (SEP-2322)** — the sanctioned interrupt/resume shape, which would let OAuth consent and tool approvals resume *without* holding an SSE stream open against the 600s timeout; readiness for SEP-2243 header-based Gateway routing -- **Status**: open — **DOWN-RANKED 2026-08-28; both verification blockers STRUCK.** Supersedes the [2026-07-24] "prep the MCP Apps host for the 2026-07-28 spec" item. research/2026-08-28 answered both prerequisites against the apps spec source, and **both came back matching code we already ship**: the capability id is `io.modelcontextprotocol/ui` under `capabilities.extensions`, and `ui/notifications/tool-input-partial` is **present** in the spec (so the `ui_tool_input_partial` relay is safe). The "do not write code until verified" gate is therefore gone — but so is the urgency: MCP **explicitly specifies back-compat for initialization-based revisions**, so a server that upgrades keeps serving handshake-era clients. This is real work, not urgent work. Keep the handshake path as a compatibility branch (`server/discover` is mandatory for servers, **optional for clients**). Note for the trail: the [2026-05-29] entry resolved as "asserts an unverified identifier" **was right all along** — the identifier is confirmed, and confirming it was the cheap thing to do. - ### [2026-08-14] Attack the W5 memory bill — self-managed LTM strategies + model Runtime Instances - **Source**: research/2026-08-14.md ▸ Top 5 #3 — https://aws.amazon.com/bedrock/agentcore/pricing/ (built-in long-term strategies **$0.75/1,000 records/month** vs override/self-managed **$0.25/1,000**) + Runtime **Instances** GA (EC2 + 12% fee, **Savings Plans / ODCR eligible**, 14-day sessions) — https://aws.amazon.com/about-aws/whats-new/2026/08/aws-bedrock-agentcore-runtime-instances-generally-available/. **Verified internally**: `infrastructure/lib/constructs/agentcore/memory-construct.ts:77` configures all three built-in strategies. - **Surface**: infrastructure / backend (`memory-construct.ts` `memoryStrategies` array + `eventExpiryDuration: 90`; `inference-agentcore-construct.ts` compute type + memory allocation; `apis/app_api/memory/routes.py` `/facts/` `/preferences/` `/summaries/` readers) @@ -144,14 +203,6 @@ Items added by `kaizen-research`, consumed by `kaizen-review-prep`. - **Unlocks**: server-side **policy layer** deciding auto-approve vs prompt via per-tool input-inspecting functions (gate on the tool's args, not just identity); cryptographically-signed, tamper-proof approval history; closes the "approval hook can't see through the tool-fold" hole (pairs with the Strands hook-ordering bump) - **Status**: open — **fold into the queued [2026-07-03] tool-approval item** rather than run a separate track; sequence after the Strands hook-ordering bump lands. The new-this-week piece is the policy layer + integrity check, beyond last week's basic human-in-the-loop. -### [2026-07-10] Wire a CloudWatch `ActiveSessionCount` alarm on the inference-api runtime -- **Source**: research/2026-07-10.md ▸ Top 5 #5 — **NEW** AgentCore Runtime `ActiveSessionCount` metric (https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/release-notes.html) -- **Surface**: infrastructure (CloudWatch alarm on the inference-api runtime's `AWS/Bedrock-AgentCore` `ActiveSessionCount` gauge; PlatformStack observability) -- **Effort × Impact**: L × M -- **Subtracts**: no — ops addition; justified as cheap early-warning for the exact failure class the agentcore bump fixes (defense-in-depth while the bump is pending) -- **Unlocks**: proactive detection of session-leak/exhaustion and the #482 hang (a hung container manifests as session pileup) before a 429 -- **Status**: open — low-effort ops win that pairs with the agentcore bump. Alarm when concurrent sessions approach the raised quota (5,000 us-west-2). - ### [2026-07-06] Spike: managed AgentCore Harness as the headless/scheduled run engine — ✅ SPIKE + Q2 LIVE PROBE COMPLETE, recommend Ship (headless-only, GO-with-boundary) - **Source**: `scoping/2026-07-06-managed-harness-build-vs-adopt.md` (brief) + `scoping/2026-07-06-managed-harness-spike-findings.md` (**findings — 3 gating questions answered**). Surfaced while dogfooding scheduled runs (Phil asked whether we use the AWS Harness feature; we use the lower-level Runtime). AWS **managed Harness** is now GA. - **Surface**: backend (`apis/shared/harness/run_agent_headless` — swap the Runtime `/invocations` target for an `InvokeHarness` endpoint on the headless lane only; swap `sse.py` accumulator for a Converse-stream one → same `RunResult`) + infra (a managed-Harness resource + OAuth-inbound JWT authorizer — a 1:1 port of our existing Runtime `customJwtAuthorizer`, `inference-agentcore-construct.ts:275`). Interactive `inference-api` untouched. @@ -306,6 +357,71 @@ Items added by `kaizen-research`, consumed by `kaizen-review-prep`. ## Resolved +### [2026-08-28] Correct the cache-write premium and fix the Global/Regional rate tier → RESOLVED — **SHIPPED IN PART** (#914) +- **Source**: research/2026-08-28.md ▸ Top 5 #1 — AWS **Price List API** (us-west-2, `AmazonBedrockFoundationModels`), cross-checked against https://platform.claude.com/docs/en/build-with-claude/prompt-caching. Verified locally at `frontend/ai.client/src/app/admin/manage-models/models/curated-models.ts:135-141`. +- **Surface**: docs / frontend / backend (`CLAUDE.md` — the "$2.50/MTok cache-write premium" appears twice, in the prompt-cache contract and the cost-effectiveness tenet; `curated-models.ts` Claude templates' four rate fields; the managed-models DynamoDB rows in dev/prod; the read path at `apis/shared/costs/pricing_config.py:75`) +- **Effort × Impact**: L × H +- **Subtracts**: yes — a wrong constant that gates merges on the model call path, plus (optionally) a hand-maintained duplicate of AWS's own price list +- **Unlocks**: cost numbers that are actually right — the precondition for every other cost decision including the Strands-bump measurement below; plus a possible **~9% cut on all model spend** if `global.*` profiles are permissible (they price 9.1% below `us.*` CRIS across input, output, cache-read and cache-write, and we already run one) +- **Status**: open — **recommended #1, and the cheapest item in the scan.** Two verified facts: (1) Bedrock's cache-write premium is **not a flat $2.50/MTok — it is a 1.25× multiplier on base input** (2× at 1h TTL, read 0.1×), so the doc **understates Sonnet 4.5 cache-write by 65% and overstates Haiku 4.5 by 82%**; $2.50 matches only Sonnet 5's *Global* rate. (2) Our `us.anthropic.*` ids are **Regional CRIS**, priced exactly 10% above Global — but `curated-models.ts` declares `us.anthropic.claude-haiku-4-5-20251001-v1:0` (our default model) with the **Global** numbers ($1.00/$1.25/$0.10/$5.00 vs Regional $1.10/$1.375/$0.11/$5.50). Ratios right, base 10% low. ⚠️ **Do NOT retroactively rewrite historical cost rows** — fix forward and annotate, or the time series the cost-effectiveness arc depends on loses comparability. Also needs one query against the managed-models table in dev to see what admins actually seeded (the `3.75` figures in `calculator.py:43` / `pricing_config.py:59` are **docstring examples, not live defaults** — the live path reads the DynamoDB row). +- **Decision**: Resolved — shipped in part. +- **Reasoning**: PR [#914](https://github.com/Boise-State-Development/agentcore-public-stack/pull/914) (merged 2026-09-03) corrected the cache-write rule and the Global/Regional tier in `CLAUDE.md` and `curated-models.ts`. Verified 2026-09-04: the same `$2.5/MTok` constant survives in `model_config.py:380`, `turn_based_session_manager.py:19`, `test_compaction_stability.py:8`, `test_prompt_cache_observability.py:464` and `docs/specs/compaction-over-threshold-cache-spiral.md:13,252`; the replacement helper hardcodes `cacheRead = input * 0.1`; and `CLAUDE.md:42`'s Price List API provenance claim did not reproduce on the 2026-09-04 scan. **The unfinished remainder is the [2026-09-04] entry of the same subject** — this entry is closed so the work is tracked in exactly one place. +- **Reviewed in**: reviews/2026-08-28.md ▸ Proposal #1 (Ship) → reviews/2026-09-04.md ▸ Proposal #1 (remainder) + +### [2026-08-28] Take Strands 1.51 → 1.54 as an instrumented cache experiment, not a routine bump → RESOLVED — **MERGED** into the [2026-09-04] gate entry +- **Source**: research/2026-08-28.md ▸ Top 5 #2 — https://github.com/strands-agents/sdk-python/pull/3681 (**BREAKING**, 1.53.0) · https://github.com/strands-agents/sdk-python/pull/3858 (fixes the #3758 blocker) · https://github.com/strands-agents/sdk-python/pull/3999 (`cancel_signal`, 1.54.0) · https://github.com/strands-agents/sdk-python/pull/2326 (history-mutation byte-stability, 1.54.0) +- **Surface**: backend (`core/model_config.py:349-391` — the three-point comment block + `CacheConfig(strategy="auto")`; `core/agent_factory.py:199-213` — our hand-built system cachePoint; `tests/agents/main_agent/core/test_bedrock_cache_points.py` — the position test that is the safety net; `session/turn_based_session_manager.py` — the compaction baseline #3886 moves; `backend/pyproject.toml:59` and `:74`) +- **Effort × Impact**: M × H +- **Subtracts**: likely yes, in the best possible place — our hand-built `SystemContentBlock` cachePoint and the 40-line comment justifying it; also strikes the stale "blocked by #3758" caveat on the [2026-08-14] cookbook entry +- **Unlocks**: `cancel_signal` — the first primitive that can actually **stop an in-flight Bedrock call** when a client disconnects (our #863 fix releases the lease and keeps paying); usable per-section TTLs, which are the precondition for a per-lane TTL policy that two independent harnesses converged on this month +- **Status**: open — **the collision is the point.** `CacheConfig(strategy="auto")` now places a **system-prompt cache point automatically** (#3681), and we already hand-place one. The comment at `model_config.py:358` explicitly asserts auto "does not touch the system/tools points" — **no longer true on 1.53.0+**, and it will actively mislead the next person who bumps. Order of work: (a) **diff the 1.51→1.54 wheels, not the release notes** — they are monorepo-wide and #3505's `ContextManager` claim is TypeScript-only; (b) determine whether auto's system point duplicates or replaces ours and delete ours if equivalent; (c) confirm we're still inside Bedrock's 4-point budget via the position test; (d) measure `cacheStatus` / `toolConfigHash` / `systemPromptHash` / read-vs-write tokens on real `C#` rows before and after, treating `partial_miss` rate as the primary signal. Be willing to pin at 1.52.0 if 1.53's placement is worse than ours. ⚠️ Note our own issue #3348 (rolling message cachePoints) had **no movement** and #3681 makes its budget question *more* constrained — a bump-and-ping is no longer enough; offering the `message_cache_points` policy decision or a PR is the unblock. +- **Decision**: Resolved — merged, not declined. +- **Reasoning**: research/2026-09-04 sharpened this into `[2026-09-04] Gate the Strands 1.51 → 1.54 bump on the system-cachePoint collision` with verified specifics (four named checks; upstream #4168, filed 2026-09-04, confirms the `bedrock_cache_points_supported()` gate is load-bearing rather than redundant) and said in terms *"merge them at review"*. Two entries for one bump is how a premise drifts. The **comment fix was split out** as its own Low-effort item (reviews/2026-09-04.md ▸ Proposal #3) so the trap can be disarmed without waiting on the experiment. Still pinned `1.51.0`. +- **Reviewed in**: reviews/2026-08-28.md ▸ Proposal #6 (Ship, unactioned) → reviews/2026-09-04.md ▸ Proposals #3 + #7 (Defer 2 weeks → 2026-09-18) + +### [2026-08-28] Make `supported_params` omission mean *unsupported*, and audit model lifecycle → RESOLVED — **SHIPPED** (#915) +- **Source**: research/2026-08-28.md ▸ Top 5 #3 — https://platform.claude.com/docs/en/about-claude/model-deprecations (`temperature`/`top_p`/`top_k` "Returns a 400 error when set to a non-default value" on Opus 4.7+; retirement floors Sept 29 / Oct 15) +- **Surface**: backend / frontend (`apis/inference_api/chat/routes.py:294-360` — the merge/filter and its pass-through loop; `agents/main_agent/core/model_config.py:45-102` — `_BEDROCK_PARAM_MAP` / `KNOWN_CANONICAL_PARAMS`; `curated-models.ts` Opus 4.7 + Sonnet 5 templates; `apis/shared/models/models.py` `SupportedParams`) +- **Effort × Impact**: L × H +- **Subtracts**: yes — one default inversion closes the entire class, instead of adding `temperature: {supported: false}` to every 4.7+ template and hoping nobody forgets one on the next model +- **Unlocks**: safe Opus 5 onboarding (it inherits the same restriction); the first model-lifecycle signal the registry has ever had +- **Status**: open — **verified end-to-end in code this run.** The chain: `temperature` ∈ `_BEDROCK_PARAM_MAP` → ∈ `KNOWN_CANONICAL_PARAMS` → the Opus 4.7 curated template **omits** it from `supportedParams` → so it is not in `seen_keys` → the pass-through loop forwards it ("Request keys for params the managed model says nothing about pass through untouched") → Bedrock → **hard 400, mid-stream.** Zero grep hits for any temperature-suppression guard in `backend/src/` or `frontend/ai.client/src/`. The trap is semantic: the curated templates express "not supported" by **omission**, and the filter reads omission as **permission**. Two pieces: (1) for a model that declares a spec at all, omission should mean unsupported — keep today's permissive behavior only for records with **no** spec, and log every omission-drop so the change is observable; (2) check `claude-haiku-4-5-20251001` (**42 refs**) and `claude-sonnet-4-5-20250929` (**18 refs**) against **Bedrock's** retirement schedule, not Anthropic's — the docs are explicit that partner platforms set their own dates. +- **Decision**: Resolved — shipped. +- **Reasoning**: PR [#915](https://github.com/Boise-State-Development/agentcore-public-stack/pull/915) `fix(models): treat an omitted supported_param as unsupported, not pass-through` (merged 2026-09-03). Closes the verified live 400 on Claude Opus 4.7+ / Sonnet 5 by inverting one default rather than adding a per-model check, ahead of Opus 5 entering the catalog. The **model-lifecycle audit half** (Bedrock-side retirement dates for `claude-haiku-4-5-20251001` and `claude-sonnet-4-5-20250929`) was not part of #915 — re-raise it if the Sept 29 / Oct 15 Anthropic floors start to matter; Bedrock sets its own schedule. +- **Reviewed in**: reviews/2026-08-28.md ▸ Proposal #2 (Ship) + +### [2026-08-28] Retire the tool-mutation premise and strike two resolved MCP blockers → RESOLVED — **EXECUTED** +- **Source**: research/2026-08-28.md ▸ Top 5 #4 — https://platform.claude.com/docs/en/build-with-claude/prompt-caching (invalidation table: modifying tool definitions invalidates the entire cache; no beta header, parameter, or dated availability anywhere on the page) · https://raw.githubusercontent.com/modelcontextprotocol/ext-apps/main/specification/2026-01-26/apps.mdx (both verification questions answered) · https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning (back-compat explicitly specified) +- **Surface**: docs / process (`docs/kaizen/review-queue.md` only — no code) +- **Effort × Impact**: L × M–H +- **Subtracts**: yes — one entry retired outright, two verification blockers struck, three dependent framings freed +- **Status**: open — **three evidence-backed resolutions, and review-prep runs against this file in ~2 hours.** (1) **[2026-08-14] "Probe the mid-conversation tool-mutation beta"** → resolve as **premise not substantiated**; Anthropic's caching docs say the opposite and name no beta. It was last week's recommended #1, and its Unlocks section is cited as the unblocker for cross-source tool search, per-tool MCP enablement, and `@`-mention prefix cost — **restore those three to their own merits.** (2) **[2026-08-14] MCP Apps host migration** → **strike both "cheap verifications required before any code" prerequisites**: the capability id is confirmed **`io.modelcontextprotocol/ui` under `capabilities.extensions`** and `ui/notifications/tool-input-partial` is confirmed **present** in the spec — *both matching what `mcp_apps.py` already ships*. **Down-rank** the entry too, since back-compat for initialization-based revisions is explicitly specified. Also record that the [2026-05-29] entry resolved as "asserts an unverified identifier" **was right all along**. (3) **[2026-08-14] cost_optimization cookbook audit** → **strike the "⚠️ blocked by Strands #3758" caveat**; the Python-side fix shipped in 1.53.0 via #3858, so the layered-TTL technique (54% cheaper upstream) becomes available on the bump above. *(This skill does not edit `## Resolved` — that move is review-prep's job; this entry supplies the evidence.)* +- **Decision**: Resolved — executed. +- **Reasoning**: all three resolutions were carried out in reviews/2026-08-28.md ▸ Retirement Candidates and are recorded in the Resolved trail below: the tool-mutation probe retired as *premise not substantiated*, both MCP Apps verification blockers struck, and the stale `#3758` caveat struck from the cookbook entry. Nothing further to do — the entry was a docs/process action with no code surface. +- **Reviewed in**: reviews/2026-08-28.md ▸ Proposal #4 / Retirement Candidates + +### [2026-08-14] Migrate the MCP Apps host off `initialize`/`serverInfo` to `server/discover` → RESOLVED — **MERGED** into the [2026-09-04] entry +- **Source**: research/2026-08-14.md ▸ Top 5 #2 — MCP **2026-07-28 is now the Current protocol version**; SEP-2575 removed the `initialize`/`initialized` handshake + `Mcp-Session-Id` — https://modelcontextprotocol.io/specification/versioning · https://blog.modelcontextprotocol.io/posts/2026-07-28/ +- **Surface**: backend (`agents/main_agent/integrations/mcp_apps.py:673` — the `getattr(result, "serverInfo", None)` capture; `_mcp_apps_server_info` consumers ~L454–461 / L628–635; `streaming/stream_coordinator.py:1680` `ui_resource` header emission; the `ClientCapabilities(experimental=...)` subclassing at `mcp_apps.py:26`) +- **Effort × Impact**: M × M–H +- **Subtracts**: yes — retires the `initialize`-response dependency, and collapses the "fresh MCP session per call" concern behind the MCP Apps proxy-call 504 work (there is no protocol-level session left to preserve) +- **Unlocks**: conformance with a published host matrix (Claude, VS Code Copilot, M365 Copilot, Goose, Postman); readiness for **MRTR (SEP-2322)** — the sanctioned interrupt/resume shape, which would let OAuth consent and tool approvals resume *without* holding an SSE stream open against the 600s timeout; readiness for SEP-2243 header-based Gateway routing +- **Status**: open — **DOWN-RANKED 2026-08-28; both verification blockers STRUCK.** Supersedes the [2026-07-24] "prep the MCP Apps host for the 2026-07-28 spec" item. research/2026-08-28 answered both prerequisites against the apps spec source, and **both came back matching code we already ship**: the capability id is `io.modelcontextprotocol/ui` under `capabilities.extensions`, and `ui/notifications/tool-input-partial` is **present** in the spec (so the `ui_tool_input_partial` relay is safe). The "do not write code until verified" gate is therefore gone — but so is the urgency: MCP **explicitly specifies back-compat for initialization-based revisions**, so a server that upgrades keeps serving handshake-era clients. This is real work, not urgent work. Keep the handshake path as a compatibility branch (`server/discover` is mandatory for servers, **optional for clients**). Note for the trail: the [2026-05-29] entry resolved as "asserts an unverified identifier" **was right all along** — the identifier is confirmed, and confirming it was the cheap thing to do. +- **Decision**: Resolved — merged, not declined. +- **Reasoning**: superseded by the [2026-09-04] entry of the same name, which carries the same surface plus the fact that changed the urgency: **FastMCP 4.0.0 (2026-08-31, breaking)** shipped the sessionless protocol our Lambda-backed MCP servers actually run on, and Strands 1.53 is concurrently churning the `ClientSession` we monkeypatch. research/2026-09-04 said *"merge them at review"*. The MRTR-readiness half of the [2026-05-10] `oauth_required` entry folds in here as well. +- **Reviewed in**: reviews/2026-08-28.md ▸ down-ranked, both blockers struck → reviews/2026-09-04.md ▸ Proposal #9 (Defer 2 weeks → 2026-09-18; afternoon spike now) + +### [2026-07-10] Wire a CloudWatch `ActiveSessionCount` alarm on the inference-api runtime → RESOLVED — **UNBLOCKED + MERGED** into the [2026-09-04] entry +- **Source**: research/2026-07-10.md ▸ Top 5 #5 — **NEW** AgentCore Runtime `ActiveSessionCount` metric (https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/release-notes.html) +- **Surface**: infrastructure (CloudWatch alarm on the inference-api runtime's `AWS/Bedrock-AgentCore` `ActiveSessionCount` gauge; PlatformStack observability) +- **Effort × Impact**: L × M +- **Subtracts**: no — ops addition; justified as cheap early-warning for the exact failure class the agentcore bump fixes (defense-in-depth while the bump is pending) +- **Unlocks**: proactive detection of session-leak/exhaustion and the #482 hang (a hung container manifests as session pileup) before a 429 +- **Status**: open — low-effort ops win that pairs with the agentcore bump. Alarm when concurrent sessions approach the raised quota (5,000 us-west-2). +- **Decision**: Resolved — merged, not declined. **The blocker cleared.** +- **Reasoning**: this entry sat open since 2026-07-10 because the metric did not exist. `AWS/Bedrock-AgentCore` now publishes `ActiveSessionCount` once per minute for Runtime and built-in tools, and PR #910 shipped the `AlarmFactory` that routes any alarm to `{prefix}-alarms` as a consequence of being used — so the work is now a ten-line construct change into one-week-old machinery. Tracked in the [2026-09-04] `ActiveSessionCount` + instance-based-Runtime entry, which research said to merge at review. +- **Reviewed in**: reviews/2026-09-04.md ▸ Proposal #6 (Ship part 1) + ### [2026-08-14] Probe Anthropic's mid-conversation tool-mutation beta on Bedrock → RESOLVED — **premise not substantiated** - **Source**: research/2026-08-14.md ▸ Top 5 #1; recommended **#1 (Ship)** in reviews/2026-08-14.md ▸ Proposal #1. - **Decision**: **Retire.** Not declined-on-priority — the premise is contradicted by the primary source. diff --git a/docs/kaizen/reviews/2026-09-04.md b/docs/kaizen/reviews/2026-09-04.md new file mode 100644 index 000000000..1cbe9f5e3 --- /dev/null +++ b/docs/kaizen/reviews/2026-09-04.md @@ -0,0 +1,251 @@ +# Kaizen Review — Friday, September 4, 2026 + +> Prepared 9:40am MT. Review window: **August 28 – September 4** (7 days). +> Source: research/2026-09-04.md + review-queue.md (**43 open** before this pass, **39** after). +> Note: branched off `kaizen/research-2026-09-04` (PR #926, unmerged at time of writing) so it can consume today's research and its five queue additions, with `develop` merged in so the diff is kaizen-only. Same arrangement as last week. + +## Week in Review + +**The decision half of the loop started working.** Of last review's ten proposals, **three shipped as real PRs within five days** — [#914](https://github.com/Boise-State-Development/agentcore-public-stack/pull/914) (the rate-tier correction, Proposal #1), [#915](https://github.com/Boise-State-Development/agentcore-public-stack/pull/915) (`supported_params` omission means unsupported, Proposal #2), and [#916](https://github.com/Boise-State-Development/agentcore-public-stack/pull/916) + [#921](https://github.com/Boise-State-Development/agentcore-public-stack/pull/921) (queue-instead-of-interrupt, Proposal #5). #921 went *past* the proposal: what was scoped as a frontend `queuedMessage` signal shipped as a full mid-turn steering path — a lease-row inbox, a `POST /sessions/{id}/steer` arming endpoint, injection at the next tool boundary, a `steering_applied` SSE event with a CLAUDE.md contract row, and a test locking cachePoint placement around the injection. After two consecutive cycles of **zero** conversion, that is the headline, and it is worth saying plainly before the criticism starts. + +It was also the busiest window this forum has ever reviewed: **31 merged PRs, 49 non-merge commits, two releases** (1.16.0 on 08-28, 1.17.0 on 09-02), a 77-alarm observability baseline where previously *none* of 13 alarms were routed, and **87 security alerts cleared** (47 Dependabot, 40 CodeQL) — including a workflow-injection root cause in `nightly.yml` that reached nine checkouts. + +The uncomfortable half: **the correction we shipped did not finish, and the scan that told us so has its own accuracy problem.** #914 fixed `$2.50/MTok` in `CLAUDE.md` and left the identical constant in six places across five code/spec files, replaced it with a helper hardcoding `cacheRead = input × 0.1` that research says is already false for a GA Bedrock model, and wrote a provenance claim into the contract — *"rates … come from the AWS Price List API"* — that **this week's scan could not reproduce**. Meanwhile research/2026-09-04 reports "**zero CI failures in the window**" (there was one, on develop, Sept 1) and pins Angular at 21.2.17 (the tree it scanned says **21.2.19**). Two cycles ago the research half was the reliable one. This week both halves need a verification rule, not just one. + +## Friction — the week's signal + +### Repeated patterns (≥2 occurrences) + +- **A stated fact in the research doc does not reproduce** (3 occurrences across 2 cycles — 1 external, 2 internal, all self-inflicted). (a) **2026-08-28**: Claude 4.x/5.x rates verified from the AWS Price List API in us-west-2, which drove #914. **2026-09-04**: an enumeration of 11,621 `usagetype` values across all regions found **no Claude SKU newer than Claude 3**, none with cache or output dimensions. Same API, same region, opposite result. (b) **2026-09-04**: *"CI failures: none in the window. `gh run list --status=failure --limit 30` returns nothing newer than 2026-04-03 … CI has been green for five months of runs."* The same command run today returns **2026-09-01 · develop · Backend Deploy · failure** — inside the window — plus the two August nightlies the *previous* scan reported. (c) **2026-09-04**: the version-pin table gives `@angular/core` as `21.2.17 (2026-06-10)`; `frontend/ai.client/package.json` **on the research branch itself** reads `21.2.19`, bumped by #924 seventeen minutes before that PR opened. + - *Hypothesis*: one root cause. The research pass **reports** a query's result without **re-running it against the tree it is describing**. External claims get a source URL; internal claims get an assertion. (b) and (c) are both "the answer was in the checkout" — cheaper to verify than to write. + - *Candidate fix*: two lines in `kaizen-research/SKILL.md`. (1) Every internal-audit number must be produced by a command **quoted in the doc**, so it is re-runnable — the version-pin table already does this for registry JSON and is the strongest section as a result. (2) A rate, price, or capability figure that will be **encoded in code** requires two independent sources or an explicit `⚠️ single-source` marker. See **Proposal #5**. + +- **The forum's own maintenance never lands, four cycles running.** Last review Ship-recommended amendments to both kaizen skill files (Proposal #3 — ✅ produces a tracked GitHub issue; Proposal #4b — never resolve a flake on a green streak). Verified today: **no `kaizen` label exists in the repo, zero `kaizen`-labelled issues**, no green-streak rule in either skill, and `.claude/skills/kaizen-review-prep/SKILL.md` has not been touched since **2026-05-10** — seventeen weeks, across four reviews that each proposed changing it. `kaizen-research/SKILL.md`: 2026-05-29. + - *Hypothesis*: **feature work ships; process work does not.** Three code proposals converted in five days; every process proposal in the same document converted at zero, for the fourth consecutive cycle. The forum is a good ranking engine for engineering work and a bad one for its own machinery, and it keeps proposing the latter as if the conversion rate were the same. + - *Candidate fix*: stop proposing the mechanical-tracking layer (#3 last week) — it has failed twice and its premise is now disproven, because items **shipped this week without it**. Ship only the two cheapest skill edits that pay for themselves immediately, in the same PR as this review if Phil marks them ✅. See **Proposal #5**. + +- **Concurrent `platform.yml` and `backend.yml` on one push, and the runtime step lost the race** (1 hard occurrence, 1 known prior class). The Sept 1 failure: `Deploy inference-api image to AgentCore Runtime` → `[inference-api] Failed to get-agent-runtime — runtime may not exist yet.` → exit 3, on run [33559336336](https://github.com/Boise-State-Development/agentcore-public-stack/actions/runs/33559336336). Both **Platform Stack** and **Backend Deploy** were triggered by the same develop push at `21:07:5x` (the #904 merge); Platform Stack succeeded, Backend Deploy's runtime step did not. This is the deploy-order race the team already knows about — `platform.yml` before `backend.yml` is documented as a convention and **not enforced** by anything. + - *Hypothesis*: `get-agent-runtime` was called while CFN was mid-replace on the Runtime resource. Recovered on the next push, so it reads as a flake — which is precisely the resolution rule the last two reviews argued against. + - *Candidate fix*: a shared concurrency group, or a wait-for-platform gate at the head of the inference-api deploy job. See **Proposal #4**. + +### One-offs worth watching + +- **`artifact` and `metadata` are emitted on the chat stream and have no row in CLAUDE.md's SSE table.** Both come out of `stream_coordinator.py`; `metadata` appears only inside the prose of other rows. Credit where due — the brand-new `steering_applied` **was** documented with its row in the same PR that shipped it, which is the convention working. These two predate it. +- **`cors-deployment` is exactly as dormant as the two skills research flags, and has never been flagged.** Three skills sit at **2026-04-27**: `angualar-best-practices`, `frontend-design`, and `cors-deployment`. The dormancy list has been reporting two of the three for four consecutive scans. +- **Research softened the dormant-skill recommendation this week** — from three consecutive *delete* recommendations to *"a flag for a decision, not a recommendation to delete."* That reversal is defensible (a reference skill earns its place by being read, not edited) but it needs to be a decision, not a fifth flag. See **Proposal #8**. +- **Zero issues opened, second consecutive window**, against 49 open. 31 PRs merged and not one of them produced a filed issue. +- **`bedrock-agentcore` 1.22.0 remains a no-op for us** (payments only) and there is still no upgrade pressure — worth recording as a *non*-finding so it stops occupying a row in the lag table. + +### Silence that matters + +- **Zero comments on the kaizen PRs — third consecutive cycle.** [#891](https://github.com/Boise-State-Development/agentcore-public-stack/pull/891) and [#892](https://github.com/Boise-State-Development/agentcore-public-stack/pull/892) both merged with no comments and no reviews, and [#926](https://github.com/Boise-State-Development/agentcore-public-stack/pull/926) has none yet. **But this week the conclusion inverts.** For two cycles the skill read that silence as the feedback loop being broken; three items shipped anyway, in code, within five days. The mechanism the skill specifies — *POC over the weekend, comment findings on the research PR, they become first-class signal next Friday* — has produced **nothing in five cycles**, while the channel that actually works is Phil implementing the item. That is not a broken loop; it is a **mechanism the skill is wrong about**, and its "POC-tested items outrank untested" tiebreak has never once fired. Retire it. See **Proposal #5**. +- **`duration_ms` tool-timing — ninth cycle.** Carried since 2026-05-15; **DROP** (2026-07-03), **Decline** (2026-08-14), **Decline** (2026-08-28). Still in `## Open`. Three decline recommendations and nobody has spent the keystroke. +- **`oauth_required` SSE flow audit — sixth surfacing**, ~15 weeks past its revisit date. reviews/2026-08-14 said *"It must not carry a fifth review"*; 08-28 said Decline; it carried. +- **The three upstream AgentCore issues (#564, #621, #629) went unmentioned in this week's scan** after two cycles of "upstream will not save us." Not obviously wrong — the conclusion was reached — but the #629 consequence (our cost telemetry systematically under-reports the **last turn of every session**) is now unowned by any queue entry. + +## Proposals — ranked + + + +### 1. Finish the #914 rate correction — six surviving constants, a per-model cache-read ratio, and a provenance claim that doesn't reproduce + +- **Source**: research/2026-09-04.md ▸ Top 5 #1 | review-queue.md (open since 2026-09-04) | direct verification of #914's merged state +- **Surface area**: backend / frontend / docs — `model_config.py:380`, `turn_based_session_manager.py:19`, `test_compaction_stability.py:8`, `test_prompt_cache_observability.py:464`, `docs/specs/compaction-over-threshold-cache-spiral.md:13,252`, `curated-models.ts:96-97`, and the prompt-cache contract bullet in `CLAUDE.md:42` +- **Change**: three steps, ascending in cost. **(1)** Strike the surviving `$2.5/MTok` constants — verified present today, after #914 merged; the one at `model_config.py:380` sits inside the 40-line cachePoint-budget comment, which is the single worst place in the repo for that number to be wrong. **(2)** Make the cache-read rate an *input* to the `curated-models.ts` helper with `0.1` as a documented default, rather than the hardcoded `round(input * 0.1)` it is today — so the next model that breaks the ratio is a data change, not a code change. **(3)** Re-run last week's Price List API query verbatim, then make `CLAUDE.md:42` name the source that **actually reproduces**. +- **Subtracts**: six duplicated wrong constants across five files, one hardcoded ratio, and one unreproducible sourcing claim currently stated as fact in the merge-gating contract. +- **Effort**: Low · **Impact**: High +- **POC findings**: **shipped-code evidence** — #914 is merged and its remainder is verified by grep, not inferred. ⚠️ One caution: research's Fable 5.1 `0.025×` figure rests on a **single announcement page**, which is the same evidence class that produced both the original `$2.50` error and this week's Price List contradiction. Step (2) is worth doing regardless of the number, because it removes the hardcoding; do **not** encode `0.025` until it reproduces from a second source. +- **Ship means**: one PR striking the constants and making the ratio a parameter; the provenance re-run is a 10-minute check appended to it (or a `⚠️ single-source` marker on the contract line if it still doesn't reproduce). +- **Decline means**: the most cost-sensitive comment in the codebase keeps a wrong number, the contract keeps a provenance claim nobody can reproduce, and the next re-derivation hits the same wall that let `$2.50` survive three scans. +- **Recommendation**: **Ship** — cheapest item on the board, second week running, and it is now half-done rather than not-started. + +### 2. Derive `missCause` from the fingerprint that flipped + +- **Source**: research/2026-09-04.md ▸ Top 5 #3 | review-queue.md (open since 2026-09-04) +- **Surface area**: backend — `apis/shared/observability/prompt_cache.py` (where `classify_cache_status` already runs), `agents/main_agent/session/hooks/prefix_fingerprint.py`, `GET /admin/costs/sessions/{id}/calls`, the admin cost-anatomy page, and the debugging quick-reference in `CLAUDE.md` +- **Change**: on the row where `cacheStatus` is already computed, compare each of `toolConfigHash` / `systemPromptHash` / `historyHash` against the previous `C#` row for the session and persist a `missCause` label — `tools_changed`, `system_prompt_changed`, `history_changed`, `agent_switched` (free from the existing `agentSwitched` flag), `ttl_expired`, `cold_start`. Surface it in the calls API and the anatomy page. +- **Subtracts**: the manual hash-diff ritual `CLAUDE.md` instructs a human to perform on every cost-spike investigation — *"the hash that changed between consecutive calls names the cache-buster."* Verified today: the three hashes are persisted, `classify_cache_status` exists, and `missCause` appears nowhere in `backend/src/`. The comparison is the only missing piece. +- **Unlocks**: **naming a cause we currently cannot see at all.** An external MCP server that edits a tool's `inputSchema` between turns re-writes our cacheable prefix with **no deploy of ours** — research measured 17 such changes across 248 servers in 27 hours. `toolConfigHash` already **detects** it; a `tools_changed` label on a row with no deploy is the first time we could **attribute** it. +- **Effort**: Low–Med · **Impact**: High +- **POC findings**: not POCed. Corroborated externally — Claude Code shipped the same feature in 2.1.260. +- **Ship means**: one backend PR (the comparison + the persisted field), one small frontend change on the anatomy page, and delete the manual-diff instruction from `CLAUDE.md` in the same commit. +- **Decline means**: every future cost investigation stays a human hash-diff, and out-of-band prefix mutation stays invisible — an unexplained `miss_avoidable` nobody can attribute to any change we made. +- **Recommendation**: **Ship** — research's own Take says this is what Phil would notice first, and it is the rare cost item that *subtracts* a documented human procedure. + +### 3. Strike the false cachePoint invariant comment now, decoupled from the Strands bump + +- **Source**: research/2026-09-04.md ▸ Top 5 #4, **split** | reviews/2026-08-28.md ▸ Risks (*"at minimum fix the comment, even if Proposal #6 is deferred"* — not actioned) +- **Surface area**: backend — `core/agent_factory.py:213-224` and the cachePoint-budget comment at `core/model_config.py:375-400` +- **Change**: rewrite two comments. `agent_factory.py:222` currently asserts *"Strands' auto strategy strips only message-level cachePoints, never system ones"* — verified verbatim on disk today. That is a 1.51-era fact which Strands **1.53.0 (#3681, marked breaking)** falsifies: `strategy="auto"` now places a system-prompt cache point itself. Replace the assertion with the version-scoped truth plus an explicit *"re-verify before any bump past 1.52"* gate. Same pass fixes the `$2.5/MTok` figure in the adjacent budget comment (shared with Proposal #1). +- **Subtracts**: a comment that actively misleads, in the most cost-sensitive file we own, on the exact question a reader consults it for. This is the whole change — no behavior moves. +- **Effort**: Low · **Impact**: High *(as risk removal, not as a feature)* +- **POC findings**: not POCed; the collision is documented in the upstream PR and the false assertion is verified in the file. +- **Ship means**: a comment-only PR, ten minutes, mergeable today and independent of whether the bump ever happens. +- **Decline means**: the trap stays armed. The failure mode is a duplicated or relocated system cachePoint — a full prefix re-write at 1.25× base input on **every session** — shipped by someone doing a routine dep bump, visible only in `cacheStatus`/`systemPromptHash`, never as an error. +- **Recommendation**: **Ship** — this was already the minimum recommendation last week and it cost nothing then either. Splitting it out is the point: the comment fix must not be hostage to the Med-effort experiment in Proposal #7. + +### 4. Enforce platform-before-backend on a develop push + +- **Source**: direct observation — Friction ≥2 | run [33559336336](https://github.com/Boise-State-Development/agentcore-public-stack/actions/runs/33559336336), the one CI failure in the window and the one research reported as not existing +- **Surface area**: CI — `.github/workflows/backend.yml` (the inference-api AgentCore Runtime deploy job) and/or a shared concurrency group with `platform.yml`. No application code. +- **Change**: either put `platform.yml` and `backend.yml` in one concurrency group so they serialize on a shared push, or add a wait-for-platform gate at the head of the inference-api runtime job. Also make `Failed to get-agent-runtime` retry rather than `exit 3` — it is a read, and it recovered unaided on the next push. +- **Subtracts**: an unenforced deploy-order convention that currently lives only in `CLAUDE.md` prose and in a memory note, plus one class of red develop build that reads as a flake. +- **Effort**: Low–Med · **Impact**: Med–High +- **POC findings**: not POCed. The evidence is a log line and two concurrent run timestamps on the same push, both verified. +- **Ship means**: one CI PR. ⚠️ Check it against the known **GSI deploy-ordering trap** first — a shared concurrency group has previously *cancelled* a run rather than queueing it, which would be a worse failure than the one being fixed. +- **Decline means**: every push that touches both surfaces can lose the inference-api rollout, and the recovery is invisible — the next push silently fixes it, so nobody learns the runtime went un-updated for a window. +- **Recommendation**: **Ship** — and note that this item exists *only* because the review re-ran research's CI query. That is the argument for Proposal #5 in one line. + +### 5. Retire the POC-comment feedback mechanism; adopt two verification rules + +- **Source**: direct observation — Friction ≥2 ×2 | Silence that matters | reviews/2026-08-28.md ▸ Proposals #3 and #4b (both Ship-recommended, both unactioned) +- **Surface area**: skills — `.claude/skills/kaizen-research/SKILL.md` and `.claude/skills/kaizen-review-prep/SKILL.md`. No code, no `CLAUDE.md`. +- **Change**: three edits, all subtractive or two-line. **(a) Delete the POC-comment loop.** Both skills specify that Phil POCs over the weekend and comments findings on the research PR, which review-prep then ranks — the `POC findings` field, the "tested outranks untested" tiebreak, and the one-week-lag philosophy all hang off it. It has produced **zero comments in five cycles** while three items shipped as code. Replace it with what demonstrably works: review-prep reads **merged PRs against the prior review's proposals** as the outcome signal. **(b) Internal-audit claims must quote their command.** Three reproduction failures in two cycles (Price List API, CI failures, Angular pin), and the one section that already quotes its method — the version-pin table — is the most reliable in the doc. **(c) Never resolve a flake on a green streak** — carried from last review's #4b, unadopted, and the Sept 1 deploy race is a live instance of exactly the entry class it governs. +- **Subtracts**: a whole specified mechanism and the ranking rule that depends on it — the largest single subtraction available this week — plus the "resolve on quiet interval" rule that produced a documented false negative in four days. Deliberately **does not** re-propose last week's #3 (✅ → tracked GitHub issue): it failed to land twice, and its premise that nothing converts a ✅ is now falsified by three shipped PRs. +- **Effort**: Low · **Impact**: Med–High +- **POC findings**: n/a — this proposal *is* the finding about POC findings. +- **Ship means**: one PR editing two skill files. If Phil marks this ✅ during the review, it can ride this review's PR rather than waiting a week — which is the only way a process item has ever landed here. +- **Decline means**: accept that the kaizen skills are frozen artifacts (17 weeks and 4 reviews of unadopted amendments), and stop spending a proposal slot on them. That is a legitimate answer and better than a fifth flag — but say it out loud in `decisions.md` so no future review re-proposes skill edits. +- **Recommendation**: **Ship (a) and (b), and this week specifically** — (a) is pure subtraction of something proven inert, and (b) is the cheapest possible answer to the only genuinely new friction in the window. Treat (c) as a rider. + +### 6. Wire the `ActiveSessionCount` alarm; scope instance-based Runtime as the first W5 lever + +- **Source**: research/2026-09-04.md ▸ Top 5 #5 | review-queue.md ([2026-07-10] entry, **unblocked** — merged into the [2026-09-04] entry this pass) +- **Surface area**: infrastructure — `lib/constructs/observability/` (the `AlarmFactory` that #910 made the only sanctioned path), the AgentCore Runtime construct; plus W5 in the cost-effectiveness roadmap +- **Change**: **(1, Low)** wire the alarm. The 2026-07-10 entry was blocked on the metric not existing; `AWS/Bedrock-AgentCore` now publishes `ActiveSessionCount` once a minute, and #910's `AlarmFactory` routes it to `{prefix}-alarms` as a *consequence* of being used. This is a ten-line construct change into machinery that shipped last week. **(2, Med)** scope instance-based Runtime: research found 889 new `Runtime:Instance-based:*:Management-Hours` SKUs in the 2026-09-01 price republish. The question is arithmetic against numbers we already hold — at 18–50 min microVM lifetimes and our measured concurrency, does a reserved instance beat `$0.0895/vCPU-hr + $0.00945/GB-hr`? +- **Subtracts**: the `/ping` access-log as our runtime-lifetime instrument — a proxy adopted only because no real metric existed. +- **Unlocks**: the **first new lever on W5 since it was named.** Runtime memory is ~73% of the AICC bill; every cost win to date has been on the token side. +- **Effort**: Low (alarm) + Med (scope) · **Impact**: Med–High +- **POC findings**: not POCed. ⚠️ Research derived the ~12% management-fee ratio itself rather than reading it published — verify EC2 base rates before any case rests on it. +- **Ship means**: part (1) as an infra PR now; part (2) as a spreadsheet and a paragraph, which either becomes a proposal or closes W5's "no proposal against it" gap with a documented *no*. +- **Decline means**: session-leak and exhaustion stay undetected until a 429, and W5 — the largest single share of the bill — keeps having no proposal against it for a fourth month. +- **Recommendation**: **Ship part (1)** — it is the cheapest alarm we will ever add and it lands in machinery that is one week old and warm. **Do part (2) as arithmetic**, not a build. + +### 7. Gate the Strands 1.51 → 1.54 bump on the system-cachePoint collision + +- **Source**: research/2026-09-04.md ▸ Top 5 #4 (minus the comment fix, now Proposal #3) | review-queue.md ([2026-08-28] entry, merged into the [2026-09-04] entry this pass) | reviews/2026-08-28.md ▸ Proposal #6 (**Ship**-recommended, unactioned; still pinned `1.51.0`) +- **Surface area**: backend — `core/model_config.py:375-400` (`strategy="auto"` + the `bedrock_cache_points_supported()` gate), `core/agent_factory.py:213-224`, `TurnBasedSessionManager` (against 1.52's tool-pair trimming), `backend/pyproject.toml:59,74` +- **Change**: an instrumented experiment, four checks. (a) Does 1.53's auto-placed system point **duplicate or replace** ours? Delete our `SystemContentBlock` list if it duplicates. (b) Is 1.52's "trim at complete tool pairs" even reachable given our custom session manager — two trimmers choosing different boundaries is exactly the byte-instability the compaction redesign exists to prevent. (c) **Keep** the `bedrock_cache_points_supported()` gate: upstream #4168 (filed 2026-09-04) is a live report of the crash it prevents, so it is load-bearing, not redundant. (d) Measure before/after on `toolConfigHash`/`systemPromptHash` and the `AgentCoreStack/PromptCache` EMF metrics. +- **Subtracts**: potentially our hand-placed system cachePoint and the ~40-line comment defending it — the library-native subtraction this forum weights for, *if* 1.53's placement proves equivalent. +- **Unlocks**: 1.54's external cancellation signal (a candidate to retire the hand-rolled anyio `CancelScope` plumbing from #863 — our current fix releases the lease and keeps paying for the in-flight call) and `Agent.session_id` as a native session pin, which the G1 agent-cache read had to establish by hand. +- **Effort**: Med · **Impact**: High +- **POC findings**: not POCed. ⚠️ The reference repo runs 1.54 cleanly but does **not** hand-place a system cachePoint — their green build is not evidence for us. +- **Ship means**: a branch, a measured dev session before and after, and the pin moved only if the fingerprints hold. The verification *is* the work. +- **Decline means**: nothing breaks today — but with Proposal #3 shipped, the trap is documented rather than armed, which is most of the value at a fraction of the effort. +- **Recommendation**: **Defer 2 weeks (revisit 2026-09-18), conditional on #3 landing.** A change from last week's Ship, and deliberately: this is the highest cost-of-regression area in the codebase, the window just absorbed 31 PRs and two releases, and #3 buys the safety for ten minutes instead of a day. If #3 does *not* ship, this reverts to **Ship** — the trap cannot stay armed indefinitely. + +### 8. Decide the dormant skills terminally — and fix the `angualar` directory name either way + +- **Source**: research/2026-09-04.md ▸ Retirement candidates (**4th consecutive flag, recommendation reversed this week**) | direct verification +- **Surface area**: skills — `.claude/skills/angualar-best-practices/`, `.claude/skills/frontend-design/`, and `.claude/skills/cors-deployment/` +- **Change**: two separable things. **(a)** `git mv .claude/skills/angualar-best-practices .claude/skills/angular-best-practices`. **Correcting the record**: research has said for four scans that references to `angular-best-practices` "silently miss." Verified today — the *only* such reference is the skill's own frontmatter, which reads `name: angular-best-practices` while the invocation name resolves from the **directory**, `angualar-best-practices`. So the bug is real but smaller and more precise than described: the skill's declared name and its invocable name disagree, and a `/`-invocation has to type the typo. **(b)** Decide the dormancy question once. Three skills — not two — sit unmodified since **2026-04-27**: the two flagged, plus `cors-deployment`, which the list has never mentioned. +- **Subtracts**: either two-to-three skill directories, or four cycles of a recurring flag. One of the two has to go. +- **Effort**: Low · **Impact**: Low–Med +- **POC findings**: n/a. +- **Ship means**: the `git mv` regardless (it is one command and it makes the name typeable), plus a one-line verdict per skill: **keep** (a reference skill earns its place by being read, not edited — research's new position, and it is a fair one) or **delete**. Either verdict goes in `decisions.md` so the flag stops recurring. +- **Decline means**: a fifth flag next Friday, and the Retirement Candidates section keeps its weakest entry — which devalues the section that is supposed to carry this forum's subtraction bias. +- **Recommendation**: **Ship (a); mark (b) "keep, stop flagging"** and log it in `decisions.md`. Research reversed itself toward keep on a defensible argument, `cors-deployment` shows the dormancy signal was never complete, and dormancy alone has now failed four times to justify a delete. If a delete is wanted, it needs a *different* argument than the calendar. + +### 9. MCP Apps host off `initialize` → `server/discover` — spike the two gates first + +- **Source**: research/2026-09-04.md ▸ Top 5 #2 | review-queue.md ([2026-08-14] entry, merged into the [2026-09-04] entry this pass) +- **Surface area**: backend — `integrations/mcp_apps.py` (the `ClientSession` symbol patch at lines 23–32; the `serverInfo` capture at line 673), `external_mcp_client.py`, `gateway_mcp_client.py`, the mcp-sandbox proxy origin, the OAuth pre-flight path (#872) +- **Change**: resolve server identity and extension capability from `server/discover`, keeping `initialize` as a fallback. **This week's change is that the servers shipped it** — FastMCP **4.0.0** (2026-08-31, breaking) is what our Lambda-backed MCP servers actually run, so a spec migration became a real one, and Strands 1.53 is concurrently churning the very client we monkeypatch. +- **Subtracts**: the monkeypatch on `strands.tools.mcp.mcp_client.ClientSession` — taken *because* the SDK offered no hook, on a class Strands is actively changing, to participate in a handshake the spec is retiring. Three independent reasons it breaks, none of which we control. +- **Unlocks**: sessionless transport (removes the fresh-MCP-session-per-call cost behind the Apps proxy-call 504); a pre-flight that reads capabilities **before** `tools/list` — the exact 401 that permanently dropped a tool until #872 worked around it three wrappers down; and reachability for SEP-2549 cache hints (directly on-thesis for tool-listing token cost) and SEP-2243 routable headers. +- **Effort**: Med–High · **Impact**: High +- **POC findings**: not POCed. ⚠️ Research flags its own spec details as coming from **search summaries, not a first-hand changelog read** — given three reproduction failures this window, treat that as unverified until read. +- **Ship means**: an afternoon spike answering exactly two questions — does AgentCore Gateway speak `server/discover`, and does Strands' MCP client expose it — plus a first-hand read of the SEP-2567/2575 changelog. Both gate the estimate; neither is the migration. +- **Decline means**: the monkeypatch stays, and it breaks on someone else's schedule — a Strands bump, a FastMCP upgrade in a server repo we don't build, or the spec's removal — with no warning from our CI, because those repos are outside this one. +- **Recommendation**: **Defer 2 weeks (revisit 2026-09-18), but do the afternoon spike now.** The spike is Low effort and it is the only thing standing between this and a real estimate; committing to a Med–High migration in a week that just absorbed 31 PRs is not the call. + +### 10. docling #405 and Guardrails #480 — terminal answer, then out of this forum + +- **Source**: review-queue.md ([2026-06-05] and [2026-06-19]) | reviews/2026-08-28.md ▸ Proposals #8 and #9, both marked **"Ship or Decline — terminal"** | reviews/2026-07-03.md, reviews/2026-08-14.md +- **Surface area**: backend (docling pin, document ingestion) · backend + infrastructure (`BedrockModel` guardrail config, `CDK_GUARDRAIL_*` env threading) +- **Change**: stop carrying them here. Both issues are **still open**, verified today: [#405](https://github.com/Boise-State-Development/agentcore-public-stack/issues/405) (`.txt` uploads fail on the docling 2.81.0 content-sniffing defect — **13 weeks**, 5 reviews) and [#480](https://github.com/Boise-State-Development/agentcore-public-stack/issues/480) (configurable Bedrock Guardrails — **11 weeks**, 5 reviews, and still the strongest capability-unlock on the board). +- **Subtracts**: two queue entries and the category they have created — *"things this forum agrees are worth doing and will never do."* +- **Effort**: Low (the decision) · **Impact**: — (honesty, not throughput) +- **POC findings**: n/a. +- **Ship means**: do them — #405 is a dep bump and a manual upload test. +- **Decline means**: log both in `decisions.md` as **declined as kaizen items, retained as ordinary product backlog**, and post a comment on each issue saying so. Per the standing rule on this repo, *do not close them* — they are live, well-specified work; the decline is about which forum owns them, not about their merit. +- **Recommendation**: **Decline as kaizen items; keep the issues open as backlog.** This is the fifth carry. A kaizen loop that re-ranks the same two items every Friday for three months is not ranking them — it is storing them, and the issue tracker already does that better. Ship them when they come up in product planning, not because a Markdown file lists them again. + +## Carried Over From Prior Reviews + +- **`oauth_required` SSE flow audit** (deferred 2026-05-10 until 2026-05-24) — **~15 weeks overdue, sixth surfacing.** reviews/2026-08-14 said *"It must not carry a fifth review"*; 08-28 recommended **Decline to `decisions.md`** and nobody executed it. *Standing context*: SEP-2322 shipped final as MRTR, the sanctioned interrupt/resume shape with no held-open stream. *Recommendation*: **Decline — execute it this pass.** Fold the MRTR-readiness half into Proposal #9 and delete the standalone. A seventh surfacing would prove the Carried Over section is the graveyard the skill forbids. +- **`duration_ms` tool-timing into `tool_result` SSE** (carried since 2026-05-15; **DROP** 07-03, **Decline** 08-14, **Decline** 08-28) — **ninth cycle**, three decline recommendations, zero keystrokes. *Recommendation*: **Decline to `decisions.md`** — "deferred indefinitely; repeatedly out-prioritized, and context attribution shipped without it." +- **Nightly `DELETE_FAILED`** (resolved 2026-08-14 prematurely; re-opened 2026-08-28) — nightly has been green since Aug 22, **including 2026-09-04**. Per the rule this forum adopted for exactly this entry, that is **not** grounds to resolve it. *Recommendation*: **keep open with an explicit "accepted flake — 2 occurrences, Aug 20/21" note**, which is the resolution shape Proposal #5(c) prescribes. Note the Sept 1 deploy race (Proposal #4) is a *different* root cause on adjacent machinery. +- **Audit whether derived execution contexts re-evaluate RBAC** (deferred 2026-08-28 until 2026-09-11) — **not yet due.** Listed so it isn't lost; it re-ranks next Friday, and last review's stated condition (that a ✅ have somewhere to live) is now partly answered by three items shipping without any tracking layer. +- **Also due 2026-09-11, listed so they aren't forgotten**: AgentCore Runtime BYO filesystem (→ single agent-workspace / code-exec ADR); W5 Runtime **Instances** track 2 (**now partly answered by Proposal #6(2)** — fold them); `bedrock-agentcore` #629 dropped end-of-invocation spans (unowned by any entry, and it means our cost telemetry under-reports the last turn of every session); Named A2A participants (still blocked on an A2A server construct; ⚠️ `bedrock-agentcore` #583 — `serve_a2a` never hits the idle session timeout, which would reintroduce the runaway-microVM class #827 fixed). + +## Retirement Candidates + +- **The six surviving `$2.5/MTok` constants** (five files) — `model_config.py:380`, `turn_based_session_manager.py:19`, `test_compaction_stability.py:8`, `test_prompt_cache_observability.py:464`, plus `docs/specs/compaction-over-threshold-cache-spiral.md:13,252`. Verified present **after** #914 merged. Proposal #1. +- **The `agent_factory.py:222` invariant comment** — asserts behavior Strands 1.53.0 falsified, in the most cost-sensitive file we own. Proposal #3. +- **⭐ The POC-comment feedback mechanism in both kaizen skills** — the `POC findings` field, the "tested outranks untested" tiebreak, and the one-week-lag philosophy that hangs off them. **Five cycles, zero comments, and the tiebreak has never once fired** — while three items shipped as code in five days through a channel the skills do not describe. This is the largest available subtraction this week and the only one that makes the forum simpler rather than the codebase. Proposal #5. +- **The "resolve on a green streak" rule** — retired by last review's #4b, which was never adopted, so it is still in force. It produced a documented false negative four days after being applied. Proposal #5(c). +- **The `ClientSession` symbol patch** in `mcp_apps.py` — a monkeypatch on a Strands internal, to join a handshake the spec is retiring, on a class Strands is actively changing. Proposal #9. +- **The `/ping` access-log lifetime proxy** — superseded by the real `ActiveSessionCount` metric. Proposal #6. +- **The docling and Guardrails queue entries** — not the *work*, the **entries**. Fifth carry. Proposal #10. +- **Last review's Proposal #3 (✅ → tracked GitHub issue)** — retired on evidence, not fatigue. Its premise was that nothing converts a ✅ into shipped work; three PRs shipped in five days with no label, no issue, and no tracker. Verified: **no `kaizen` label exists in this repo.** Do not re-propose. +- **Two rows in the version-pin table** — `bedrock-agentcore` 1.22.0 (payments-only, touches no construct we use; "no upgrade pressure" for a second consecutive scan) and `@analogjs/platform`, which research itself notes **is not a dependency of this repo.** Both cost a row and inform nothing. + +## Risks Acknowledged But Not Acted On + +- **⭐ `cacheRead = input × 0.1` is hardcoded and reportedly false for a GA Bedrock model** — https://www.anthropic.com/claude-fable-and-mythos-5-1 — *what breaks*: a 4× overstatement of cache-read cost on any Fable row an admin adds, propagating into per-session cost, `wastedUsd`, the cost anatomy and the G0–G3 gates — in the **opposite direction** from last week's error. — recommendation: **Address now** via Proposal #1, but parameterize the ratio rather than encoding `0.025` until it reproduces from a second source. +- **⚠️⚠️ Two consecutive scans disagree about whether the AWS Price List API carries Claude 4.x/5.x rates** — AWS Price List API (`AmazonBedrock`, us-west-2) — *what breaks*: `CLAUDE.md:42` now asserts as fact that our rates *"come from the AWS Price List API, not the pricing page."* If that does not reproduce, the next person to re-derive rates cannot — which is exactly the failure that let `$2.50/MTok` survive three scans. The **ratio** work survives independently (1.100× Regional premium, confirmed on nine Grok 4.6 SKU pairs); the **absolute** Claude rates in `curated-models.ts` currently have no reproducible provenance. — recommendation: **Address now** — the re-run is 10 minutes and it is step (3) of Proposal #1. +- **⚠️ A routine `strands-agents` bump is now a prompt-cache change** — https://github.com/strands-agents/sdk-python/pull/3681 — *what breaks*: a duplicated or relocated system cachePoint, i.e. a full prefix re-write at 1.25× base input on every session, shipped by a bump that looks routine and guided by a comment that asserts the opposite. — recommendation: **Address the comment now** (Proposal #3); defer the bump (Proposal #7). +- **⚠️ Externally hosted MCP servers can re-write our cacheable prefix with no deploy of ours, and nothing names it** — measured at 17 schema/annotation-only changes across 248 servers in 27 hours. `toolConfigHash` detects it; nothing attributes it. — recommendation: **Address now** via Proposal #2. +- **⚠️ FastMCP 4.0 is breaking for the MCP servers this stack consumes** — https://github.com/jlowin/fastmcp/releases — *what breaks*: nothing in this repo's build, which is the problem. Those servers are separate repos, the snake_case field rename **warns rather than errors**, and the drift is invisible from this side for weeks. — recommendation: **Watch until 2026-09-18**, inside Proposal #9's spike. +- **`bedrock-agentcore` #646 — a document-bearing tool result 413s `CreateEvent` and kills the turn** — https://github.com/aws/bedrock-agentcore-sdk-python/issues/646 — *what breaks*: attachment conversations are 11% of sessions and **31% of prod spend**; the ceiling is server-side and the failure is a hard turn kill. Was **Watch until 2026-09-11**, paired with the context-overflow hardening item — which did not ship and is not on this board. — recommendation: **hold the watch date**, and note that the pairing it was deferred against no longer exists. +- **Prod still carries the 10% rate-tier understatement** — the runbook shipped in #917; the correction has not been applied to prod's managed-model rows. — recommendation: **Address now** — it is a documented runbook against a read-only environment, so it needs a human with write access, not a decision. +- **The #629 consequence is unowned** — dropped end-of-invocation spans mean our cost telemetry systematically under-reports **the last turn of every session**. Two reviews said "stop tracking, start guarding"; no queue entry owns it and this week's scan did not mention it. — recommendation: **Accept explicitly, or queue a guard.** Silence is the one option that has already been tried. + +## What Shipped This Week + +*(7-day window — **31 PRs** merged into `develop`, **49** non-merge commits, **two releases**: 1.16.0 on 08-28 and 1.17.0 on 09-02. Zero reverts. One CI failure.)* + +- **#914 — the cache-write premium and Global/Regional rate tier corrected** — *last review's Proposal #1, shipped in five days. Half-done, and Proposal #1 above finishes it.* +- **#915 — an omitted `supported_param` now means unsupported, not pass-through** — *last review's Proposal #2. Closes a verified live 400 by inverting one default, ahead of Opus 5 landing in the catalog.* +- **#916 + #921 — mid-turn steering** — *last review's Proposal #5, delivered past its scope: a lease-row inbox, a `POST /sessions/{id}/steer` endpoint, injection at the next tool boundary, a `steering_applied` SSE event documented in `CLAUDE.md`, and a test locking cachePoint placement around the injection. The follow-up is queued, not eaten.* +- **#919 + #920 + #922 — artifact sharing, three PRs** — *data model and share-scoped mint, owner UI, recipient UI. A net-new product surface delivered end-to-end in one week.* +- **#910 — production observability baseline** — *77 alarms on one routed SNS topic with a CMK, an `AlarmFactory` that a source-level test makes mandatory, a platform-health dashboard, and 18 tunables. Before this: 13 alarms, **none routed**, two of them watching metrics that exist in no namespace and therefore reading as healthy forever. Also cut X-Ray from 100% sampling.* +- **#905 + #907 + #909 — the chat path made legible** — *transient Bedrock faults retried and surfaced (`model_retry`), a stall indicator after 30s/90s of silence, attachment re-send after a turn dies before the model reads its documents, and a completed answer no longer telling the model it was cut short.* +- **#906 — a session id could be forked across two users** — *`_get_session_by_gsi` returned `None` for both "no such session" and "someone else's session".* +- **#898–#908 — managed KB migration, eleven PRs** — *provisioning order, wait-for-ACTIVE, ingestion polling, deletion propagation, legacy-pipeline standdown, filtered-retrievability verification. Every one found by **running it**, not by review.* +- **#902 + #903 + #904 — security fixes** — *stored XSS in skill resources, BFF OAuth state unbound from the browser (CSRF/session fixation), admin skills routes unscoped.* +- **#924 + #925 — 87 security alerts cleared** — *47 Dependabot across 16 packages (`cryptography` → 50.0.1, `aiohttp` → 3.14.3, Angular → 21.2.19 in lockstep) and 40 CodeQL, whose 11 highs shared one root cause: `nightly.yml` resolving track branches by slicing a user-supplied string into nine `ref:` checkouts.* +- **CI**: **one** failure in the window — 2026-09-01 · develop · Backend Deploy · `Failed to get-agent-runtime` on the inference-api step, concurrent with a Platform Stack run on the same push (Proposal #4). Everything since is green, including the 2026-09-04 nightly. + +## Take + +**The forum converted, and the honest read is that it converted on engineering items and on nothing else.** Three of ten proposals shipped in five days, one of them past its scope — after two cycles of zero, that settles the question the last two reviews kept asking, and it retires their answer: no tracking layer, no `kaizen` label, no issue-per-✅ was needed. What shipped was what Phil wanted to build. Every process proposal in the same document converted at zero for the fourth cycle running, and `kaizen-review-prep/SKILL.md` has not been edited since May while four consecutive reviews proposed editing it. The lesson is not "try harder on process" — it is that this forum should propose **less** process and should stop describing a POC feedback loop that has produced nothing in five cycles while a different, working channel goes undocumented. + +**The one genuinely new failure this week is that a stated fact didn't reproduce — three times, in the doc that exists to establish facts.** "Zero CI failures" concealed a red develop deploy; the Angular pin was wrong against the very checkout being scanned; and last week's Price List provenance is now written into `CLAUDE.md` as fact while a second scan can't find the data. None of these are hard to prevent: the version-pin table already quotes its method and is the most reliable section in the doc. That's Proposal #5(b), it is two lines, and Proposal #4 exists only because this review re-ran a query research had already answered. + +**If Phil ships three: #1** (finish the correction — half-done is worse than not-started, because the contract now carries a provenance claim nobody can reproduce), **#3** (ten minutes to disarm a silent prefix re-write in the most cost-sensitive file we own), and **#2** (the first cost item that *subtracts* a human procedure, and the only way we will ever attribute an external MCP server re-writing our prefix). **#5** should ride this review's own PR if it's going to happen at all — that is the only route by which a process item has ever landed here. Two things are overdue for a keystroke, not a deliberation: `duration_ms` (ninth cycle, three declines) and `oauth_required` (sixth surfacing, fifteen weeks). And **#10** needs the terminal answer this document asked for last week: three months of re-ranking two items every Friday isn't ranking, it's storage, and the issue tracker is better at it. + +--- + +## Review Protocol (for Phil) + +1. Read Friction (2 min). +2. Scan Proposals — mark ✅ Ship / ❌ Decline / ⏸ Defer on each (3-5 min). +3. Scan Retirement Candidates — same marks (1-2 min). +4. Resolve Carried Over items — three of them need a keystroke, not a decision (1-2 min). +5. Resolve the Risks block. +6. Pick 1-3 to ship this week. Decline or defer the rest with a reason. + +Target: 10-15 minutes. + +## Post-review (for Phil — separate PRs) + +- ✅ Ship items → individual feature PRs over the week. **Exception**: Proposal #5 is a two-file skill edit and should ride *this* PR if marked ✅, because a process item deferred to its own PR has failed four times. +- ❌ Decline items → appended to `docs/kaizen/decisions.md` with the reason, so future research doesn't re-propose. Queued this pass and awaiting the keystroke: `duration_ms`, `oauth_required`, and (per Proposal #10) docling #405 + Guardrails #480 as *kaizen* items only — the GitHub issues stay open as backlog. +- ⏸ Defer items → kept open in `review-queue.md` with a "revisit by" date; they resurface in the next review when due. + +This skill produces the agenda. Implementation never happens here. diff --git a/docs/one-pagers/model-lifecycle-audit.md b/docs/one-pagers/model-lifecycle-audit.md new file mode 100644 index 000000000..9b1fcc038 --- /dev/null +++ b/docs/one-pagers/model-lifecycle-audit.md @@ -0,0 +1,45 @@ +# Model lifecycle audit — Bedrock, not Anthropic + +**Run:** 2026-09-02 · us-west-2 · `aws bedrock list-foundation-models` +**Why:** kaizen review 2026-08-28, proposal #2 (part 2). Anthropic deprecated +`temperature` / `top_p` / `top_k` on Claude Opus 4.7 and later, which prompted +the question of what *else* about our model ids is on a clock we aren't reading. + +## The correction this audit exists to make + +**Read Bedrock's schedule, not Anthropic's.** Partner platforms set their own +dates; an Anthropic deprecation notice is evidence about the API, not about the +Bedrock model id we actually invoke. The two have diverged before and will again. + +## What Bedrock actually publishes + +A **status, not a date**: `modelLifecycle.status` is `ACTIVE` or `LEGACY`. There +is no retirement date in the API, so the registry cannot surface a countdown — +only "still current" vs "on the way out". `LEGACY` is the signal to migrate; +treat its appearance as the notice period, because that is all there is. + +```bash +aws bedrock list-foundation-models --region us-west-2 --by-provider anthropic \ + --query 'modelSummaries[].{id:modelId,status:modelLifecycle.status}' --output table +``` + +## Result — all four curated models are current + +| Curated key | Bedrock model id | Status | +|---|---|---| +| `claude-opus-4-7` | `anthropic.claude-opus-4-7` | ACTIVE | +| `claude-sonnet-5` | `anthropic.claude-sonnet-5` | ACTIVE | +| `claude-sonnet-4-6` | `anthropic.claude-sonnet-4-6` | ACTIVE | +| `claude-haiku-4-5` | `anthropic.claude-haiku-4-5-20251001-v1:0` | ACTIVE | + +`LEGACY` in us-west-2 as of this run: Sonnet 4 (`20250514`), Opus 4.1, and the +Claude 3 Haiku family. **We curate none of them**, so there is no migration +pending and no action falls out of this audit. + +## Standing note + +Newer ids (Opus 4.7/4.8/5, Sonnet 5, Fable 5.x) carry **no dated suffix** — +`anthropic.claude-opus-4-7`, not `...-20251101-v1:0`. Version pinning by date is +no longer available on those, so "which snapshot am I on" stops being answerable +from the id. Re-run this query when adding a model, and again if a turn starts +failing in a way that looks like a model changed underneath us. diff --git a/docs/one-pagers/prod-model-rate-tier-correction.md b/docs/one-pagers/prod-model-rate-tier-correction.md new file mode 100644 index 000000000..2dc9eba51 --- /dev/null +++ b/docs/one-pagers/prod-model-rate-tier-correction.md @@ -0,0 +1,214 @@ +# Prod runbook — correct the managed-model rate tier + +**Applies to:** prod (`beta.boisestate.ai`, acct 897729136999, us-west-2, prefix `boisestateai-v2`) +**Origin:** kaizen 2026-08-28 #1 → PR #914. Dev was corrected 2026-09-03; prod was not. +**Expected duration:** ~10 minutes, all through the admin API. + +## What is wrong + +Every curated Claude template declared **Global**-tier prices while its `modelId` named a +**`us.*` Regional (CRIS)** inference profile, which prices ~10% higher. Managed-model rows are +seeded from those templates, so prod's rows are almost certainly a flat ~10% low on all four +rate fields — including whichever model is `isDefault`. + +PR #914 fixed the template. It did **not** touch existing rows, which is why this runbook exists. + +Confirmed in dev: **all four** Claude rows were wrong, including a hand-created +`us.anthropic.claude-sonnet-5` row that did not match the curated `global.anthropic.claude-sonnet-5` +template at all. Do not assume prod's row set matches dev's — discover before writing. + +## Two things to know before you start + +**1. The Regional premium is not a universal 1.1×.** It holds for Haiku 4.5, Sonnet 4.5/4.6, +Sonnet 5 and the Opus 4.5–5 family, but **Claude Sonnet 4 prices identically on both tiers** +(3.00/15.00 either way). Never apply a blanket multiplier — read the per-model tier row below. + +**2. Do NOT touch historical cost rows.** `create_pricing_snapshot` stamps every model call with +the rates in force at the time, so past `C#` rows are internally consistent and comparable. +Rewriting them would corrupt the time series the whole cost-effectiveness arc is measured on. +This is a fix-forward change: new calls price correctly, old calls stay as billed. + +## Verified rates — us-west-2, AWS Price List API, published 2026-09-01 + +Per 1M tokens. Cache write is **1.25 × input**, cache read is **0.1 × input** on every model +(1-hour-TTL write, which we do not use, is 2×). Pick the row matching your `modelId` prefix: +`global.*` → Global, anything else → Regional. + +| Model | Tier | input | output | cache write | cache read | +|---|---|---|---|---|---| +| Haiku 4.5 | **Regional** | 1.10 | 5.50 | 1.375 | 0.11 | +| Haiku 4.5 | Global | 1.00 | 5.00 | 1.25 | 0.10 | +| Sonnet 4 | either | 3.00 | 15.00 | 3.75 | 0.30 | +| Sonnet 4.5 | **Regional** | 3.30 | 16.50 | 4.125 | 0.33 | +| Sonnet 4.5 | Global | 3.00 | 15.00 | 3.75 | 0.30 | +| Sonnet 4.6 | **Regional** | 3.30 | 16.50 | 4.125 | 0.33 | +| Sonnet 4.6 | Global | 3.00 | 15.00 | 3.75 | 0.30 | +| Sonnet 5 | **Regional** | 2.20 | 11.00 | 2.75 | 0.22 | +| Sonnet 5 | Global | 2.00 | 10.00 | 2.50 | 0.20 | +| Opus 4.5 / 4.6 / 4.7 / 4.8 / 5 | **Regional** | 5.50 | 27.50 | 6.875 | 0.55 | +| Opus 4.5 / 4.6 / 4.7 / 4.8 / 5 | Global | 5.00 | 25.00 | 6.25 | 0.50 | +| Fable 5 / 5.1, Mythos 5.1 | **Regional** | 11.00 | 55.00 | 13.75 | 1.10 | +| Fable 5 / 5.1, Mythos 5.1 | Global | 10.00 | 50.00 | 12.50 | 1.00 | + +Legacy families (Claude 3.x, Opus 4/4.1, Instant) are **deliberately omitted** — re-verify those +from the Price List API if prod carries one. Re-verify anything with: + +```bash +aws pricing get-products --region us-east-1 \ + --service-code AmazonBedrockFoundationModels \ + --filters Type=TERM_MATCH,Field=regionCode,Value=us-west-2 +``` + +Newer ids publish `*_tokens_standard` usagetypes; older ones publish `*TokenCount`. A query +written for one shape silently returns nothing for the other. + +## Procedure + +Run steps 1–3 in the **browser devtools console on `https://beta.boisestate.ai`**, signed in as a +models admin. The admin API is the right surface here: it validates the payload, updates +`updatedAt`, and applies a partial update (`exclude_none=True`) so untouched fields — role grants, +`isDefault`, `enabled`, `supportedParams` — are preserved. Writing to DynamoDB directly bypasses +all of that; don't. + +### Step 1 — Discover (read-only, writes nothing) + +```js +const RATES = { + 'haiku-4-5': { regional: [1.1, 5.5], global: [1.0, 5.0] }, + 'sonnet-4-6': { regional: [3.3, 16.5], global: [3.0, 15.0] }, + 'sonnet-4-5': { regional: [3.3, 16.5], global: [3.0, 15.0] }, + 'sonnet-4': { regional: [3.0, 15.0], global: [3.0, 15.0] }, + 'sonnet-5': { regional: [2.2, 11.0], global: [2.0, 10.0] }, + 'opus-4-5': { regional: [5.5, 27.5], global: [5.0, 25.0] }, + 'opus-4-6': { regional: [5.5, 27.5], global: [5.0, 25.0] }, + 'opus-4-7': { regional: [5.5, 27.5], global: [5.0, 25.0] }, + 'opus-4-8': { regional: [5.5, 27.5], global: [5.0, 25.0] }, + 'opus-5': { regional: [5.5, 27.5], global: [5.0, 25.0] }, + 'fable-5': { regional: [11.0, 55.0], global: [10.0, 50.0] }, + 'mythos-5': { regional: [11.0, 55.0], global: [10.0, 50.0] }, +}; +const round = n => Math.round(n * 1e6) / 1e6; +// Longest key first so `sonnet-4-6` never matches the `sonnet-4` entry. +const familyOf = id => Object.keys(RATES).sort((a,b) => b.length - a.length).find(k => id.includes(k)); + +const res = await fetch('/api/admin/managed-models', { credentials: 'include' }); +const data = await res.json(); +const rows = Array.isArray(data) ? data : (data.models ?? data.items ?? []); + +window.__planned = []; +console.table(rows.map(m => { + const id = m.modelId ?? ''; + const fam = familyOf(id); + const tier = id.startsWith('global.') ? 'global' : 'regional'; + const cur = [m.inputPricePerMillionTokens, m.outputPricePerMillionTokens, + m.cacheWritePricePerMillionTokens, m.cacheReadPricePerMillionTokens]; + if (!fam) { + return { modelId: id, tier, verdict: m.provider === 'bedrock' ? 'UNKNOWN — check by hand' : 'skip (non-Bedrock)', + current: cur.join(' / '), expected: '', emptySpec: '' }; + } + const [i, o] = RATES[fam][tier]; + const want = m.supportsCaching ? [i, o, round(i*1.25), round(i*0.1)] : [i, o, cur[2], cur[3]]; + const ok = JSON.stringify(cur) === JSON.stringify(want); + if (!ok) window.__planned.push({ + id: m.id, modelId: id, + body: { + inputPricePerMillionTokens: want[0], outputPricePerMillionTokens: want[1], + ...(m.supportsCaching ? { cacheWritePricePerMillionTokens: want[2], + cacheReadPricePerMillionTokens: want[3] } : {}), + }, + }); + return { + modelId: id, tier, default: !!m.isDefault, + current: cur.join(' / '), expected: want.join(' / '), + verdict: ok ? 'ok' : 'NEEDS FIX', + emptySpec: Object.keys(m.supportedParams?.params ?? {}).length === 0 ? 'EMPTY SPEC — see step 4' : '', + }; +})); +console.log(`${window.__planned.length} row(s) queued for update`); +``` + +**Read the output before continuing.** Every row should be `ok` or `NEEDS FIX`. Any +`UNKNOWN — check by hand` is a Bedrock model not in the table above — price it from the Price List +API and handle it separately; the apply step deliberately skips it rather than guessing. + +### Step 2 — Apply + +```js +const csrf = document.cookie.split('; ').find(c => c.startsWith('__Host-bff_csrf=')) + ?.split('=').slice(1).join('='); +if (!csrf) throw new Error('No CSRF cookie — are you signed in?'); + +for (const p of window.__planned) { + const r = await fetch('/api/admin/managed-models/' + p.id, { + method: 'PUT', credentials: 'include', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf }, + body: JSON.stringify(p.body), + }); + console.log(p.modelId, r.status, r.ok ? 'updated' : await r.text()); +} +``` + +Note the endpoint keys on the record's **`id` UUID**, not `modelId` — passing `modelId` 404s. +A missing `X-CSRF-Token` gives a 403, not a silent failure. + +### Step 3 — Verify + +Re-run **Step 1**. Every Bedrock row should read `ok`. Then spot-check the invariant: + +```js +const d = await (await fetch('/api/admin/managed-models', {credentials:'include'})).json(); +(Array.isArray(d) ? d : (d.models ?? d.items ?? [])) + .filter(m => m.supportsCaching) + .forEach(m => console.log(m.modelId, + 'cw=1.25x:', Math.abs(m.cacheWritePricePerMillionTokens - m.inputPricePerMillionTokens*1.25) < 1e-9, + 'cr=0.1x:', Math.abs(m.cacheReadPricePerMillionTokens - m.inputPricePerMillionTokens*0.1) < 1e-9)); +``` + +Send one chat turn afterwards and confirm the per-session cost still renders — that exercises +`get_model_pricing` → `create_pricing_snapshot` against the new values. + +### Step 4 — Empty `supportedParams` (separate decision, don't bundle) + +Any row Step 1 flagged `EMPTY SPEC` is **not protected by PR #915**. That change makes an omitted +param mean *unsupported*, but only for rows that declare a spec at all — a row declaring nothing +has made no claim, so it keeps the old permissive pass-through. On a model that deprecates +`temperature` / `top_p` / `top_k` (Opus 4.7 and later, Sonnet 5) a stale client override can still +reach Bedrock and hard-400 the turn mid-stream. + +Fix is data, not code. Seed the spec, scoping `max_tokens.max` to that row's **actual** +`maxOutputTokens` rather than the curated template's: + +```js +const csrf = document.cookie.split('; ').find(c => c.startsWith('__Host-bff_csrf=')) + ?.split('=').slice(1).join('='); +const RECORD_UUID = ''; +const MAX_OUT = 4096; // <- read this off the row; do not guess + +await fetch('/api/admin/managed-models/' + RECORD_UUID, { + method: 'PUT', credentials: 'include', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf }, + body: JSON.stringify({ supportedParams: { params: { + max_tokens: { supported: true, min: 1, max: MAX_OUT, default: MAX_OUT }, + effort: { supported: true, allowed: ['low','medium','high','xhigh'], default: 'medium' }, + }}}), +}); +``` + +This narrows what users can send, so send one turn on that model afterwards to confirm it still +streams. Done on dev for `us.anthropic.claude-sonnet-5` with no issues. + +## Rollback + +Step 1 prints the `current` values before anything is written — **screenshot or copy that table +first**. To revert, PUT the original four numbers back to the same record UUID. Nothing else is +touched, and no historical data is modified at any point, so there is nothing else to undo. + +## After + +- Cost figures for prod rise ~10% for affected models. That is the *correction*, not a regression: + we were under-reporting. Expect `wastedUsd`, `partialMissUsd`, per-session cost and the admin + cost anatomy to step up at the cutover, and annotate the date so the discontinuity is legible + rather than looking like a spend spike. +- Worth a follow-up, separately: `global.*` profiles price ~9% **below** `us.*` CRIS on every field + and we already run one, so the mechanism is proven — but it needs a data-residency review nobody + has done. Kaizen #1 explicitly said not to bundle it with the rate fix. diff --git a/docs/prompts/phase-2-spinner-component.md b/docs/prompts/phase-2-spinner-component.md new file mode 100644 index 000000000..87b8cbc7e --- /dev/null +++ b/docs/prompts/phase-2-spinner-component.md @@ -0,0 +1,185 @@ +# Task: extract a shared spinner component and migrate every inline loading spinner to it + +## Context + +This is Phase 2 of a 4-phase Tailwind color-token migration. Phase 1 (blue accent +pass — interactive elements to `primary-*`, informational notices to +`state-info-*`) is complete across the entire `frontend/ai.client/src/app` tree. +Throughout Phase 1, every inline `animate-spin` loading spinner was deliberately +left untouched and deferred to this phase, so there is now a real backlog: **115 +`animate-spin` occurrences across 72 files**, almost all of them the same ring +spinner hand-copied with small variations (size, border color, dark-mode +override). + +Read first: +- `.kiro/steering/tailwind-colors.md` — the color token rules (authoritative). +- `.kiro/steering/tailwind-ui.md` — general Tailwind v4 conventions used in this app. +- `frontend/ai.client/src/branding/README.md` section 6 — contributor-facing color rules. +- `frontend/ai.client/src/styles/tokens/state.css` — status tokens. +- `frontend/ai.client/src/app/components/pulsating-loader.component.ts` — an + existing example of a small standalone loading component in this codebase, for + style/selector/file-layout conventions (not a spinner, but same category of + component). + +All paths below are relative to `frontend/ai.client/`. + +## The one hard rule + +Never mix a zero-pixel refactor with a visible recolor in the same chunk. Each +chunk must be reviewable as either "this must look identical" or "this should +look different, and here's how." State which it is when you hand it over. + +This phase is mostly a zero-pixel refactor (same ring spinner, same sizes, same +colors, just deduplicated into one component) — but a handful of call sites +currently use raw `blue-*`/`red-*`/`purple-*`/`indigo-*`/`orange-*` border +colors that were never resolved to tokens because Phase 1 skipped all spinners +categorically. Migrating those is a visible recolor (if the raw color and its +target token don't resolve to the same pixels) and must be called out separately +from the mechanical extraction. Don't silently fold a recolor into what's +supposed to be a no-op refactor. + +## Step 1 — inventory before building anything + +Run a fresh scan; do not trust any counts from prior sessions: + +```powershell +Get-ChildItem -Recurse -Path src/app -Include *.ts,*.html | ` + Where-Object { $_.Name -notlike "*.spec.ts" } | ` + Select-String -Pattern "animate-spin" +``` + +Classify every hit into one of these shapes: + +1. **Ring spinner** — `
` or `` with `animate-spin rounded-full border-* + border-t-*` (by far the most common; this is what the shared component + should replace). +2. **SVG spinner** — `` with a `` + + `` (the classic Tailwind docs spinner markup, used in + a few button-loading states like `tool-approval-prompt`, + `oauth-consent-prompt`, `citation-display`). +3. **Icon spinner** — `` + used for refresh buttons. These rotate an icon in place rather than showing a + ring, and arguably aren't the same UI affordance — decide whether they belong + in scope (see Step 2). +4. **Non-spinner `animate-spin` usage** — skim for anything that isn't a loading + indicator at all before assuming every hit is in scope. + +Note the distinct border-color combinations in play so you know what the +component's color variants need to cover, e.g.: +- `border-gray-300 border-t-blue-600 dark:border-gray-600 dark:border-t-blue-400` + (by far the most common — this is the "default/neutral" spinner used on + full-page and section loading states) +- `border-white/30 border-t-white` (on solid-color buttons, e.g. red/primary + submit buttons, where the spinner sits on a colored fill) +- `border-t-primary-accessible` / `border-t-primary-accessible-dark` (already + migrated in a few places during Phase 1 admin work — treat these as the + target shape for the "brand" variant) +- One-off outliers: `border-t-red-600`, `border-t-purple-600`, `border-t-indigo-500`, + `border-t-indigo-600`, `border-t-orange-500` — check each of these + individually, they may be intentional status-colored spinners (e.g. a + danger-flavored retry spinner) rather than copy-paste drift. + +Report the inventory (counts per shape, per color variant, list of outlier +files) before writing the component. Don't start building until you've shown +this breakdown — the actual variant API depends on what's really out there. + +## Step 2 — design the component + +Once you know the real shape of the data, propose (and get a nod on) a small +API. A reasonable starting point, subject to what Step 1 finds: + +```html + +``` + +- `size` maps to the existing `size-*` values in use (likely 3–4 sizes cover + everything: `sm`≈4, `md`≈8, `lg`≈12, plus whatever oddballs like `size-3.5` + show up — round to the nearest standard size rather than preserving every + exact value, and call out any place where that changes the visual size even + slightly). +- `variant` maps to the border-color combinations found in Step 1. `neutral` is + the gray/blue-accent default (recolor to `primary-accessible` / + `primary-accessible-dark` — see Step 3), `on-solid` is the white-on-white/30 + version for buttons with a solid color fill, `brand` is the + `primary-accessible` version already used in a few places. +- Decide whether the icon-rotation spinners (`ng-icon` refresh buttons) belong + in this component or stay as-is — they're a different visual pattern (a + rotating icon vs. a ring), not obviously the same component. Default to + leaving them alone unless there's a clean way to unify them; ask if unsure. +- `role="status"` and an `aria-label` (or a slotted/default sr-only label) + should be baked into the component so every call site gets an accessible + loading announcement for free — check which existing call sites already pass + `aria-label="Loading..."` / `role="status"` and which are missing it + entirely (several are, e.g. plain `
` with no + ARIA at all). Bringing every site up to the same accessibility baseline is a + positive side effect of this refactor, not scope creep. +- Standalone Angular component, `ChangeDetectionStrategy.OnPush`, selector + `app-spinner`, colocated in `src/app/components/spinner/` (matching the + existing flat/small-component convention in `src/app/components/`, e.g. + `pulsating-loader.component.ts`). + +## Step 3 — the color decision + +The dominant existing pattern is `border-t-blue-600 dark:border-t-blue-400` on a +`border-gray-300 dark:border-gray-600` track. Per the color rules, raw +`blue-*` is banned. Decide once, for the whole component, whether the default +spinner accent is `primary-accessible`/`primary-accessible-dark` (brand — my +recommendation, since these are almost universally generic "loading" states +with no informational meaning) or `state-info-*` (status). Do not decide this +per call site — that defeats the point of extracting a shared component. A few +call sites already use `border-t-primary-accessible`, which supports the brand +reading. Flag this decision explicitly when you hand off the chunk, since it's +the one part of this phase that changes a pixel. + +## Step 4 — migrate call sites + +- Break the 72 files into logical chunks (by top-level `app/` directory mirrors + the Phase 1 grouping: `session/`, `components/`, `settings/`, `admin/`, + `agents/`, `fine-tuning/`, `assistants/`, `memory-spaces/`, `memory/`, + `schedules/`, `knowledge-base/`, `my-skills/`, `auth/`, `files/`, + `manage-sessions/`). Work incrementally and wait for an "okay" between chunks, + same as Phase 1, unless told otherwise. +- For each call site: import `SpinnerComponent`, replace the inline markup with + ``, remove now-unused imports if a + file no longer needs `NgIcon`/whatever for anything else. +- Preserve `[class.animate-spin]`-style conditional spinners (Step 1, shape 3) + as-is unless Step 2 decided to fold them in. +- Watch for spinners inside `@if (loading()) { ... }` blocks where the + surrounding wrapper div (padding, centering, `role="status"` on the wrapper + instead of the spinner) may become redundant once the component carries its + own `role="status"` — don't leave duplicate ARIA roles on parent and child. + +## Step 5 — outliers + +Handle `border-t-red-600`, `border-t-purple-600`, `border-t-indigo-*`, +`border-t-orange-500` (and anything else Step 1 turns up) as individual +decisions, not folded into the default variant. Some may be intentional +(e.g. a spinner inside a danger-colored retry button should probably stay +red-ish, which would map to `state-danger-*` not the component's default +brand accent) — check the surrounding button/context for each before deciding. +If the component's `variant` prop doesn't cleanly cover one of these, it's fine +to leave that one call site as a documented exception rather than distorting +the component API for a single outlier. + +## Verification + +- Run `npm run build` after each chunk (use `control_pwsh_process` background + start + `get_process_output` polling — builds take ~40-90s and direct + `execute_pwsh` will time out). +- After all chunks: re-scan for `animate-spin` outside + `components/spinner/spinner.component.ts` itself and confirm every remaining + hit is a deliberately-excluded shape from Step 2 (icon-rotation spinners) or + a documented Step 5 exception — not a miss. +- Check `.spec.ts` files for any test that asserts on the old inline spinner + markup or class list (Phase 1 hit this in `quota-card.component.spec.ts` and + `status-badge.component.spec.ts`) and update assertions to match the new + component/classes. +- Do not add new unit tests or visual regression snapshots for the spinner + component itself unless asked — out of scope, same as Phase 1. + +## Before you start + +Ask any clarifying questions after you've read the material above and done the +Step 1 inventory — in particular, confirm the `variant` naming/count and the +Step 3 color decision before touching any call site, since both are hard to +walk back once 70 files depend on them. diff --git a/docs/prompts/phase-3-outlier-colors-and-icon-spinners.md b/docs/prompts/phase-3-outlier-colors-and-icon-spinners.md new file mode 100644 index 000000000..18fbfe143 --- /dev/null +++ b/docs/prompts/phase-3-outlier-colors-and-icon-spinners.md @@ -0,0 +1,178 @@ +# Task: resolve the Phase 2 outliers and decide the fate of icon-rotation spinners + +## Context + +Phase 2 (spinner component extraction) is complete: 99 of the 115 original +`animate-spin` ring/SVG spinners were replaced with `` across +~65 files, recoloring the dominant `border-t-blue-600` pattern to +`primary-accessible`/`primary-accessible-dark` per the Phase 2 color decision. +That work is done and merged; do not redo it. + +Phase 2 deliberately left two categories untouched, and this phase closes +both out: + +1. **Four raw-color outliers** — spinners using `purple-600`, `indigo-600`, + `indigo-500`, and `orange-500` instead of a token. Phase 2 flagged these + individually rather than folding them into the new component's `brand` + variant, per the rule that a recolor must never ride along inside a + zero-pixel refactor. +2. **Icon-rotation spinners** — `` + on refresh/sync/discover buttons. Phase 2 defaulted to leaving these alone + since they're a different visual pattern (a rotating icon vs. a ring), but + flagged the decision as open. + +Read first: +- `.kiro/steering/tailwind-colors.md` — the color token rules (authoritative). +- `frontend/ai.client/src/app/components/spinner/spinner.component.ts` — the + component built in Phase 2 (`size`: sm/md/lg/xl, `variant`: brand/on-solid/danger). +- `frontend/ai.client/src/branding/README.md` section 6 — contributor-facing + color rules. + +All paths below are relative to `frontend/ai.client/`. + +## The one hard rule + +Same as Phase 2: never mix a zero-pixel refactor with a visible recolor in +the same chunk. Every outlier decision below is a visible recolor (or an +explicit "stays weird" decision) — call each one out individually rather than +batching them under one heading. + +## Part 1 — the four outliers + +Re-verify these are still exactly where Phase 2 left them (a fresh scan, not +trusted from memory) before touching anything: + +```powershell +Get-ChildItem -Recurse -Path src/app -Include *.ts,*.html | ` + Where-Object { $_.Name -notlike "*.spec.ts" } | ` + Select-String -Pattern "border-t-purple|border-t-indigo|border-t-orange" +``` + +For each hit, decide **stays colored** or **becomes `brand`** (i.e. gets +recolored to `primary-accessible` and switched to ``), +using the context Phase 2 already gathered: + +1. **`admin/connectors/pages/connector-form.page.ts`** — `border-t-purple-600` + on a "Loading roles..." spinner. Phase 2's read: likely copy-paste drift, + no other purple styling on that section of the page (there's an unrelated + purple checkbox accent nearby, but it's a different control). Recommend + **fold into `brand`** unless you find something Phase 2 missed. + +2. **`admin/tools/components/tool-role-dialog.component.ts`** — `border-t-indigo-600` + inside a dialog whose checkboxes, selected-row highlight, and Save button + are all indigo. Recommend **leave colored**, but not necessarily via the + component's `variant` prop — check whether `state-info-*` or a bespoke + indigo utility (if this app's dialog boilerplate is meant to be + consistently indigo across several admin dialogs — see the next bullet) + is the more honest fix than hardcoding `indigo-600` again. + +3. **`manage-sessions/manage-shares-dialog/manage-shares-dialog.component.ts`** — + `border-t-indigo-500` on a plain "Loading" spinner, in a dialog whose only + other indigo is the close button's `focus:outline-indigo-600`. Phase 2 + noted this matches the same pattern as #2 and speculated it might be a + **shared dialog template default** rather than two independent accidents. + **Before deciding either of #2 or #3**, grep for `outline-indigo` and + `border-t-indigo` across `src/app/**/*.ts` and `*.html` to see how many + dialogs share this exact boilerplate (close button focus ring + loading + spinner both indigo). If it's a real pattern across 3+ dialogs, that's a + different fix than a one-off recolor: propose it as a shared dialog + convention (e.g. document it in a steering file, or note that dialogs + without explicit branding default to `state-info-*`) rather than silently + converting some but not all instances to `brand`. + +4. **`settings/pages/api-keys/api-keys.page.ts`** — `border-t-orange-500` on + a "Loading models" spinner, inside a page section where the surrounding + CTA buttons, icons, and borders are all orange (`bg-orange-500`, + `text-orange-600`, `border-orange-300`, etc. — this looks like a + page-local accent, not brand and not a state color). This is the clearest + "leave it" case, but note that `orange-*` is currently a raw Tailwind + palette color, which the color rules forbid in application code + (`tailwind-colors.md`: "Never use Tailwind's built-in color palettes"). + Decide: does this whole page's orange theme deserve its own category + token (e.g. a fourth brand role, or a category token if it's meant to + distinguish "this is the API/developer section" the way `vendor-*` or + `filetype-*` distinguish categories elsewhere), or is a hardcoded orange + accent tolerated here as a pre-existing exception? Don't fix only the + spinner's `orange-500` while leaving every other `orange-*` utility on the + page untouched — that's a partial, inconsistent recolor. Either the whole + page's orange theme gets a token, or document why it's exempt and leave + all of it, spinner included. + +For whichever outliers you decide **stay colored without a token**, add a +one-line comment above the markup (``) +so a future contributor doesn't "fix" it by accident. Phase 2 added exactly +this comment on the purple site as a placeholder; replace it with your actual +decision rather than leaving it as a TODO. + +## Part 2 — icon-rotation spinners + +Ten `[class.animate-spin]`/static-`animate-spin` `ng-icon` sites were left +alone in Phase 2. Full list (re-verify with the pattern below, not from this +list, in case something changed): + +```powershell +Get-ChildItem -Recurse -Path src/app -Include *.ts,*.html | ` + Where-Object { $_.Name -notlike "*.spec.ts" } | ` + Select-String -Pattern "animate-spin" | ` + Select-String -Pattern "ng-icon|heroArrowPath" +``` + +Known sites (as of Phase 2 handoff): `admin/auth-providers/pages/provider-form.page.ts` +(discover button), `admin/auth-providers/pages/provider-list.page.ts` (test +connection), `admin/roles/pages/role-list.page.ts` (sync role), `components/file-card/file-card.component.ts`, +`components/model-settings/model-settings.html` (discover MCP servers), +`components/sidenav/components/session-list/session-list.html`, `files/file-browser.page.ts` +(refresh), `manage-sessions/manage-sessions.page.ts` (refresh), `session/components/export-dialog/export-dialog.component.ts` +(busy indicator), `session/components/message-list/components/artifact/artifact-panel.component.ts` +(downloading). + +This is a genuine design decision, not a mechanical migration — resolve it +before writing any code: + +- **Are these the same affordance as the ring spinner, just drawn + differently?** A rotating `heroArrowPath` icon reads as "refresh/retry in + progress" (the icon itself implies the action), whereas the ring spinner + reads as generic "loading, please wait." They may be intentionally + different vocabulary, not two implementations of one concept. +- If you conclude they're the same concept and should unify: `` + would need a new variant or prop that renders a rotating icon instead of a + ring (a `shape="icon"` prop taking an icon name, or a completely separate + `app-icon-spinner` component). Sketch the API and confirm before touching + call sites, same as Phase 2's Step 2. +- If you conclude they should stay separate (this is the Phase 2 default and + probably still correct): do nothing to these ten sites, but write down the + reasoning somewhere durable (a code comment on `SpinnerComponent` itself, + or a line in this phase's handoff) so Phase 4 or a future contributor + doesn't reopen the question from scratch. + +Don't let "should these unify" turn into scope creep on the four buttons that +use icon-rotation for something other than a refresh affordance (check each +call site's context, not just its markup, before grouping it with the +others). + +## Verification + +- Run `npm run build` after Part 1 and again after Part 2 (use + `control_pwsh_process` background start + `get_process_output` polling, + same as Phase 2 — builds take ~40-90s). +- Re-scan for `border-t-purple|border-t-indigo|border-t-orange` after Part 1 + and confirm every remaining hit has an explicit "intentional" comment, not + a silent leftover. +- Re-scan for `animate-spin` after Part 2. If icon-rotation spinners were + folded into a new shape, confirm the count of raw `ng-icon` + `animate-spin` + combinations drops to zero (outside the four call sites you decided don't + belong in the group, if any). If left alone, no count should change. +- Check `.spec.ts` files touched by either part for assertions on the old + markup/classes (Phase 2 found none outstanding, but re-check anything you + edit in this phase). +- Do not add new unit tests for the spinner component or any icon-spinner + variant unless asked — out of scope, same as Phase 1 and Phase 2. + +## Before you start + +Confirm the Part 1 decisions (especially #2/#3's "shared dialog pattern" +question, since it could turn into a small refactor of its own rather than a +per-site fix) and the Part 2 direction (unify vs. leave separate) before +writing any code. Both are visible-recolor or API-shape decisions that are +harder to walk back once applied across multiple files, same caution as +Phase 2. diff --git a/docs/prompts/phase-3-state-identity-hygiene.md b/docs/prompts/phase-3-state-identity-hygiene.md new file mode 100644 index 000000000..7b8bc1354 --- /dev/null +++ b/docs/prompts/phase-3-state-identity-hygiene.md @@ -0,0 +1,226 @@ +# Task: state and identity color hygiene sweep + +## Context + +This is Phase 3 of a 4-phase Tailwind color-token migration. Phase 1 (blue +accent pass) and Phase 2 (shared spinner component) are complete. Unlike +Phase 1, this phase is almost entirely mechanical: red/amber/green/emerald map +to a fixed status meaning (danger/warning/success) essentially everywhere they +appear, so most of this phase is find-and-replace rather than judgment calls. +The exceptions are called out explicitly below — do not treat those as +precedent for bulk-replacing the rest. + +Read first: +- `.kiro/steering/tailwind-colors.md` — the color token rules (authoritative). +- `frontend/ai.client/src/branding/README.md` section 6 — contributor-facing rules. +- `frontend/ai.client/src/styles/tokens/state.css` — status tokens + (`state-danger-*`, `state-warning-*`, `state-success-*`, `state-info-*`). +- `frontend/ai.client/src/styles/tokens/identity.css` — vendor/file-type tokens, + including the "categorical chart series colors deliberately do NOT live + here" note at the bottom — that note is why this phase's chart work produces + a TypeScript module instead of new CSS tokens. + +All paths below are relative to `frontend/ai.client/`. + +## The one hard rule + +Never mix a zero-pixel refactor with a visible recolor in the same chunk. Each +chunk must be reviewable as either "this must look identical" or "this should +look different, and here's how." State which it is when you hand it over. + +Nearly all of this phase should be zero-pixel: the state tokens were copied +verbatim (same OKLCH values) from the same Tailwind steps the raw utilities +currently use, so `bg-red-50` → `bg-state-danger-50` etc. should not move a +single pixel. The exceptions in Step 3 (green-that-means-file-type, not +green-that-means-success) are the only places a wrong call would actually +change what the color communicates, not just its class name. + +## Step 1 — fresh inventory + +Run this before touching anything; do not reuse counts from a prior session: + +```powershell +$exts = @("*.ts","*.html") +$colors = @("red","amber","green","emerald","yellow","orange","purple","indigo","rose","sky","cyan","teal","lime","fuchsia","pink","violet") +foreach ($color in $colors) { + $m = Get-ChildItem -Recurse -Path src/app -Include $exts | ` + Where-Object { $_.Name -notlike "*.spec.ts" } | ` + Select-String -Pattern "$color-\d+" + "$color : $($m.Count) hits in $((($m | Group-Object Path).Count)) files" +} +``` + +Rough baseline from the last full-app scan (confirm, don't trust): `red` ~480 +hits, `amber` ~128, `green` ~118, `emerald` ~28, `yellow` ~32, `purple` ~50, +`rose` ~48, `orange` ~41, `indigo` ~40. Also scan for raw hex: + +```powershell +Get-ChildItem -Recurse -Path src/app -Include *.ts,*.html | ` + Where-Object { $_.Name -notlike "*.spec.ts" } | ` + Select-String -Pattern "#[0-9a-fA-F]{3,8}\b" +``` + +This turns up ~30 hits across ~28 files — most are Chart.js config objects +(`borderColor: '#3b82f6'`, tooltip `backgroundColor: isDarkMode ? '#1f2937' : +'#ffffff'`, etc. — TypeScript object literals, not Tailwind classes, so they +need a different fix than a class rename) plus a handful of genuinely unrelated +hex (e.g. an OAuth provider's brand `button_color` passed through from the +backend, which is correctly *not* in scope — that's per-provider branding data, +not app UI). + +## Step 2 — the mechanical majority + +For each of these, the mapping is fixed and should not require a judgment call +per site — apply it directly: + +| Raw utility | Token | Notes | +| --- | --- | --- | +| `red-*` | `state-danger-*` | error/destructive text, banners, delete buttons, invalid-input rings | +| `amber-*`, `yellow-*` | `state-warning-*` | caution banners, "almost at limit" text, pending/awaiting-auth dots | +| `green-*` | `state-success-*` | success banners, "complete" status, checkmarks — **check each site against Step 3 first** | +| `emerald-*` | `state-success-*` | same status meaning as `green` in this codebase; confirm no site uses emerald for something else before treating it as an alias | + +Work through files by top-level `app/` directory (mirrors the Phase 1/2 +grouping: `session/`, `components/`, `settings/`, `admin/`, `agents/`, +`fine-tuning/`, `assistants/`, `memory-spaces/`, `memory/`, `schedules/`, +`knowledge-base/`, `my-skills/`, `auth/`, `files/`, `manage-sessions/`). Work +incrementally, wait for an "okay" between chunks unless told otherwise. + +Straight class-string replacement is fine here (regex via `execute_pwsh` + +`Get-Content -Raw`/`Set-Content -NoNewline`, or `str_replace` for one-off +matches) — but per the lesson from Phase 1, **run `git diff --stat` after every +regex pass** to confirm only the intended files changed, especially with any +generic whitespace-cleanup regex. + +## Step 3 — the exceptions (do not bulk-replace these) + +A handful of `green`/other call sites mean something other than "success" and +must NOT become `state-success-*`: + +- **Spreadsheet file badges** (csv/xls/xlsx) — `green` here means "this is a + spreadsheet," a fixed file-type identity, not "this succeeded." These belong + on `filetype-sheet-*` (already defined in `identity.css`), not + `state-success-*`. Check `components/file-card/file-card.component.ts`, + `session/.../file-attachment-badge`, and + `session/.../renderers/file-download-renderer` specifically — these three + were named in the original Phase 1 plan as having partially-started + file-type maps; finish them here. Verify each file-type entry (PDF→rose/ + filetype-pdf, DOC/DOCX→filetype-doc, sheets→filetype-sheet, MD→ + filetype-markdown, images→filetype-image, HTML/code→filetype-code, + presentations→filetype-presentation) resolves to the matching `identity.css` + token rather than assuming only the green ones need attention. +- **Vendor/provider colors** — if any `green`/`purple`/`orange`/etc. hit turns + out to be a specific connector or vendor's brand color (matching the + Google/Microsoft/Canvas/Zoom pattern from Phase 1) rather than a generic + decorative pick, it belongs in `identity.css` as a `vendor-*` token, not + `state-*`. Check before assuming every non-red/amber color is fair game for + `state-success`. +- **Per-item decorative palettes** — metric-card icon colors or category badge + palettes where a color is one of several arbitrary hues distinguishing + siblings with no status meaning (the pattern handled in Phase 1's Agent + Marketplace chunk via `vendor-*` reuse). If you hit one of these, reuse an + existing non-brand, non-state token rather than assuming it's a success + indicator just because it happens to be green. +- When genuinely unsure whether a color is status or identity, ask — don't + guess and bulk-apply. + +## Step 4 — indigo, purple, rose, orange, sky, cyan, etc. + +These don't have a single fixed meaning in this app the way red/amber/green +do. For each hit, check the surrounding context and classify it as one of: + +1. **Actually a vendor/file-type identity color** (e.g. `indigo` used for + image file badges, `rose` for PDF, per the existing `identity.css` map) — + apply the matching `filetype-*`/`vendor-*` token. +2. **A decorative/arbitrary distinguishing color** with no fixed meaning (e.g. + a checkbox accent, a per-item palette slot) — per the Phase 1 precedent, + reuse an existing non-brand token that stays visually distinct from + siblings, rather than inventing a new one. +3. **Mislabeled state color** — occasionally `purple` or another odd color + shows up standing in for warning/info/success where whoever wrote the + component just grabbed a color that looked fine at the time with no + identity meaning at all. If it's genuinely a stray status color, migrate it + to the correct `state-*` token instead of preserving the arbitrary choice. +4. **Genuinely out of scope** — a one-off decorative accent with no + status/identity meaning and no sibling colors to stay distinct from (rare, + but don't force a token if there's truly no rule to apply). Flag these + rather than silently skipping them, so the user can weigh in. + +This step will need more back-and-forth than Step 2. Batch similar findings +together and present them rather than asking one-by-one. + +## Step 5 — chart series colors → TypeScript constants module + +`admin/costs/components/model-breakdown.component.ts` has a hardcoded +`colors` array of 10 raw hex strings (`#3b82f6`, `#10b981`, `#f59e0b`, +`#ef4444`, `#8b5cf6`, `#ec4899`, `#06b6d4`, `#84cc16`, `#f97316`, `#6366f1`) +used to color pie/bar chart segments by index. +`admin/costs/components/cost-trends-chart.component.ts` has two more +(`#3b82f6` for the cost line, `#10b981` for the requests line, plus matching +`rgba(...)` fill colors and dark-mode-aware tooltip/grid/text colors). + +Per `identity.css`'s note, these do not become CSS tokens — Chart.js needs a +resolved color string at render time, not a utility class. Create +`src/app/shared/constants/chart-colors.constants.ts` (matches the existing +convention in `shared/constants/session.constants.ts`) exporting: + +- A named palette array for categorical/indexed series (replaces the + `model-breakdown` color array) — keep the same 10 hex values unless there's + a reason to change them, since this is meant to be zero-pixel. + Well-known distinguishable colors, ideally the resolved sRGB values of the + same Tailwind steps already in use (converting OKLCH tokens to a hex string + for JS consumption is fine here — this is the one place raw hex is + intentional, since it's JS interop, not a Tailwind utility). +- Named exports for the specific semantic series colors currently hardcoded in + `cost-trends-chart` (`cost` line color, `requests` line color, and their + fill/alpha variants). +- Optionally, a small helper for the dark-mode-aware chrome colors (tooltip + bg/text, grid lines) that are currently duplicated across both chart + components with slightly different variable names — check if unifying them + is in scope or better left alone (they're not colors from the design system, + just gray-scale chart chrome, so this may be lower priority than the actual + data-series colors). + +Update both components to import from the new module instead of hardcoding +literals. This module is JS/TS, so the "copy OKLCH literals verbatim" rule +doesn't apply the same way — resolved hex/rgba strings are correct here since +Chart.js can't consume CSS custom properties directly at canvas-render time. + +## Step 6 — raw hex cleanup (the rest) + +For hex values found in Step 1 that are NOT chart-related (i.e. not covered by +Step 5), classify each: +- If it's a Tailwind-palette color spelled out as hex instead of a utility + class (e.g. someone wrote `style="color: #ef4444"` instead of + `class="text-red-500"`) — convert to the matching utility class first, then + apply the same token mapping as Step 2/3/4. +- If it's legitimately external data (an OAuth provider's configured + `button_color`, a user-uploaded brand asset, etc.) — leave it. This is data, + not a design-system color, and out of scope. +- If it's part of `dark:` conditional chart chrome already covered in Step 5, + skip (handled there). + +## Verification + +- Run `npm run build` after each chunk (`control_pwsh_process` background + start + `get_process_output` polling; builds take ~40-90s). +- After Step 5, manually sanity-check that `admin/costs` charts still render + with the same visual colors (open the cost dashboard, or at minimum confirm + the constants module exports the identical hex values that were removed + from the components). +- Check `.spec.ts` files for assertions on any class string or color literal + touched in this phase (Phase 1 hit this twice: `quota-card.component.spec.ts`, + `status-badge.component.spec.ts`) and update them. +- Full re-scan at the end: rerun the Step 1 script and confirm the only + remaining hits are the documented Step 3/4 exceptions and Step 6 external + data, not misses. +- Do not add new unit tests or visual regression snapshots — out of scope, + same as Phases 1 and 2. + +## Before you start + +Confirm the Step 1 counts and share the breakdown before starting Step 2 — +some of these numbers (480 `red` hits especially) suggest there may be +sub-chunking needed within a single color, not just within a directory tree. +Propose a chunking plan (by directory, as in Phase 1/2, or by color, or both) +before starting. diff --git a/docs/prompts/phase-4-lock-it-in.md b/docs/prompts/phase-4-lock-it-in.md new file mode 100644 index 000000000..eb92709c0 --- /dev/null +++ b/docs/prompts/phase-4-lock-it-in.md @@ -0,0 +1,251 @@ +# Task: lock in the color token migration (enforcement + raw hex mopup) + +## Context + +This is Phase 4, the final phase of the Tailwind color-token migration. +Phases 1–3 are done or nearly done: the `src/app` tree has gone from ~1816 +raw `blue-*` usages plus ~2000 other raw palette utilities down to **42 +remaining raw palette utility usages**, and the spinner duplication is +consolidated into ``. + +Phase 4 has two jobs: +1. **Enforcement** — make it impossible (or at least loud) to reintroduce raw + palette utilities, so the migration doesn't silently rot. +2. **Raw hex mopup** — the remaining hardcoded hex color values. + +Read first: +- `.kiro/steering/tailwind-colors.md` — the color token rules (authoritative). +- `frontend/ai.client/src/branding/README.md` section 6 — contributor-facing rules. +- `frontend/ai.client/src/app/global-hygiene.spec.ts` — an existing "guard spec" + in this codebase that enforces a global invariant via a test. This is the + closest existing precedent for the enforcement mechanism below; read it for + tone and structure (note how its header comment explains what a failure + means and what it does *not* mean). +- `docs/prompts/phase-3-outlier-colors-and-icon-spinners.md` — has open + decisions that overlap this phase's prerequisites. See "Coordination" below. + +All paths below are relative to `frontend/ai.client/` unless stated otherwise. + +## The one hard rule + +Never mix a zero-pixel refactor with a visible recolor in the same chunk. +Phase 4's enforcement work should be **entirely zero-pixel** — adding a guard +does not change any rendered output. The hex mopup in Part 2 may include +recolors; keep those in a separate chunk from the guard, and say which is which. + +## Important correction to the original plan + +The original 4-phase plan specified "an ESLint rule banning built-in palette +utilities in `src/app`." **This project has no ESLint.** Verified: no +`eslint.config.*`, no `.eslintrc*`, no `eslint`/`angular-eslint`/ +`typescript-eslint` in `package.json` devDependencies, and no `lint` script. +(`.kiro/steering/tech.md` claims "Linting: ESLint (frontend)" — that line is +aspirational and currently wrong; consider fixing it as part of this phase.) + +Two further facts make a lint rule a poor fit here: + +- **137 components use inline `template:` template literals vs. 46 using + `templateUrl`.** Roughly three quarters of this app's markup lives inside + TypeScript template literals. `angular-eslint`'s template rules primarily + target `.html` files; covering inline templates means wiring up its + processor, and even then the rule would be inspecting template-literal + contents. Class strings also appear in plain TS (e.g. `computed()` returning + `'bg-state-danger-600'`, `FILE_TYPE_CONFIG` color maps), which template + linting wouldn't see at all. +- Adding ESLint means introducing a whole toolchain (eslint + typescript-eslint + + angular-eslint + flat config + a `lint` script + a CI job) purely to + enforce one string-pattern rule. + +**Recommended instead: a guard spec.** A vitest spec that walks +`src/app/**/*.{ts,html}` and asserts no banned palette utility appears: +- Zero new dependencies. +- Runs in existing CI today — `.github/workflows/tests.yml` already runs + `npm run test:ci` in `frontend/ai.client`. +- Works uniformly across inline templates, `.html` files, and plain-TS class + strings, because it reads file text rather than parsing a template AST. +- Matches the existing `global-hygiene.spec.ts` precedent. +- The ratchet (below) is trivial to express as a list of enabled palettes. + +The one genuine advantage of ESLint is **editor-time feedback** — red squiggles +as you type, rather than a test failure after the fact. If the user wants that, +the honest answer is "both, eventually": ship the guard spec now because it's +cheap and total, and treat ESLint as an optional later addition for DX. Do not +silently substitute one for the other — state the recommendation, note the +tradeoff, and get agreement before building. + +## Part 1 — the guard + +### The ratchet + +The original plan's instruction still holds and is the key design idea: *enable +one palette at a time as each bucket completes, so it ratchets rather than +gates.* A guard that fails on day one is a guard someone disables. + +Current state (verified; re-verify before trusting — see the regex note below). +Counts are raw palette **utility** usages in `src/app`, excluding `*.spec.ts`: + +| Palette | Hits | Status | +| --- | --- | --- | +| red, amber, green, emerald, yellow, rose, cyan, teal, lime, fuchsia, pink, violet | 0 | **ban immediately** | +| sky | 1 | 1 file | +| blue | 3 | 2 files | +| indigo | 5 | 5 files | +| purple | 9 | 6 files | +| orange | 24 | mostly one page | + +So **12 of 17 palettes can be banned today.** Ship the guard with those 12 +enabled, and leave the remaining 5 as explicitly-listed pending palettes with a +comment pointing at what has to happen first. Every time one clears, move it +into the banned list — that's the ratchet. + +Do **not** ban neutrals. Per `tailwind-colors.md`, `gray`, `slate`, `zinc`, +`neutral`, `stone`, `white`, and `black` are outside the themed surface and are +explicitly allowed. A guard that flags `text-gray-500` would be wrong and +would generate thousands of false failures. + +### Get the regex right (two traps) + +This bit me while surveying, so save yourself the cycle: + +- **A naive `"$color-\d+"` over-matches comments.** Most apparent "violations" + left in the tree are documentation, e.g. `chart-colors.constants.ts` contains + `'#3b82f6', // blue-500` — a comment naming the source step, which is + desirable, not a violation. Ditto `/* slate-800 */` in a CSS comment and the + `` + annotation in `api-keys.page.ts`. +- **A lookbehind like `(?` comments before matching; +or match only inside `class="..."`/`[class...]`/`'...'` contexts; or keep a +small explicit allowlist of `file:line` exceptions. Whatever you choose, the +guard must not flag the legitimate documentation comments listed above. + +### Shape of the guard + +- Location: alongside the precedent, e.g. `src/app/color-tokens.spec.ts` + (or `src/styles/color-hygiene.spec.ts` if that reads better). +- It needs filesystem access, so it likely wants `// @vitest-environment node` + rather than the jsdom default. **Verify this actually works** under this + project's `@analogjs/vitest-angular` builder before building the whole thing + — write a trivial one-assertion version first and run it. If reading the + source tree from a spec turns out to be awkward under that builder, fall + back to a standalone node script wired into CI, and say so. +- Failure output must be actionable: list offending `file:line` plus the + matched utility, and point at `tailwind-colors.md` for the mapping. A guard + that just says "expected 3 to be 0" will get deleted by the next person who + hits it. +- Header comment in the style of `global-hygiene.spec.ts`: what this enforces, + why, what a failure means, and how to fix it (map to `primary-*` / + `state-*` / `vendor-*` / `filetype-*`), including how to add a documented + exception if one is genuinely warranted. + +## Part 2 — raw hex mopup + +Current state (verified, `src/**`, excluding `*.spec.ts` and generated files): +**177 hex values across 39 files** — 139 in `.ts`, 30 in `.css`, 8 in `.html`. + +The original plan said "~206 raw hex values across 16 files." The hit count is +in the same ballpark; the file count is not, so re-scan rather than trusting +either number: + +```powershell +Get-ChildItem -Recurse -Path src -Include *.ts,*.html,*.css | ` + Where-Object { $_.Name -notlike "*.spec.ts" -and $_.FullName -notlike "*generated*" } | ` + Select-String -Pattern "#[0-9a-fA-F]{3,8}\b" +``` + +Classify every hit. Known buckets: + +- **Out of scope — generated.** `src/styles/generated/brand-theme.css` (~39 + hits) is machine-written from `brand.config.ts`. Never hand-edit; exclude + from both the scan and any guard. +- **Out of scope — intentional JS interop.** `src/app/shared/constants/chart-colors.constants.ts` + (~22 hits) was created in Phase 3 precisely because Chart.js needs resolved + color strings, not utility classes. This is the one place raw hex is correct. + If the guard also checks hex, this file must be allowlisted with a comment + explaining why, so nobody "fixes" it later. +- **Out of scope — external data.** Values that are provider/tenant branding + passed through from the backend (e.g. an OAuth provider's configured + `button_color`, seen in `admin/auth-providers/pages/provider-form.page.ts` + and `auth/login/login.page.ts`). This is data, not design-system color. + Confirm each before excluding. +- **In scope — component CSS and inline styles.** The `.css` files + (`tool-rail.component.css` ~16, `voice-overlay.component.css` ~9) and hex + inside `.ts`/`.html` style bindings. For these, the fix is a CSS custom + property referencing a token (`var(--color-state-danger-500)` etc.) rather + than a utility class, since they're real CSS declarations. Check what the + hex actually resolves to before swapping — if it's a neutral gray it may be + fine as-is per the neutrals exemption. +- **In scope — needs a look.** `assistant-indicator.component.ts` (~27), + `artifact-card.component.ts` (~23), `agent-icon.component.ts` (~12), + `mcp-app-frame.component.ts` (~10). These are the biggest concentrations and + are not yet characterized. Inspect them before deciding — some may be SVG + fills, gradient stops, or canvas/drawing colors that need the same JS-interop + treatment as the chart colors (i.e. a constants module) rather than tokens. + +Handle this in chunks by file or directory, same incremental rhythm as earlier +phases. Where a hex maps cleanly onto an existing token, the swap should be +zero-pixel; where it doesn't, that's a recolor and needs to be called out. + +## Coordination with the parallel Phase 3 track + +`docs/prompts/phase-3-outlier-colors-and-icon-spinners.md` owns open decisions +that are **prerequisites for fully closing the ratchet**, specifically: + +- The **`api-keys.page.ts` page-wide orange theme** (~24 of the 42 remaining + hits, by far the largest block). That prompt asks whether the page's orange + accent earns its own token or is a documented exception. Until that's + answered, `orange` cannot be banned. +- The **indigo dialog boilerplate** — `focus:outline-indigo-600` on close + buttons, confirmed across `confirmation-dialog.component.ts`, + `tool-role-dialog.component.ts`, `delete-tool-dialog.component.ts`, and + `manage-shares-dialog.component.ts`. The parallel prompt hypothesized this + was a shared dialog convention rather than independent accidents; the spread + across four dialogs supports that. Until it's resolved, `indigo` can't be + banned. + +Do not unilaterally resolve those two here — they're recolor/convention +decisions owned by that track. Phase 4's job is to make the guard tolerate them +today (pending palettes) and tighten once they land. The remaining stragglers +(purple category/decorative usages in `fine-tuning-costs`, `gemini-models`, +`role-form`; the deferred two-color gradients in `profile-settings` and +`agent-detail`; the lone `sky` hit) are smaller and may be fair game here — +check with the user whether to fold them into this phase or leave them to the +Phase 3 track. + +## Verification + +- `npm run build` after each chunk (`control_pwsh_process` background start + + `get_process_output` polling; builds take ~40-90s and a direct + `execute_pwsh` call will time out). +- Run the guard spec itself and confirm it **passes** on the current tree with + the 12 clean palettes enabled. Then deliberately break it (temporarily add + `bg-red-500` somewhere) and confirm it **fails with a useful message**. A + guard that can't fail is worse than no guard. Revert the deliberate break. +- Run the full frontend suite (`npm run test:ci`) once at the end, since this + phase adds a spec rather than only touching markup. +- Confirm the guard is actually reached by CI (it should be, via + `.github/workflows/tests.yml` → `npm run test:ci`) — don't assume, check the + workflow's working directory and command. +- Do not add visual regression snapshots — still out of scope. + +## Before you start + +Confirm with the user: +1. **Guard spec vs. ESLint** (or both) — this is the central decision of the + phase and the point where the original plan needs amending. Do not start + building either until this is settled. +2. Whether the purple/gradient/sky stragglers belong to this phase or the + parallel Phase 3 track. +3. Whether the incorrect "ESLint (frontend)" line in + `.kiro/steering/tech.md` should be corrected as part of this work. diff --git a/docs/specs/artifact-sharing.md b/docs/specs/artifact-sharing.md new file mode 100644 index 000000000..f8586b512 --- /dev/null +++ b/docs/specs/artifact-sharing.md @@ -0,0 +1,397 @@ +# Artifact sharing + +**Status:** proposed +**Feasibility:** high — no new AWS resources, no new IAM, no CSP change +**Builds on:** artifacts feature (#306–#311), conversation sharing +(`backend/src/apis/app_api/shares/`), render-token contract +(`backend/src/lambdas/artifact_render/handler.py`) + +## Summary + +Users can create artifacts (`create_artifact` / `update_artifact`) but cannot +show one to anyone. The only egress today is **download** — mint a token, save +the file, email the file. That loses the live render, loses versioning, and +loses revocation. + +This spec adds a share record on an **artifact version**, mirroring the +conversation-share model already in production: an owner-created, revocable, +access-controlled pointer that any authenticated recipient can open at +`/shared-artifact/{shareId}`, viewing the exact bytes in the same sandboxed +iframe the owner sees. + +The feasibility conclusion is that this is mostly **plumbing an ACL check in +front of an existing minting call**. Every hard part — the isolated render +origin, the strict CSP, the signed short-lived token, immutable versions, the +sandboxed viewer component, the download path — already exists and is already +deployed. + +## Why it is feasible + +### 1. The render token already separates *authorization* from *identity* + +`RenderTokenService.mint` (`app_api/artifacts/service.py`) does exactly two +things: assert the `(user, artifact, version)` row exists, then sign an HS256 +JWT. The render Lambda then uses the token's `sub` claim purely as the +**DynamoDB partition key** to locate the artifact: + +``` +PK = USER#{sub} +SK = ARTIFACT#{aid}#V#{ver:05d} +``` + +The Lambda performs **no ownership comparison of its own** — it never sees the +viewer. The token *is* the capability. So a share flow only has to answer "may +this viewer be handed a token for the owner's row?" in app-api, and mint with +`sub` = the owner's id. **The render Lambda needs no change at all** for the +core feature. + +That is the single most important finding: the expensive half of a sharing +feature (a viewer-safe, authenticated, sandboxed rendering path for +attacker-authored HTML) is already built and is identity-agnostic. + +### 2. Versions are immutable, so snapshot semantics come for free + +`update_artifact_record` appends `V#{n+1}` and re-points `#HEAD`; there is no +`DeleteObject` grant in inference-api. A share pinned to `(artifactId, version)` +can therefore **never change under the recipient** — no snapshot copy, no S3 +duplication, no schema-versioned body like `shares/snapshot_store.py` needed for +conversations. The conversation-share feature had to offload a JSON body to S3 +to get point-in-time semantics; artifact sharing gets them from the storage model. + +### 3. The CSP already permits exactly the topology we want + +`ArtifactsDistributionConstruct` sets `frame-ancestors https://{domainName}` +(plus `config.artifacts.extraFrameAncestors`) and `connect-src 'none'`. A +recipient viewing a shared artifact **inside the SPA** is the SPA origin +framing the artifact origin — already allowed. Artifact JS still cannot reach +app-api, cannot phone home, cannot exfiltrate. Nothing in the CSP, the +distribution, or the response-headers policy changes. + +### 4. app-api already holds every permission required + +`app-api-iam-grants.ts` grants the task role `GetItem/PutItem/UpdateItem/ +DeleteItem/Query` on the artifacts table (and `index/*`), `GetObject/PutObject/ +PutObjectTagging/ListBucket` on the content bucket, and `GetSecretValue` on the +render-token secret. Share rows can live on the **existing artifacts table** +under a new key prefix. **Zero new CDK resources, zero new IAM statements.** + +### 5. The whole UX pattern is already in the codebase + +| Need | Existing thing to model on | +|---|---| +| Share dialog (public / specific-emails, copy link) | `session/components/share-modal/` | +| Share HTTP client | `session/services/share/share.service.ts` | +| Recipient page behind `authGuard` | `shared/shared-view.page.ts`, route `shared/:shareId` | +| Manage/revoke existing shares | `manage-sessions/manage-shares-dialog/` | +| Sandboxed artifact viewer | `artifact-panel.component.ts` | +| Download of a shared version | `artifact-download.service.ts` + `?download=1` | + +### Feasibility risk register + +| Risk | Severity | Handling | +|---|---|---| +| Minting a token whose `sub` is another user makes the render log attribute the view to the owner | medium | Add `vwr` (viewer id) + `shr` (share id) claims at mint time. `_verify_token` validates `alg`/`iss`/`aud`/`exp`/`iat`/`sub`/`aid`/`ver` and then returns the claim dict — it has **no extras rejection**, verified by reading the handler — so extra claims are forward-compatible with the currently-deployed Lambda and a later Lambda deploy starts logging them. No deploy sequencing required. | +| Recipient could re-share by copying the iframe URL | low | Already true of the owner's own render URL, and tokens live ~120s. The share record, not the token, is the revocable control. | +| Artifacts survive session deletion (no cascade today; the `lifecycle-class=deleted` S3 rule has no writer) | medium | Cascade artifact-share revocation on session delete, mirroring `delete_shares_for_session`. See §7. | +| Shared artifacts inside a **shared conversation** silently vanish | medium | Pre-existing gap, documented in §8 as explicitly out of scope for PR-1. | +| A recipient views an artifact whose owner has since revoked | low | Every open re-checks the share row before minting; tokens are ~120s, so the revocation window is bounded by token TTL, not by session length. | + +## Non-goals + +- **Anonymous/unauthenticated public links.** Conversation sharing's `"public"` + already means "any authenticated tenant user" — the `/shared/:shareId` route + sits behind `authGuard` and `get_shared_conversation` depends on + `get_current_user_from_session`. Artifact sharing matches that exactly. + Governance here is Entra JWT identity, not content inspection. +- **Collaborative editing.** Shares are read-only. A recipient who wants to + iterate forks (§6, deferred). +- **Sharing `#HEAD` (a moving pointer).** A share pins one version. Sharing a + pointer that moves under the recipient is a different feature with different + consent semantics, and a moving pointer is the exact trap that bit agent + version snapshots. +- **Changing the render Lambda's verification contract.** + +## Decisions + +| Question | Decision | +|---|---| +| Share target | One **immutable `(artifactId, version)`** pair, never `#HEAD` | +| Access levels | `public` (any authenticated user) \| `specific` (email allowlist) — same literals as conversation shares | +| Storage | New key prefix on the **existing** `user-artifacts` table | +| Snapshot | **None** — version immutability is the snapshot | +| Render path | app-api ACL check → mint token with `sub`=owner, `vwr`=viewer, `shr`=share | +| Render Lambda change | **None required** for PR-1 | +| Recipient surface | SPA route `/shared-artifact/:shareId` behind `authGuard` | +| Revocation | Delete the share row; effective within one token TTL (~120s) | +| Feature enablement | Same signal as artifacts: presence of `ARTIFACTS_RENDER_TOKEN_SECRET_ARN` | +| Audit | Structured logs only (conversation sharing sets this precedent; `AuditAction` is a closed set scoped to admin mutations) | + +### Why a new key prefix instead of the shared-conversations table + +The share row must be read on **every** render-token mint for a shared +artifact, and the mint path already touches the artifacts table to validate the +version. Co-locating keeps that to one table and no second client. The +conversation-share table is keyed `share_id` with a `SessionShareIndex`; it has +no artifact concept and adding one would leave two unrelated record shapes +under one key. + +## 1. Data model + +Added to the existing `{prefix}-user-artifacts` table. No new GSI **for PR-1** +(see the lookup note below). + +**Share record** — the owner-scoped row, so an owner can list their own shares +with one `Query` on their existing partition: + +``` +PK = USER#{owner_id} +SK = SHARE#{artifact_id}#V#{version:05d}#{share_id} +attrs: + share_id, artifact_id, version, owner_id, owner_email, + access_level: "public" | "specific", + allowed_emails: [str] # present only when access_level == "specific" + title # denormalized for the recipient header + content_type # denormalized so the viewer can pick its chrome + session_id # provenance / cascade-revoke + created_at, updated_at +``` + +**Share lookup record** — the recipient path resolves `share_id` alone, with no +idea who the owner is: + +``` +PK = SHARE#{share_id} +SK = META +attrs: owner_id, artifact_id, version, access_level, allowed_emails, + title, content_type, created_at +``` + +Two rows, written in one `transact_write_items`, is deliberately chosen over a +GSI. Per the GSI deploy-ordering trap, adding an index means a `platform.yml` +deploy that must land before the backend code that queries it, one +`UpdateTable` at a time, with CFN green ≠ index `ACTIVE`. Two items in a +transaction needs no infra deploy at all and makes the recipient read a single +`GetItem`. The cost is denormalized duplication on update — bounded, because +the only mutable fields are `access_level` and `allowed_emails`. + +No IAM change is needed for the transaction: DynamoDB authorizes +`TransactWriteItems` against the **underlying** item actions, and the artifacts +grant already carries `PutItem`/`UpdateItem`/`DeleteItem`. `TransactWriteItems` +appears in no grant in `app-api-iam-grants.ts`, yet +`apis/shared/rbac/repository.py` calls `transact_write_items` against the +app-roles table in production — the pattern is already proven here. (Keep the +transaction to plain writes; a `ConditionCheck` item *would* need +`dynamodb:ConditionCheckItem` added.) + +## 2. API + +New router, `backend/src/apis/app_api/artifacts/shares.py`, mounted under the +same `ARTIFACTS_RENDER_TOKEN_SECRET_ARN` guard that already gates +`artifacts_router` in `main.py`. + +All owner endpoints use `get_current_user_from_session` (SPA-facing — Bearer-only +would cause 401 redirect loops). + +| Method | Path | Purpose | +|---|---|---| +| `POST` | `/artifacts/{artifact_id}/shares` | Create a share for `{version, accessLevel, allowedEmails?}`. 201. | +| `GET` | `/artifacts/{artifact_id}/shares` | List the caller's shares for this artifact. | +| `PATCH` | `/artifacts/shares/{share_id}` | Change `accessLevel` / `allowedEmails`. Owner only. | +| `DELETE` | `/artifacts/shares/{share_id}` | Revoke. 204. Owner only. | +| `GET` | `/shared-artifacts/{share_id}` | Recipient metadata: `{shareId, title, contentType, version, createdAt, ownerEmail, canDownload}`. Access-controlled. **Never returns content.** | +| `POST` | `/shared-artifacts/{share_id}/render-token` | Access-checked mint. Returns the same `{url, expires_at}` shape as the owner endpoint. | + +Error mapping follows the existing artifacts routes: 404 unknown share/version, +403 access denied, 413 too large (content view), 500 `RenderTokenConfigError`, +503 `ArtifactQueryError`. + +### The mint, in full + +```python +def mint_for_share(self, *, share_id: str, viewer: User) -> tuple[str, int]: + origin = _origin() # fail closed before any DDB call + share = _get_share_lookup(share_id) # PK=SHARE#{id}, SK=META + if not share: + raise ArtifactNotFoundError(...) + _check_share_access(share, viewer) # owner | public | email allowlist + _assert_version_exists( # unchanged helper + share["owner_id"], share["artifact_id"], int(share["version"]) + ) + now = int(time.time()) + claims = { + "iss": _ISS, "aud": _AUD, + "sub": share["owner_id"], # DDB partition — NOT the viewer + "aid": share["artifact_id"], + "ver": int(share["version"]), + "sid": "", + "vwr": viewer.user_id, # who actually looked (audit) + "shr": share_id, # under which grant + "iat": now, "exp": now + _TTL_SECONDS, + } + ... +``` + +`_check_share_access` is a direct port of `ShareService._check_access`: owner +always passes, `public` passes, `specific` compares `viewer.email.lower()` +against the lowercased allowlist, else `AccessDeniedError`. + +**`sub` is the owner and that is load-bearing** — it is the DynamoDB partition +key the render Lambda builds, not an identity assertion. `vwr`/`shr` carry the +real viewer. This must be commented at the mint site, or a future reader will +"fix" it into a privilege bug. + +## 3. Frontend + +**Owner side.** A `Share` button beside `Download` on both the artifact card +(`artifact-card.component.ts`, action row near line 102) and the panel header +(`artifact-panel.component.ts`). Icon-only variants need `[appTooltip]` per the +frontend accessibility rule; the card's existing pattern keeps a visible label, +so match it. Opens `artifact-share-modal.component.ts`, adapted from +`share-modal.component.ts` (same access-level radio, same email chips, same +clipboard copy, same `@angular/cdk/dialog` shell). + +**Recipient side.** Route `shared-artifact/:shareId` behind `authGuard`. New +`shared-artifact-view.page.ts`, modelled on `shared-view.page.ts`: the same +sticky "Shared read-only snapshot" banner, the same 403/404/500 branches, and +the artifact rendered full-width in the same sandboxed iframe +(`sandbox="allow-scripts"`, **no** `allow-same-origin`) the panel uses. Reuse +the panel's render/code toggle by extracting its iframe + `ArtifactSourceComponent` +body into a presentational child both pages import — the panel keeps its +docking, resize, and version-menu chrome. + +**Services.** `ArtifactShareService` alongside `artifact-http.service.ts` +(owner CRUD), and a `mintSharedRenderToken(shareId)` path. `ArtifactDownloadService` +takes an optional share id so the hidden-iframe `?download=1` trick works +unchanged for recipients — the recipient mint is a different endpoint, same +returned URL shape. + +**Content/code view for recipients.** `GET /artifacts/{id}/content` builds its +key from the authenticated user and must stay that way. Recipients get a +parallel `GET /shared-artifacts/{share_id}/content` that resolves the owner from +the share row after the same ACL check, reusing `ArtifactContentService` with an +explicit `owner_id` argument. + +## 4. Delivery plan + +| PR | Scope | +|---|---| +| **PR-1** | Data model + owner CRUD API + share-scoped mint. Backend only, fully tested. | +| **PR-2** | Owner UI: Share button on card + panel, share modal, manage/revoke list. | +| **PR-3** | Recipient UI: route, page, extracted viewer child component, shared content + download. | +| **PR-4** | Cascade revoke on session delete (§7) + docs-site page update. | + +PR-1 through PR-3 are independently mergeable behind the artifacts enablement +signal; the Share button in PR-2 is the first user-visible change. + +## 5. Testing + +Backend (`backend/tests/apis/app_api/artifacts/`): + +- `test_artifact_shares.py` — create writes both rows transactionally; owner + list is partition-scoped; PATCH/DELETE reject a non-owner with 403; revoked + share 404s. +- `test_shared_render_token.py` — **the security core.** Assert the minted + claims: `sub` == owner, `vwr` == viewer, `shr` == share id. Assert `public` + admits an arbitrary authenticated user; `specific` admits only allowlisted + emails (case-insensitively) and 403s everyone else; a revoked share mints + nothing; a share whose underlying version row is gone 404s rather than + minting a token that renders the Lambda's error page. +- A test that the currently-deployed verifier accepts a token carrying the new + `vwr`/`shr` claims — import `_verify_token` from the Lambda handler directly + (it takes no AWS calls before the signature check) and assert it returns the + claims rather than raising. This is the assertion that makes "no Lambda + change required" a fact rather than a reading. + +Frontend (`ng test` — never bare `npx vitest run`): share-modal interaction, +recipient page 403/404 branches, and the extracted viewer child rendering from +a stubbed token. Use DI token overrides, not `vi.mock`. + +## 6. Deferred + +- **Fork-to-own-artifact**, mirroring `POST /shares/{id}/export`: copy the + version's S3 object into the recipient's own `{user_id}/{new_aid}/v1/` prefix + and write fresh v1 rows, so a recipient can iterate on someone else's + artifact in their own chat. This is the artifact analogue of "Continue this + conversation" and is the most likely next ask. +- **Artifacts inside shared conversations** (§8). +- Share expiry (`ttl` on the share rows — the table already has a TTL attribute + configured, so this is a field, not an infra change). +- View counts / "who opened this". + +## 7. Session-delete cascade + +`sessions/routes.py` already schedules `share_service.delete_shares_for_session` +as a background task on conversation delete. Artifacts are **not** cleaned up +there today, and the S3 `lifecycle-class=deleted` rule has no backend writer at +all — so artifacts (and their shares) would outlive the conversation that +produced them. + +PR-4 adds `delete_artifact_shares_for_session(session_id)`, called from the same +background task. It queries `SessionIndex` for the session's artifact ids, then +deletes matching `SHARE#` rows and their lookup rows. Best-effort and +never-raising, exactly like the conversation version: the failure mode is an +orphan row, never a blocked delete. + +Deliberately **not** in scope: deleting artifact content on session delete. +That is a retention decision about the artifacts feature as a whole, not about +sharing, and it deserves its own spec. + +## 8. Artifacts in shared conversations — CLOSED + +**Status: implemented.** Sharing a conversation now shares the artifacts it +produced. The rest of this section is the original gap analysis, kept because +the reasoning still explains the shape of the fix. + +The mechanism differs from the sketch below in one important way: **no artifact +share records are created.** The conversation share record *is* the grant. + +- `create_share` pins the session's artifacts, at the version each stood at, into + the snapshot body alongside the messages. That preserves the point-in-time + promise (a recipient reading a frozen conversation sees the artifact the + transcript describes) and makes the snapshot the **allowlist**. +- `resolve_shared_artifact` is the whole access boundary: it checks the + conversation share's ACL *and* that the artifact is one the snapshot pinned, + then hands an owner id and pinned version to + `RenderTokenService.mint_for_conversation_share`, which checks nothing itself. +- Access, updates and revocation are therefore free. Narrow the conversation's + allowlist and the artifacts follow in the same write; revoke it and they go + with it. + +Auto-provisioning parallel artifact shares (the original sketch) was rejected on +two grounds. Each would need cascading on update, revoke, artifact delete and +session delete — and a missed cascade leaves an artifact readable after its +conversation was locked down, which is a security bug rather than a display one. +It would also put N rows in the recipient's "Shared with you" inbox for one +conversation share, when the conversation is the thing that was shared. + +The snapshot's artifact list is optional on read. Conversation sharing is in +production, so bodies written before this exist and have no `artifacts` key; +they read as an empty list, and there is no migration. + +### Original gap analysis + + + +`shared-view.page.ts` renders `MessageListComponent` with `embeddedMode` and has +no artifact wiring. Artifact hydration goes through +`GET /artifacts?session_id=…`, which filters HEAD rows by +`item.user_id == requester`. A recipient viewing a shared conversation that +produced artifacts therefore sees **nothing** where the owner sees artifact +cards — silently, with no placeholder. + +Closing it properly means auto-provisioning artifact shares for every artifact +in a conversation at conversation-share time (a consent decision: sharing a +conversation would then also share its artifacts), plus surfacing them in the +shared view. That is a separate spec, and it depends on this one's share record +existing first. Recording it here so it is a known gap rather than a discovered +bug. + +## Effort estimate + +| Area | Assessment | +|---|---| +| Infrastructure | **None.** No new table, bucket, distribution, secret, cert, DNS, IAM, or CSP change. | +| Render Lambda | **None** for PR-1. Optional later change to log `vwr`/`shr`. | +| Backend | Moderate — one service + one router, both close ports of existing code. | +| Frontend | Moderate — one new modal, one new page, one extracted viewer child. | +| Highest-risk surface | The share-scoped mint. It hands a viewer a credential for another user's DynamoDB partition; the ACL check is the only thing standing between "sharing" and "read any artifact by id". Test it as the security boundary it is. | diff --git a/docs/specs/feature-announcements.md b/docs/specs/feature-announcements.md new file mode 100644 index 000000000..bd1996157 --- /dev/null +++ b/docs/specs/feature-announcements.md @@ -0,0 +1,613 @@ +# Feature announcements + +**Status:** PROPOSED — no code. Written 2026-09-04 from the "how do we tell +users about new features, and let them acknowledge it once" conversation. + +**Refs:** `apis/shared/user_menu_links/` (the closest existing precedent — an +admin-authored, markdown-bodied, modal-rendered surface), `apis/shared/rbac/ +admin_scopes.py` (the delegation registry this adds a scope to), +`components/quota-warning-banner/` (the banner surface pattern), +`docs/specs/granular-admin-permissions.md`, `docs/specs/mid-turn-steering.md` +(#934, the `isLoading()` caveat in §D8) + +--- + +## 1. Problem + +There is no way for an admin to tell users that something changed. + +The platform ships features continuously — Skills, Agents, the Marketplace, +Memory Spaces, MCP Apps, mid-turn steering — and every one of them landed +silently. Discovery is left to the user noticing a new nav item. The only +admin-authored user-facing text in the product today is a **user-menu link** +(`apis/shared/user_menu_links/`), which is a static, always-present entry: +it has no notion of "new", no notion of "this user has already seen it", and +no way to draw attention to itself. + +So the two halves of the ask are: + +1. **Reach** — put an admin-authored message in front of users, with a + choice of how loudly. +2. **Acknowledgement** — record, per user and durably, that they have seen + it, so it stops being shown. Without this half the feature is worse than + nothing: a banner that reappears on every page load trains users to + ignore banners. + +The second half is the part that is easy to get wrong, and where the +interesting decisions are. + +--- + +## 2. What already exists + +Everything below is a real precedent in the codebase, not an analogy. The +design leans on all four rather than inventing parallel machinery. + +| Precedent | What it gives us | +|---|---| +| `apis/shared/user_menu_links/{models,repository,service}.py` | Admin-authored content, fixed-partition single-table storage (`PK: USER_MENU_LINKS`, `SK: LINK#`), markdown body, `enabled`/`order` fields, admin CRUD at `/admin/user-menu-links` + a public read at `/user-menu-links`. The announcement repository is this file with more fields. | +| `apis/shared/user_settings/repository.py` | Per-user server-side state at `PK: USER#`, `SK: SETTINGS`. Establishes the per-user key shape acknowledgements reuse. | +| `components/quota-warning-banner/quota-warning-banner.component.ts` | The banner surface: compact, `role="status"`, `aria-live="polite"`, dismiss button, light/dark. Its dismissal is *client-side*, which is correct for a recurring live signal and wrong for a one-shot announcement — see §D3. | +| `components/topnav/components/user-menu-link-modal/` | Markdown-in-a-dialog, via `ngx-markdown`'s ``, `@angular/cdk/dialog`, `appDialogDismiss`, focus/escape handling. The announcement modal is this component with an ack button. | +| `apis/shared/rbac/admin_scopes.py` + `admin/admin-scope.model.ts` | The closed, delegable admin-scope registry. `admin.user_menu_links` already exists in the `Customization` group; `admin.announcements` slots in beside it. | +| `apis/shared/users/repository.py` (`UserProfile.created_at`) | The signup timestamp that makes new-user backfill suppression (§D6) possible. | + +--- + +## 3. Decision summary + +| # | Decision | +|---|---| +| D1 | Three surfaces — **panel** (durable), **banner** (ambient), **modal** (interruptive) — selected per announcement. The panel is always implied. | +| D2 | Three acknowledgement actions — `seen`, `dismissed`, `acknowledged` — monotonic, never downgraded. | +| D3 | Ack state is **server-side per user**, not `localStorage`. | +| D4 | Acks are keyed by `#R`; editing an announcement does not re-show it unless the admin bumps `revision`. | +| D5 | The server computes visibility; the client renders what it is handed. | +| D6 | Users who joined after `publishAt` do not see the announcement, unless the admin opts in with `showToNewUsers`. | +| D7 | At most **one** modal and **one** banner per response, no matter how many are eligible. | +| D8 | The modal never opens over an active or paused turn. | +| D9 | `targetRoles` is a **display filter, not an RBAC grant** — it must never be written into a role's `granted*` lists. | +| D10 | Markdown is sanitized, and the admin scope is delegable — those two facts are connected. | +| D11 | Gated by `ANNOUNCEMENTS_ENABLED`, default ON with a kill switch. | +| D12 | Nothing about this feature touches the model call path. | + +--- + +## 4. Design + +### D1 — Three surfaces, selected per announcement + +An announcement carries `surfaces: list[Literal["panel", "banner", "modal"]]`. + +- **`panel`** — a "What's New" entry in the user dropdown, next to the + existing admin-managed links, with an unread dot on the avatar. Pull-based, + never interrupts, browsable forever. **Implied on every announcement**: the + service adds `"panel"` if the admin omits it, so dismissing a loud surface + can never destroy the information. +- **`banner`** — a compact pill floating just above the chat composer, + rendered by a sibling of `quota-warning-banner`. One line plus an optional + CTA and a ✕. + + *Revised after PR-4 shipped.* It was first built as a full-bleed strip below + the top nav. Two things moved it. Dismissing a strip that occupied layout + reflowed the whole view, so it became an overlay; and what a banner + announces — a new model, a new capability — is acted on **in the composer**, + so the notice belongs where the decision is made rather than in a corner the + eye has already left. The consequence to keep in mind: the banner is now a + **chat-view surface only**, and it is deliberately suppressed in the + embedded preview panes (agent preview, marketplace test-drive). The panel + remains the everywhere-record, which is why `panel` is forced onto every + announcement server-side. +- **`modal`** — a dialog on next load. This is the only surface that can + demand a real acknowledgement (`requiresAck`). + +The pattern that matters: **one durable surface plus at most one +attention-grabber.** The admin picks how loud; the record persists either way. + +Two surfaces are deliberately excluded. + +**Toasts.** `components/toast` exists, but a toast is ephemeral by +construction — if the user is not looking at that corner of the screen for +four seconds, the announcement never happened. There is no honest way to +record `seen` for it. + +**Injected chat messages.** Tempting — the user is already looking at the +thread — and wrong on this platform specifically. A message in the session +lands inside the cacheable prefix; CLAUDE.md's prompt-cache contract is +explicit that restored history must be byte-stable between turns, and an +announcement injected into history re-writes a 30k–150k-token prefix at the +cache-write premium for every user who receives it. It also becomes context +the model reads and may respond to. The reach is not worth a fleet-wide cache +bust. **Announcements never touch the model call path** (D12). + +### D2 — Three acknowledgement actions, monotonic + +| Action | Written when | Effect | +|---|---|---| +| `seen` | The announcement renders on any surface | Clears the unread dot. Does not suppress anything. | +| `dismissed` | User clicks ✕ or "Got it" | Suppresses banner and modal. Entry stays in the panel. | +| `acknowledged` | User clicks the confirm button on a `requiresAck` modal | As `dismissed`, plus it is a durable record an admin can report on. | + +They are ranked (`seen=1 < dismissed=2 < acknowledged=3`) and the stored rank +**only ever increases**. The write is a conditional `UpdateExpression`: + +``` +SET actionRank = :rank, #action = :action, actionAt = :now, ... +ConditionExpression: attribute_not_exists(actionRank) OR actionRank < :rank +``` + +A `ConditionalCheckFailedException` here is success, not an error — swallow it. + +This matters more than it looks. `seen` is written automatically on render, so +it races the user's click on ✕; without the guard, a late-arriving `seen` +write would clobber `dismissed` and the banner would come back. CLAUDE.md +already records two production bugs (#741, #751) whose shape was +per-user/per-session state moving backwards. Same class, so use the same +discipline: **make the write monotonic at the database, not in application +ordering.** + +### D3 — Server-side ack state, not `localStorage` + +`quota_warning` dismissal is client-side today and that is right for what it +is: a recurring signal that recomputes every turn, where "dismiss" means "not +now" and re-showing tomorrow is the intended behaviour. + +An announcement is the opposite — a one-shot statement where "dismiss" means +"never again". Client-side state gives: + +- the banner back on every other device, +- the banner back in a private window, +- the banner back after a cache clear, +- no way to answer "did people read the policy change?", +- and for `requiresAck`, an acknowledgement record that any user can erase + from devtools, which is not a record at all. + +So acks are DynamoDB items under the user's partition. The cost is two small +queries on SPA bootstrap (§5), which is cheap and bounded. + +`localStorage` still has one legitimate job: the **fail-open** case in §D7 — +if the ack POST fails, hide the item locally for the tab session so the user +is not stuck under an undismissable banner. It is a fallback, never the +source of truth. + +### D4 — Revision-keyed acks + +The ack sort key is `ACK##R`, not +`ACK#`. + +Without the revision, an admin fixing a typo in a published announcement has +two equally bad options: the edit is invisible to everyone who already +dismissed it, or every edit un-dismisses it for the entire user base and a +typo fix re-fires a modal at ten thousand people. + +With it, `revision` is an explicit admin lever. `PATCH` to body/title leaves +`revision` alone by default; a **"Show this again"** action in the admin UI +increments it, and everyone's suppression lapses at once. Because the ack SKs +share the `ACK##` prefix, a `begins_with` query still tells us the user +saw revision 1 — which lets the panel mark the entry **Updated** rather than +plain unread. + +### D5 — The server computes visibility + +`GET /announcements` returns only what this user should actually see, already +filtered and capped. The client renders it; it does not evaluate targeting, +dates, or ack state. + +The alternative — ship all announcements plus the ack list and filter in the +SPA — means the visibility rules exist in two languages, drift, and the +`showToNewUsers` and one-modal caps get re-implemented (differently) in +TypeScript. It also leaks announcements to users who were never targeted. + +Filter chain, in order: + +1. `state == "published"` (draft / scheduled / archived are excluded) +2. `publishAt <= now` and (`expiresAt` is null or `expiresAt > now`) +3. `targetRoles` intersects `user.roles`, or contains `"*"` +4. `showToNewUsers` is true, or `publishAt > user_profile.created_at` +5. no ack item at the current revision with `actionRank >= 2` +6. cap: all panel items, at most one banner, at most one modal (§D7) + +### D6 — New-user backfill suppression + +Rule: a user whose `created_at` is **after** an announcement's `publishAt` +does not see it, unless the announcement sets `showToNewUsers: true`. + +This is the single most common failure mode of announcement systems. Without +it, a user signing up eighteen months from now logs in for the first time and +is met with a queue of modals about features that have always existed from +their point of view. It is also self-inflicted in a way users read as +brokenness rather than as history. + +`showToNewUsers: true` exists for the real exception — a standing policy +notice ("AI output must be reviewed before use") that genuinely does apply to +everyone who ever joins. Those should be rare, and the admin form should say +so next to the checkbox. + +Fallback: if the user profile has no usable `created_at` (the repository +already heals malformed ISO strings on read), treat the user as **existing** +and show the announcement. Failing toward showing a message is the recoverable +direction; failing toward silence is not. + +### D7 — One modal, one banner, per response + +Even with §D6, an admin can publish three things in a week and a returning +user is eligible for all of them. Three stacked banners is a broken page; +three sequential modals is an ordeal. + +So the response caps the loud surfaces: + +- **banner**: at most one — highest `severity`, then oldest `publishAt`. +- **modal**: at most one — same ordering, with `requiresAck` items sorted + first so a blocking notice is never queued behind an informational one. + +Oldest-first drains the queue in the order things happened, and the rest stay +eligible for the next page load. The panel is uncapped — it is a list, and a +list of five is fine. + +**Fail-open dismissal.** If `POST /announcements/{id}/ack` fails, the client +hides the item for the tab session anyway and retries opportunistically. A +user trapped under a banner they cannot dismiss because of a transient 500 is +a worse outcome than an announcement that reappears tomorrow. + +### D8 — Never interrupt a turn + +The modal opens on route settle, and only when all of these hold: + +- no active stream — `messageMapService.isLoadingSession() === null` +- no pending tool-approval or OAuth-consent prompt +- the composer is not focused with a non-empty draft + +The second condition is not belt-and-braces. Per `docs/specs/ +mid-turn-steering.md` (#934), **`isLoading()` is `false` while a turn is +paused on an interrupt** — so a stream-only check would happily throw a modal +over an OAuth consent dialog and steal its focus. Check the +`tool-approval` and `oauth-consent` services directly. + +If the gate fails, do not queue the modal for later in the session — leave it +eligible and let it open on the next clean load. Deferred modals that fire +minutes later, mid-thought, are the worst version of this feature. + +### D9 — `targetRoles` is a filter, not a grant + +`targetRoles: list[str]` is matched against `User.roles`, with `"*"` meaning +everyone. It is stored on the announcement. + +CLAUDE.md's RBAC rule says a `granted*` list on the **AppRole record** is the +only thing access checks read, and that `allowedAppRoles` on a resource is a +derived, display-only projection. That rule exists because a role list +persisted on a *tool*, *model*, or *skill* silently grants nothing. + +**Announcements are outside that rule, and must stay outside it.** Visibility +of a notice is not access control: there is no capability being granted, no +`can_access_*` predicate, and nothing to inherit. Writing `targetRoles` +through to `AppRole.granted*` would put display metadata into the +access-decision path — strictly worse than the bug the rule prevents. + +Concretely: the announcement admin form's role picker writes **only** to the +announcement item, and `apis/shared/rbac/` is not modified by this feature. +Worth a comment on the field so a future reader does not "fix" it into the +role service. + +### D10 — Sanitized markdown, delegable scope + +Body content is markdown, rendered with `ngx-markdown` exactly as +`user-menu-link-modal.component.ts` does, so heading/list/link styling matches +assistant messages. + +But note what changes. `admin.user_menu_links` is held by a small number of +people; announcements are the kind of thing you *want* to delegate to +comms/enablement staff, and a broadcast surface authored by a wider group is a +stored-XSS target aimed at every user of the platform. + +Therefore: + +- `provideMarkdown` must run with sanitization on for this content — do + **not** enable `[disableSanitizer]` on the announcement renderer. +- The server validates `ctaUrl` with the same `http(s)`-only check + `user_menu_links` already applies (`_validate_http_url`), for the same + documented reason: Angular's `DomSanitizer` strips `javascript:` from + `[href]`, but anyone hitting the API with curl bypasses the SPA form. +- Body length is capped (16 KB) at the model layer. +- Writes are audit-logged through the existing `admin.audit` trail. + +### D11 — Feature flag + +`ANNOUNCEMENTS_ENABLED` in `apis/shared/feature_flags.py`, **default ON with a +kill switch** per house style — unset or empty resolves to enabled, only the +literal `"false"` disables: + +```python +def announcements_enabled() -> bool: + return os.environ.get("ANNOUNCEMENTS_ENABLED", "").strip().lower() != "false" +``` + +CDK threads `config.announcements.enabled` with the same empty-string-safe +ternary the other flags use, so an unset GitHub Actions variable cannot +silently disable the feature. While off, both routers 404 and the SPA renders +no surfaces. + +### D12 — No model-path impact + +Stated as a decision so it survives review: this feature adds nothing to the +system prompt, `toolConfig`, or conversation history; makes no model calls; +and is not read on the inference path. The read is two DynamoDB queries at SPA +bootstrap. Nothing here can move the cache-hit rate. + +--- + +## 5. Data model + +One new table, `-announcements`, added to `AdminTablesConstruct` +(`infrastructure/lib/constructs/data/admin-tables-construct.ts`) beside +`UserMenuLinksTable`. Generic `PK`/`SK` strings, `PAY_PER_REQUEST`, PITR on, +AWS-managed encryption — identical to its siblings — plus **TTL on `ttl`**, +which is why acks live here rather than in the user-settings table. + +### Announcement item + +``` +PK ANNOUNCEMENTS +SK ANNOUNCEMENT# +``` + +| Field | Type | Notes | +|---|---|---| +| `announcementId` | str | uuid4 | +| `title` | str | ≤ 140 chars; the banner line and panel heading | +| `bodyMarkdown` | str | ≤ 16 KB; panel and modal only | +| `summary` | str? | one line for the banner when `title` is too long | +| `surfaces` | list | subset of `panel` / `banner` / `modal`; `panel` forced on | +| `severity` | enum | `info` \| `success` \| `warning`; drives banner colour + ordering | +| `state` | enum | `draft` \| `scheduled` \| `published` \| `archived` | +| `publishAt` | iso8601 | when it becomes visible | +| `expiresAt` | iso8601? | **required** if `surfaces` includes banner or modal | +| `targetRoles` | list[str] | `["*"]` default. Display filter (§D9) | +| `showToNewUsers` | bool | default `false` (§D6) | +| `requiresAck` | bool | modal only; disables backdrop dismiss | +| `ctaLabel` / `ctaUrl` | str? | http(s) only, validated server-side | +| `revision` | int | starts at 1; bumping re-shows (§D4) | +| `createdAt` / `updatedAt` / `createdBy` | | mirrors `UserMenuLink` | + +Single fixed partition, queried by `PK = ANNOUNCEMENTS AND begins_with(SK, +"ANNOUNCEMENT#")`, exactly as `UserMenuLinksRepository.list_links` does. The +same "when per-org scoping is needed, the PK becomes `ANNOUNCEMENTS#`" +note applies. Volume is tens of items; no GSI, and an in-process TTL cache +(60s) on the published list is a reasonable later optimization, not a +requirement. + +### Acknowledgement item + +``` +PK USER# +SK ACK##R +``` + +| Field | Type | Notes | +|---|---|---| +| `announcementId`, `revision` | | denormalized for reporting | +| `action` | enum | `seen` \| `dismissed` \| `acknowledged` | +| `actionRank` | int | 1/2/3, monotonic guard (§D2) | +| `actionAt` | iso8601 | | +| `surface` | str | which surface it was acted on — tells you whether the modal or the panel is doing the work | +| `ttl` | int | `expiresAt` + 90 days, or `publishAt` + 2 years for open-ended items | + +Read: one `query` on `PK = USER# AND begins_with(SK, "ACK#")`. Bounded by +the number of announcements a user has ever interacted with — tens. + +TTL keeps that bounded forever without a sweeper. **Do not TTL an +`acknowledged` item for a `requiresAck` announcement** while the record has +compliance value; set `ttl` to null for those and let the archive path handle +them deliberately. + +--- + +## 6. API surface + +All routes are `app_api` — this is user-facing CRUD and has no business on +`inference-api` (CLAUDE.md's inference-api boundary: custom paths there are +unreachable through the AgentCore Runtime data plane). + +### User-facing — `apis/app_api/announcements/routes.py` + +`Depends(get_current_user_from_session)` on every route (cookie session, not +Bearer — CLAUDE.md's auth rule). + +``` +GET /announcements + → { panel: [...], banner: Announcement|null, modal: Announcement|null, + unreadCount: int } + Already filtered and capped per §D5/§D7. + +POST /announcements/{id}/ack + body: { action: "seen"|"dismissed"|"acknowledged", surface: str } + → 204. Idempotent; monotonic (§D2). 404 if the id is not visible to + this user — do not let an ack confirm the existence of an + announcement targeted at another role. +``` + +### Admin — `apis/app_api/admin/announcements/routes.py` + +Guarded by `require_admin_scope("admin.announcements")`, package-wide, per the +convention in `admin/routes.py` ("the permission boundary is the package +boundary", enforced by `tests/architecture/test_admin_scope_coverage.py`). + +``` +GET /admin/announcements list all states +POST /admin/announcements create (defaults to draft) +GET /admin/announcements/{id} +PATCH /admin/announcements/{id} body edits; revision unchanged +POST /admin/announcements/{id}/publish draft|scheduled → published +POST /admin/announcements/{id}/archive → archived (stops showing, keeps acks) +POST /admin/announcements/{id}/revise revision += 1 — "show this again" +DELETE /admin/announcements/{id} +GET /admin/announcements/{id}/stats { seen, dismissed, acknowledged, targeted } +``` + +`/stats` needs a count of acks across users, which the key shape does not +support directly. Options, cheapest first: (a) atomic counters on the +announcement item incremented on first ack per rank — approximate, O(1), +adequate for "did anyone read this"; (b) a GSI on `announcementId`; (c) +a scan behind a cache. **Start with (a).** Percentages need a denominator too; +`targeted` is an estimate from the user table filtered by `targetRoles`, and +should be labelled as an estimate in the UI. + +### New admin scope + +Add to `ADMIN_SCOPES` in `apis/shared/rbac/admin_scopes.py`: + +```python +AdminScope( + id="admin.announcements", + label="Announcements", + group=GROUP_CUSTOMIZATION, + description="Author and publish feature announcements shown to all users.", + delegable=True, +) +``` + +and to `ADMIN_SCOPE_IDS` in `frontend/.../admin/admin-scope.model.ts` — the +literal union is duplicated deliberately and `admin-scope.model.spec.ts` is +the reminder to keep both ends in step. + +Delegable, unlike `admin.roles` / `admin.auth_providers`: authoring a notice +does not confer admin power. It does confer a broadcast channel, which is +what §D10 is about. + +--- + +## 7. Frontend + +New service `services/announcements/announcements.service.ts` — signal-based +per house convention. Fetches once on bootstrap (after the session resolves, +so `roles` are known), exposes `panelItems()`, `bannerItem()`, `modalItem()`, +`unreadCount()`, and `ack(id, action, surface)` with the fail-open behaviour +from §D7. + +| Component | Location | Notes | +|---|---|---| +| Whats-new panel | `components/topnav/components/whats-new-panel/` | Dialog listing panel items newest-first, relative dates, **New** / **Updated** pills, markdown body. Opens from the user dropdown; unread dot on the avatar and the menu row. Mirrors `user-menu-link-modal`. | +| Announcement banner | `components/announcement-banner/` | Mounted by `chat-input` beside `quota-warning-banner`, floated `bottom-full` so dismissing it never moves the composer. `role="status"`, `aria-live="polite"`, severity colours from the `state-*` token scale, ✕ + optional CTA. Gated off in embedded panes via `[showAnnouncements]="false"`. | +| Announcement modal | `components/announcement-modal/` | `user-menu-link-modal` plus a primary ack button. When `requiresAck`, `appDialogDismiss` and the escape handler are disabled so the only exit is the button. | +| Admin list | `admin/manage-announcements/manage-announcements.page.ts` | Mirrors `manage-user-menu-links`. State chips, surface icons, ack counts, "Show again" action. | +| Admin form | `admin/manage-announcements/announcement-form.page.ts` | Title, markdown body with live preview, surface checkboxes, severity, schedule, role picker, `showToNewUsers` (with the §D6 warning text), `requiresAck`, CTA. | + +Admin nav entry under **Customization**, `data: { scope: 'admin.announcements' }`, +next to the existing user-menu-links entry. + +Accessibility, non-negotiable: unread dot needs a text alternative (`aria-label` +carrying the count, not colour alone); banner is `aria-live="polite"` and never +`assertive`; modal keeps the focus trap and returns focus on close; the panel +list is keyboard-navigable. + +--- + +## 8. Infrastructure + +1. `AnnouncementsTable` in `AdminTablesConstruct` — same shape as + `UserMenuLinksTable`, plus `timeToLiveAttribute: 'ttl'`. +2. SSM publication at `/${prefix}/admin/announcements-table-name`, matching + the sibling parameters. +3. `announcementsTable: dynamodb.ITable` threaded through + `PlatformComputeRefs` → `platform-stack.ts` → `app-api-environment.ts` + as `DYNAMODB_ANNOUNCEMENTS_TABLE_NAME`, and a read/write grant in + `app-api-iam-grants.ts` alongside the user-menu-links grant. +4. `ANNOUNCEMENTS_ENABLED` env var from `config.announcements.enabled`. +5. Add the table to the backup/restore list at `platform-stack.ts:1002`. + +No new GSI, so none of the GSI deploy-ordering hazards apply. + +--- + +## 9. PR breakdown + +Sequenced so each PR is independently shippable and the risky surfaces come +last. PR-1 through PR-3 deliver a complete, useful, zero-interruption feature. + +| PR | Scope | Notes | +|---|---|---| +| **PR-1** | Data model + repository + service + admin CRUD + admin scope + CDK table | No user-facing surface. Ships dark; admins can author drafts. | +| **PR-2** | `GET /announcements` + `POST .../ack` + `announcements.service.ts` + What's-new panel + unread dot | The durable surface. End-to-end value, nothing interrupts anyone. | +| **PR-3** | Admin list + form pages | Removes the "author by curl" step. Could merge into PR-1 if the form is small. | +| **PR-4** | Banner surface + severity ordering + one-banner cap | First interruptive-ish surface. | +| **PR-5** | Modal + `requiresAck` + the §D8 turn-safety gate | Highest-risk PR — everything that can annoy a user lives here. | +| **PR-6** | `/stats` + ack counters + admin reporting | Tells you whether any of this works. | + +Coach marks / spotlight tooltips anchored to specific UI elements are +explicitly **not** in this sequence — see §12. + +--- + +## 10. Testing + +Backend: + +- Visibility filter table-driven across state / dates / roles / new-user / + ack — this is where the logic is, so this is where the tests are. +- **Monotonic ack**: `seen` after `dismissed` leaves `dismissed` intact. Write + this one first; it is the §D2 regression. +- **Revision**: ack at R1, bump to R2, item becomes visible again; ack history + at R1 still readable. +- **New-user suppression**: user `created_at` after `publishAt` sees nothing; + `showToNewUsers` flips it; malformed `created_at` fails toward showing. +- **Caps**: five eligible announcements → 5 panel, 1 banner, 1 modal, with + `requiresAck` first. +- Ack on an id not visible to the caller → 404, not 204. +- Admin scope coverage picks up the new package automatically + (`test_admin_scope_coverage.py`); confirm it does rather than assuming. +- `ctaUrl` rejects `javascript:` at the API, not only in the form. +- Flag off → both routers 404. + +Frontend (`ng test`, never bare `npx vitest run`): + +- Service caps and fail-open dismissal (ack rejects → item still hides). +- Modal gate: does not open while `isLoadingSession()` is non-null, **and** + does not open while a tool-approval or OAuth-consent prompt is pending + (the #934 case). +- `requiresAck` modal ignores backdrop click and escape. +- Unread count clears on panel open. +- Use DI-token overrides rather than `vi.mock`, per house convention. + +--- + +## 11. Risks and open questions + +**Announcement fatigue is the real failure mode.** Every mechanism here is +sound and the feature still fails if admins publish a modal a week. Partial +mitigations: the one-modal cap, an `expiresAt` requirement on loud surfaces, +and `/stats` making low ack rates visible. The rest is a norm, not a control — +worth writing into the admin page's help text: *panel by default, banner when +it matters, modal when it is a policy change.* + +**`/stats` denominators are estimates.** "1,200 of ~4,000 targeted users +acknowledged" — the denominator moves as people join and roles change. Label +it as approximate; do not build compliance reporting on it without a real +targeted-user snapshot at publish time. + +**Open — should announcements be dismissible in bulk?** "Mark all as read" in +the panel is trivial to add and slightly undermines the ack signal. Suggest +shipping without it and adding it if users ask. + +**Open — per-org scoping.** The fixed `ANNOUNCEMENTS` partition assumes +single-tenant, as `user_menu_links` does. The PK shape leaves room; nothing +else in the design does. Fine to defer, worth not forgetting. + +**Open — email/out-of-band delivery.** Deliberately excluded (§12), but +`state == "published"` is the natural hook if it is ever wanted. + +--- + +## 12. Out of scope + +- **Coach marks / spotlight tooltips** anchored to specific controls. The + highest-value discovery mechanism and the highest-maintenance one: it needs + an anchor registry in the SPA, and every anchor is a latent breakage the + next time that component is refactored. Revisit once the basic system is + proven. +- **Email or out-of-band notification.** Different delivery guarantees, + different consent story, different infrastructure. +- **Per-user scheduling / drip campaigns.** This is an announcement system, + not a marketing automation platform. +- **Localization.** No i18n infrastructure exists in the SPA today; adding it + for this feature alone is the wrong entry point. +- **Rich media in bodies.** Markdown text and links only. Images mean an + upload path, storage, and a CSP conversation. diff --git a/docs/specs/gpt-5-6-prompt-caching.md b/docs/specs/gpt-5-6-prompt-caching.md new file mode 100644 index 000000000..b5cc98054 --- /dev/null +++ b/docs/specs/gpt-5-6-prompt-caching.md @@ -0,0 +1,838 @@ +# Plan: prompt caching for OpenAI GPT-5.6 on Bedrock + +**Status:** Shipped and VERIFIED LIVE 2026-09-05 — PR-1 (#945), PR-2 (#949), PR-5 (#951), PR-4 (#954, shipped OFF via #956) and the IAM fix (#959). Caching confirmed working end-to-end through the agent loop: warm turns cost 10.6x less than cold. PR-3 (catalog rates) is UNBLOCKED as of 2026-09-06: the rates are published on each model's card in the Bedrock User Guide — they are absent from the pricing APIs, which is what the earlier BLOCKED finding was actually measuring. All three dev GPT-5.6 rows were wrong and over-charged by exactly 20% (Terra and Luna carried GovCloud rates; Sol's output came from an inferred 6x ratio that is really 5x); corrected 2026-09-06T15:59Z. The tier/long-context modelling gap is resolved too: Priority and Flex are not supported for these models, and `maxInputTokens: 272000` pins us inside short-context pricing. +**Author:** (drafted with Claude) +**Date:** 2026-09-04 +**Related:** `agents/main_agent/core/model_config.py`, `agents/main_agent/core/agent_factory.py`, +`apis/shared/models/mantle.py`, `apis/shared/bedrock/bearer_token.py`, +`apis/shared/costs/calculator.py`, `apis/shared/observability/prompt_cache.py`, +`frontend/.../admin/manage-models/models/curated-models.ts`, +`infrastructure/lib/constructs/inference-api/inference-api-iam-roles.ts` + +## Summary + +GPT-5.6 (Sol / Terra / Luna) supports both implicit and explicit prompt caching on +Bedrock, with a 1.25× cache-write premium and a 90%-off read — the same economics +shape as our Claude models, and therefore subject to the same prompt-cache contract +in `CLAUDE.md`. + +None of our existing caching machinery reaches it. `CacheConfig(strategy="auto")` +and `cache_tools` are `BedrockModel` (Converse) features gated on Anthropic model +ids; GPT-5 is built as an `OpenAIResponsesModel`. **The auto cache config does not +apply and cannot be made to apply.** + +The decision this doc makes: **route GPT-5.6 over the Responses API on the +`bedrock-runtime` endpoint**, fix the provider-shaped accounting bugs that path +exposes, and only then add explicit cache breakpoints. + +## Background: caching is Responses-API-only + +Two generations behave differently, and the boundary is at 5.6: + +| | GPT-5.5 and earlier (incl. our curated `openai.gpt-5.4`) | GPT-5.6 Sol / Terra / Luna | +|---|---|---| +| Caching | Implicit only, automatic, no params | Implicit by default **+ explicit breakpoints** | +| Min prefix | 1,024 tokens | 1,024 tokens per breakpoint (max 4) | +| TTL | — | 30 min (`prompt_cache_options.ttl`, default `30m`) | +| Cache write fee | **None** — reads only | **1.25×** the input rate | +| Controls | none | `prompt_cache_breakpoint: {mode:"explicit"}` on content blocks, `prompt_cache_options.mode`, `prompt_cache_key` | +| Usage fields | `input_tokens_details.cached_tokens` | `cached_tokens` **and** `cache_write_tokens` | + +And the endpoint/API matrix for 5.6, per its model card: + +| | `bedrock-runtime` | `bedrock-mantle` | +|---|---|---| +| APIs | Responses ✅ · Chat Completions ✅ · **Converse ✅** · Invoke ❌ | Responses ✅ · Chat Completions ✅ · Converse ❌ | +| Prompt caching | ✅ **Responses API only** | ✅ **Responses API only** | +| Also gets | Guardrails (Converse only), application inference profiles (Converse only), invocation logs, CloudWatch, Cost Explorer itemization, Geo/Global CRIS | Server-side tool use, Projects, In-Region inference | +| Model id | `us.openai.gpt-5.6-sol` / `global.openai.gpt-5.6-sol` — **in-Region not offered here** | `openai.gpt-5.6-sol` | +| CountTokens | ❌ not supported | ❌ not supported | + +The trap is that Converse is the *tempting* path — it drops straight into our +existing `BedrockModel` plumbing with SigV4 and no new auth — and it is the only +path with **zero** prompt caching. At Sol's published in-Region rates ($4.40/MTok +input, $0.44 cache read), our ~30k-token stable prefix costs ~$0.13/turn on +Converse against ~$0.013 on a Responses cache hit. Over a session that dwarfs the +Converse conveniences. + +## Decision + +**Responses API on `bedrock-runtime`.** Rationale: + +1. Caching at all — the only reason this doc exists. Rules out Converse. +2. Versus Responses-on-Mantle: `bedrock-runtime` adds CRIS, invocation logging, + CloudWatch metrics, and Cost Explorer itemization, and Global CRIS is cheaper + ($4.00 vs $4.40 input for Sol short-context). We give up server-side tool use + (we don't use it) and In-Region inference (not available on that endpoint for + this model anyway). +3. It leaves the Mantle path untouched for `openai.gpt-5.4` and Gemma/Qwen, so + nothing already shipped moves. + +⚠️ **Pricing above is transcribed from the model card and must be re-verified +against the Price List API before any catalog row ships.** Note the direction is +*inverted* from our Claude rule of thumb: for GPT-5.6 the `global.*` profile is +the cheaper one, not the more expensive. Don't inherit the Claude assumption. + +## What Strands 1.51.0 gives us + +Gives us one thing: `input_tokens_details.cached_tokens` → `cacheReadInputTokens` +(`models/openai_responses.py:894`). Also useful: `config["params"]` is spread +verbatim into `responses.create()` (`:562`), so top-level request params pass +through with no SDK change. + +Does **not** give us (verified by grepping the installed package — zero hits): +`prompt_cache_breakpoint`, `prompt_cache_options`, `prompt_cache_key`, +`cache_write_tokens`. And `bedrock_mantle_config` hardcodes the Mantle host +(`models/_openai_bedrock.py:19`) while *rejecting* a caller-supplied `base_url` / +`api_key` when it is set (`models/openai_responses.py:184`) — so pointing at +`bedrock-runtime` means not using that config at all. + +## Work plan + +### PR-1 — Normalize OpenAI usage semantics (correctness; blocks the rest) ✅ SHIPPED (#945) + +The bug that bites the moment any GPT-5 turn runs, caching or not: + +OpenAI's `input_tokens` **includes** cached tokens. Bedrock Converse's +`inputTokens` **excludes** them. Strands passes `input_tokens` straight through as +`inputTokens` and reports `cacheReadInputTokens` alongside it. Our +`CostCalculator` documents and relies on the buckets being disjoint +(`apis/shared/costs/calculator.py:71`) and sums all three — so every cached token +is billed at the full input rate *and* the cache-read rate. The same assumption +is baked into the context-attribution sum at `stream_coordinator.py:725`. + +- Normalize on the OpenAI-family path before usage reaches the calculator: + `inputTokens -= (cacheReadInputTokens + cacheWriteInputTokens)`, clamped at 0. +- Map `cache_write_tokens` → `cacheWriteInputTokens` (Strands drops it; this is + the one field that makes 5.6's 1.25× premium visible at all). Upstream a patch + to `strands-agents` in parallel — we shouldn't carry this forever. +- Do it once, in a provider-aware shim, not at each of the several call sites that + read the usage dict. + +**Tests:** a usage-mapping unit test asserting the disjoint invariant per provider, +and a calculator test proving a fully-cached GPT-5.6 call costs +`cached × readRate`, not `cached × (inputRate + readRate)`. + +#### ⚠️ Correction: subtract the write bucket too + +The first draft of this section said `inputTokens -= cacheReadInputTokens`. That +was written when `cacheWriteInputTokens` was always 0 — Strands drops the field — +so only reads could double-count. The moment the bullet above maps +`cache_write_tokens`, the *same* double-count reappears for writes, and worse: at +`inputRate + 1.25×inputRate` rather than `inputRate + 0.1×inputRate`. + +AWS's GPT-5.6 prompt-caching guidance states the identity outright: + +``` +input_tokens = cached_tokens + cache_write_tokens + non-cached remainder +``` + +Both cache buckets are *inside* the inclusive total, so restoring disjointness +means subtracting both. #945 ships it that way; the bullet above is corrected to +match. + +#### What actually shipped + +`apis/shared/models/usage_normalization.py`: + +- `normalize_usage(usage, provider)` — Bedrock passes through untouched; OpenAI + gets both cache buckets subtracted out of `inputTokens`, clamped at 0. +- `openai_cache_write_tokens(usage_obj)` — reads + `input_tokens_details.cache_write_tokens` (also checks the top level, where some + OpenAI-compatible gateways hoist it). +- `usage_normalized(model_cls)` — memoized subclass applying both while the model + formats its `metadata` chunk. + +The seam is the **model class**, not any downstream usage reader: Strands destroys +`cache_write_tokens` inside its chunk formatter, so by the time usage reaches +`stream_processor._extract_usage_data` the field is unrecoverable. Installed at the +two OpenAI-family *construction* sites — `build_mantle_model` and +`AgentFactory._create_openai_model`. The two SDK classes disagree on the method +name (`OpenAIResponsesModel._format_chunk` is private, +`OpenAIModel.format_chunk` is public), which the shim resolves and pins with a +contract test. + +⚠️ **The convention follows the model family, not the adapter.** GPT-5.6 routed +over *Converse* on `bedrock-runtime` reports **disjoint** buckets — the Bedrock +convention — measured by a third party on +[strands-agents/harness-sdk#3546](https://github.com/strands-agents/harness-sdk/issues/3546). +Keying the shim off the OpenAI model class is correct for the Responses transport +PR-2 builds, but a Converse-routed OpenAI model must **not** be wrapped or its +input will be under-counted. Anything that adds an OpenAI model on the Converse +path has to opt out. + +**Upstream:** [harness-sdk#4193](https://github.com/strands-agents/harness-sdk/pull/4193) +maps the dropped `cache_write_tokens`. Note the prior art before proposing anything +broader: #3546 is an open umbrella bug for this exact convention split, and the +84-file fix for it (#3561) was closed unmerged — maintainers want small, +independently-reviewable PRs. A maintainer on that thread also notes AgentCore +GenAI Observability currently double-counts these tokens in its own cost display +and suggests pinning `strands-agents==1.53.0`; we are on 1.51.0. + +### PR-2 — `bedrock-runtime` Responses transport + +- New transport target alongside Mantle. Do **not** pass `bedrock_mantle_config`; + pass plain `client_args` with + `base_url="https://bedrock-runtime.{region}.amazonaws.com/openai/v1"` and an + `api_key` minted by `apis/shared/bedrock/bearer_token.py` (already produces + exactly the right credential). +- **Token refresh:** Mantle config re-mints per request; a static `api_key` in + `client_args` freezes at construction. Our microVMs live 18–50 min against a + 12-hour token cap so it would work by luck — instead override + `_resolve_client_args()` (called per request) to mint fresh. A handful of lines, + and it removes a class of "worked in dev, expired in prod" failure. +- **Model id:** requests must name `us.` or `global.` prefixed inference profiles. + Reuse the existing region/profile plumbing rather than string-munging ids at the + call site. +- **IAM:** `bedrock-runtime` OpenAI access additionally requires + `bedrock:InvokeModel` on the account's **default project ARN** + (`arn:aws:bedrock:{region}:{account}:project/default`) on top of the inference + profile. Not present in `inference-api-iam-roles.ts` today — this is a + `platform.yml` deploy, so sequence it ahead of the backend change. + +#### ⚠️ CORRECTED TWICE — the real IAM gap was a different action + +**The `InvokeModel` half of that bullet was already satisfied.** Simulated against +the deployed dev roles with `iam simulate-principal-policy`: + +| action | resource | runtime role | +|---|---|---| +| `bedrock:InvokeModel` | `inference-profile/us.openai.gpt-5.6-sol` | allowed | +| `bedrock:InvokeModel` | `project/default` | allowed | +| `bedrock:InvokeModelWithResponseStream` | inference profile | allowed | +| **`bedrock:CallWithBearerToken`** | any | **implicitDeny** | + +The account-scoped `arn:aws:bedrock:{region}:{account}:*` resource on the existing +`BedrockModelInvocation` statement already matches `project/default`, so PR-2 +shipped without an IAM change on that basis — correctly, as far as it went. + +**What PR-2 missed is the bearer token itself.** The `bedrock-runtime` +OpenAI-compatible endpoint authenticates with the same short-term token +construction as Mantle, but authorizes it under a *different IAM service +namespace*: `bedrock:CallWithBearerToken`, not +`bedrock-mantle:CallWithBearerToken`. Only the Mantle one was granted, so the +first real turn failed: + +``` +401 ... is not authorized to perform: bedrock:CallWithBearerToken on resource: * +because no identity-based policy allows the action +``` + +Both roles need it — the AgentCore runtime role (agent loop) and the app-api task +role (`/chat/api-converse`). **This IS a `platform.yml` deploy**, and it must land +before the transport can serve a single turn. + +Why no earlier step caught it: unit tests construct the model but never issue a +request, and the `probe_gpt56_cache_rates.py` run that verified PR-1/2/5 executed +under a developer's SSO credentials, which carry far broader permissions than the +runtime role. Only an end-to-end turn through the deployed agent exercises the +actual principal. Worth remembering as a general shape — a transport that +authenticates differently from its neighbours needs its IAM verified against the +*deployed role*, not inferred from the neighbour's grants. + +**Boundary check:** this is model-transport code, so it belongs in +`apis/shared/models/` next to `mantle.py`, consumed by both `agent_factory` and the +API-key `/chat/api-converse` handler. Don't fork the build logic. + +### PR-3 — Catalog entry and pricing + +> **SHIPPED 2026-09-06.** `CURATED_BEDROCK_RESPONSES_MODELS` carries Sol, Terra +> and Luna at their published Geo CRIS short-context rates, behind a new +> "Bedrock Responses" catalog tab. `supportsCaching` is pinned true and +> `maxInputTokens` to 272,000 by test, because both are pricing correctness +> rather than preference. The curated `openai.gpt-5.4` Mantle entry was fixed in +> the same pass — it inherited `mantleDefaults()`' `supportsCaching: false` and +> so one-click-created exactly the mis-priced row that had to be repaired by +> hand in prod. `supportedParams` is deliberately omitted: AWS publishes no +> parameter table for GPT-5.6, and a declared spec flips the #915 guard +> restrictive, so a guess would silently block valid params. +> +> The original plan below is kept for the reasoning; where it conflicts with +> the RESOLVED section, the RESOLVED section is right. + +- Add GPT-5.6 to `CURATED_MANTLE_MODELS`' sibling set (or a new + `CURATED_RUNTIME_OPENAI_MODELS` if the transport field warrants a separate tab). +- `supportsCaching: true`, plus verified `cacheReadPricePerMillionTokens` and + `cacheWritePricePerMillionTokens`. Note `mantleDefaults()` hardcodes + `supportsCaching: false` (`curated-models.ts:233`) — GPT-5.6 must not inherit it. +- For `openai.gpt-5.4` / 5.5, if we keep them: `supportsCaching: true` with + `cacheWritePricePerMillionTokens: 0`. That is correct (no write fee) and + conveniently makes `compute_wasted_usd` see a non-positive premium and return + $0 rather than inventing waste. +- Re-verify every rate against the Price List API, per the ⚠️ above. + +#### ✅ RESOLVED 2026-09-06 — the rates were published all along, in the model cards + +Everything below this section was written while looking for these rates in +*pricing APIs*. They are not there, and that finding stands. But they are +published, in prose, on each model's card in the Bedrock User Guide: + +- [GPT-5.6 Sol](https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-openai-gpt-56-sol.html) +- [GPT-5.6 Terra](https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-openai-gpt-56-terra.html) +- [GPT-5.6 Luna](https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-openai-gpt-56-luna.html) +- [GPT-5.4](https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-openai-gpt-54.html) + +**Published rates, Geo CRIS, Short Context (272K)** — Geo CRIS is the row that +applies to the `us.*` inference profiles we actually call. All figures $/MTok. + +| model | input | 30m cache write | cache read | output | +|---|---:|---:|---:|---:| +| `us.openai.gpt-5.6-sol` | 4.40 | 5.50 | 0.44 | 22.00 | +| `us.openai.gpt-5.6-terra` | 2.20 | 2.75 | 0.22 | 13.20 | +| `us.openai.gpt-5.6-luna` | 0.22 | 0.275 | 0.022 | 1.32 | +| `openai.gpt-5.4` (Mantle, In-Region) | 2.75 | — (no write fee) | 0.275 | 16.50 | + +**Every dev row was wrong, and every error over-charged by exactly 20%.** +Corrected in the dev catalog 2026-09-06T15:59Z: + +| model | field | was | now | +|---|---|---:|---:| +| sol | output | 26.40 | **22.00** | +| terra | input / output / cache read / cache write | 2.64 / 15.84 / 0.264 / 3.30 | **2.20 / 13.20 / 0.22 / 2.75** | +| luna | input / output / cache read / cache write | 0.264 / 1.584 / 0.0264 / 0.33 | **0.22 / 1.32 / 0.022 / 0.275** | + +The `1.2x` is not a coincidence: Terra and Luna had been sourced wholesale from +the **GovCloud** Price List rows, which are exactly 1.2x commercial. Sol's +output was the one number with no source at all — it came from a `6x` input +ratio inferred from GovCloud, and the real ratio is `5x`. `openai.gpt-5.4` was +already correct, including the empty cache-write cell. + +**The modelling gap is resolved, not merely downgraded.** + +- **Service tiers do not apply.** Every card states it outright: *"Priority and + Flex tiers are not supported for this model."* Only Standard exists, so the + 0.5x/2x tier dimension `CuratedModel` cannot represent is not a dimension for + these models at all. +- **Long context is real, and the earlier "2x twin" was wrong.** The threshold + is 272K (the models' own window is 1M). Above it, **input is 2x but output is + only 1.5x** — Sol 4.40→8.80 and 22.00→33.00, and the same 2x/1.5x split holds + for Terra and Luna. A flat 2x assumption would have over-priced long-context + output by a third. +- **We do not reach it.** All four rows carry `maxInputTokens: 272000`, pinned + at the short-context boundary, and compaction runs at 100K. Short-context + rates are therefore the correct single rate for our traffic, and the cap is + what keeps that true — do not raise it without also modelling the second + price card. + +**For prod: use Global CRIS.** `global.openai.gpt-5.6-*` prices **9.1% below** +the `us.*` Geo CRIS rates across every bucket (Sol 4.00 / 5.00 / 0.40 / 20.00). +Prod already runs Claude on `global.*`; dev cannot, because of the dev-only SCP. +So the prod rows should be `global.*` ids with the Global CRIS rate card — not +copies of the dev rows. + +**What this means for the empirical work below.** It is no longer the source of +truth, but it is not wasted: it is now the *audit* of these published numbers, +and the tooling fixes it produced (the 1000x unit bug, daily-vs-monthly, the +attribution guard) are what make that audit trustworthy. The 2026-09-06 Sol +window still reads on schedule; it should now reproduce 4.40 / 0.44 / 5.50 / +22.00 rather than discover them. + +**Process lesson.** The search was run entirely against pricing *APIs* — Price +List, then Marketplace Catalog — and concluded "no source exists" without ever +checking the model's own documentation page. Check the model card first; it is +the primary source for rates, caching support, context windows, service tiers, +and endpoint/API support, and it is where AWS documents all of them together. + +#### ⛔ SUPERSEDED — the Price List API does not publish these rates + +Checked 2026-09-05 against dev-ai (490617140655) with SSO credentials, across +every Bedrock service code: + +| Service code | GPT-5.6 coverage | +|---|---| +| `AmazonBedrock` | `openai.gpt-5.6-terra` + `-luna` only, **`us-gov-west-1` only**, and only `-mantle-` usage types. **`sol` absent entirely.** | +| `AmazonBedrockService` | `provider` attribute values are `Anthropic` and `Luma AI` only — no OpenAI | +| `AmazonBedrockFoundationModels` | no GPT entries in `servicename` | +| `AmazonBedrockAgentCore` | AgentCore consumption SKUs, not foundation-model pricing | + +**Commercial-region rates for the GPT-5.6 family are not in the Price List API +at all.** Neither are commercial `openai.gpt-5.4` rates — so the shipped +`$2.75 / $16.50` row on that model was never Price-List-verified either; it +came from the model card. This PR's own gate therefore cannot be satisfied +today, and PR-3 is deferred rather than shipped on transcribed numbers. + +Options when it is picked back up, in preference order: + +1. Wait for AWS to publish commercial rates and verify as specced. +2. Derive them empirically — add the model through the admin escape-hatch + form (the PR-2 transport already supports it), drive real turns via the + dev-ai experiment harness, and reconcile against Cost Explorer to back out + per-token rates. Stronger evidence than a published table, but Cost + Explorer lags ~24h. +3. Ship model-card rates explicitly labelled unverified, in code and in the + PR. Last resort: these rows price real spend against faculty quotas. + + +#### 2026-09-06 — Option 2 attempted: what Cost Explorer can and cannot say + +Option 2 was run against dev-ai. It is viable, but only under a constraint the +plan above did not anticipate, and it closed off Option 1 in the process. + +⚠️ **The conclusion this paragraph originally drew was wrong — see the RESOLVED +section above.** What holds: these models bill through **AWS Marketplace**, the +Price List API has no Marketplace service code (all 269 enumerated 2026-09-06), +and the Marketplace Catalog API is seller-side and returns nothing for a +subscriber. What does not hold is the inference drawn from that — "therefore no +source exists, derive them empirically." The rates were published in the model +cards the whole time. Absence from an API is not absence from the docs. + +**Cost Explorer has the dollars, but names no model.** Usage types look like +`USW2-MP:USW2_cache_read_tokens_standard-Units` — they carry the token bucket +and the service tier, never the model id. Every OpenAI-family model in the +account shares the same four rows. There is no finer dimension: checked +`USAGE_TYPE` grouped by `OPERATION` (all `InvokeModelStreamingInference`) and +by `BILLING_ENTITY` (all `AWS Marketplace`). + +**Therefore a rate is attributable only on a single-model day.** That is the +method, and it works — dev has near-zero organic OpenAI traffic, so clean days +are easy to claim. `probe_gpt56_cache_rates.py --rates-only --table ` now prints which models we recorded that day and refuses to vouch for a +number when more than one OpenAI-family model ran. + +Two traps, both hit and both now guarded in the script: + +- **Read it DAILY, not monthly.** Daily rows come back as exact round numbers; + a multi-day window silently blends models into a meaningless average. August + shows two distinct price cards — `$5.50 / $27.50` and `$2.20 / $11.00` — and + 2026-08-31 is visibly a blend of the two (`$4.3780` input). A monthly read + would have reported that blend as if it were a rate. +- **The unit is `1M tokens`, not `1K`.** Cost Explorer declares it in the + `Unit` field, and it differs by billing path: Marketplace rows are `1M + tokens`, natively-billed rows (Nova, Titan, Mantle-served models) are `1K + tokens`. The script previously assumed 1K for everything, which overstated + every Marketplace rate by 1000x. It now reads the declared unit. + +**The cache ratios hold, independently confirmed.** On every clean day, in both +price cards, cache read is exactly `0.1x` input and cache write exactly +`1.25x`. This is commercial-region billing data, and it corroborates the +GovCloud ratio finding below from a completely different source. + +**Bearing on the modelling gap.** Every row observed is `_standard` and no +`-long-ctx` usage type has ever appeared in this account. The model cards +explain why: Priority and Flex are *not supported* for these models, so +`_standard` is the only tier that can appear; and our `maxInputTokens: 272000` +keeps every request inside the short-context price card. The billing data and +the cards agree. + +**Controlled window claimed: 2026-09-06, `us.openai.gpt-5.6-sol` only.** Dev +had zero recorded model calls that day before the probe. Expected totals, to be +divided into that day's Cost Explorer dollars: + +| bucket | tokens | +|---|---| +| `inputTokens` | 2,660 | +| `cacheReadInputTokens` | 17,496 | +| `cacheWriteInputTokens` | 5,868 | +| `outputTokens` | 50 | + +Two arms produced these: an 8,000-token prefix over 4 turns for the cache +buckets, and a 600-token prefix over 6 turns — deliberately under the +1024-token minimum cacheable prefix, so nothing caches and input tokens +accumulate as the rate anchor. Read it once Marketplace settles (allow 24-48h, +not 24h) and Terra and Luna need their own single-model days. + +**What the read was expected to settle.** The dev rows carried, at the time this was written (all since corrected — see the RESOLVED section above): + +| model | input | output | cache read | cache write | +|---|---:|---:|---:|---:| +| `us.openai.gpt-5.6-sol` | 4.40 | 26.40 | 0.44 | 5.50 | +| `us.openai.gpt-5.6-terra` | 2.64 | 15.84 | 0.264 | 3.30 | +| `us.openai.gpt-5.6-luna` | 0.264 | 1.584 | 0.0264 | 0.33 | + +The cache columns are not the risk — they are `0.1x` and `1.25x` of input, now +confirmed from two independent sources. The risk is concentrated in two places: + +- **Every output rate is a guess.** They are `6x` input, a ratio taken from the + GovCloud Terra row. Commercial daily billing shows both price cards running + at `1:5`, not `1:6` — so if GPT-5.6 also prices at `1:5`, Sol's output rate is + overstated by 20%. Output is the largest per-token number in each row. +- **Sol's input rate has no source at all.** Terra and Luna at least descend + from GovCloud Price List rows; `sol` is absent from the Price List entirely, + so `4.40` came from the model card. + +Both were settled by the model cards instead (Sol output is `22.00`, not `26.40`; every Terra and Luna figure was a GovCloud rate). The single-model day now serves as an audit of the published numbers rather than the source of them. + +What the GovCloud rows *do* establish, and can be relied on: + +- The **ratios are exact**. Terra standard: input `2.64`, cache read `0.264` + (0.1x), cache write `3.30` (1.25x). The cache economics this whole plan + rests on are confirmed. +- The **30-minute TTL is confirmed** by the SKU naming itself — the write + usage types are `...-cache-write-tokens-30m-...`. That is the corroboration + PR-5 needed. + +#### ⚠️ Modeling gap PR-3 must resolve before it ships + +The Price List rows carry two pricing dimensions `CuratedModel` cannot +represent: + +- **Service tiers.** Every model is priced at `flex` / `standard` / `priority` + = 0.5x / 1x / 2x. +- **Long context.** Every rate has a `-long-ctx` twin at **2x** the base. + +Our catalog holds one flat rate per bucket, so a GPT-5.6 row would mis-price +any long-context turn by 2x — silently, as a plausible-looking number rather +than an error, which is exactly the failure the Verification section exists to +catch. Decide before curating: either model the dimensions, or scope the row +to standard-tier short-context and gate on staying inside it. + +### PR-4 — Explicit cache breakpoints + `prompt_cache_key` ⛔ SHIPPED OFF (measured pessimization) + +Only worth building for 5.6, where the 1.25× write premium makes placement matter. + +- `prompt_cache_key` is free — it rides `config["params"]` through the existing + spread. Key it off fingerprints we already compute: + `f"{systemPromptHash}:{toolConfigHash}"`, so requests sharing a prefix route to + the same cache and a config change rotates the key by construction. +- Explicit breakpoints need a `BedrockResponsesModel(OpenAIResponsesModel)` + subclass overriding `format_request` to stamp + `prompt_cache_breakpoint: {mode: "explicit"}` on the last stable content block + (after tool definitions and the system/developer message, before conversation + history), plus `prompt_cache_options: {mode: "explicit", ttl: "30m"}`. +- This buys us the Claude-path resilience we already depend on: a message-level + miss costs a *read* of the ~30k static prefix instead of a full re-write. +- The existing prompt-cache contract carries over unchanged — implicit and + explicit caching are both exact-prefix, so deterministic tool/skill ordering and + the truncation anchor in `TurnBasedSessionManager` remain load-bearing. + +#### Two corrections from building it + +**`prompt_cache_key` does not ride `config["params"]`.** It is a first-class +parameter on the OpenAI SDK's `responses.create`, so it is set directly on the +formatted request. `prompt_cache_options` is *not* a named parameter and has to +travel in `extra_body`. Both are set in the `_format_request` override rather +than at model construction, because that is the only seam where the system +prompt and tool specs — the inputs the key is derived from — are actually in +scope. + +**There is no "system/developer message" to stamp in Strands' output.** It emits +the system prompt as the top-level `instructions` *string*, and a breakpoint has +to sit on a content **block**. So the override re-expresses `instructions` as the +`developer` message AWS's guidance shows, at the head of `input`, carrying the +breakpoint: + +```python +{"type": "message", "role": "developer", + "content": [{"type": "input_text", "text": ..., + "prompt_cache_breakpoint": {"mode": "explicit"}}]} +``` + +That places the boundary exactly at the end of the static prefix (tools + +system), before any history. Stamping `input[0]`'s existing block instead would +have put the first user message inside the cached segment, so compaction — which +rewrites history — would invalidate the tools+system segment too. + +#### Risk this PR carries + +Explicit mode **opts out of** the model's default implicit caching. A badly +placed boundary is therefore worse than not switching at all. Two mitigations: + +- With no system prompt there is no static prefix to bound, so the request is + left untouched and implicit caching stays on. +- Kill switch `BEDROCK_RESPONSES_EXPLICIT_CACHE_ENABLED` (default ON). + ⚠️ Deliberately **not** wired into the CDK Runtime construct: that construct is + at the `AWS::BedrockAgentCore::Runtime` 50-variable cap, and a 51st entry fails + changeset validation after CI is green. Flipping it in a deployed environment + needs an out-of-band Runtime update. + +#### ⛔ Measured live — the premise was wrong, so this ships OFF + +Run 2026-09-05 against `us.openai.gpt-5.6-sol` (dev-ai, us-west-2) via +`backend/scripts/probe_gpt56_cache_rates.py --mode both --grow-history`: +8k-token static prefix, 5 turns, ~1.5k tokens of history growth per turn. + +| | uncached input | cacheRead | cacheWrite | input-equivalents | +|----------|---------------:|----------:|-----------:|------------------:| +| explicit | **22,790** | 23,228 | 5,807 | **32,372** | +| implicit | **10** | 38,410 | 13,405 | **20,607** | + +(Input-equivalents price the buckets at the Price List ratios this same +investigation confirmed: input 1×, cache read 0.1×, cache write 1.25×.) + +**Explicit cost ~57% more.** Per turn, explicit's uncached input grew +1,516 → 7,600 while its `cacheRead` stayed flat at 5,807; implicit held +uncached input at 2/turn and let `cacheRead` grow with the conversation. + +The reasoning above had the *counterfactual* wrong. Implicit caching does not +re-write history when it grows — it appends the delta (measured: 1,521 +cacheWrite per turn). So a breakpoint after the static prefix saves no +re-write; it only stops the history being cached at all, and the cost of that +grows linearly with conversation length. + +The code ships behind `BEDROCK_RESPONSES_EXPLICIT_CACHE_ENABLED`, **default +OFF**, so the production path is byte-identical to stock Strands. It is kept +rather than deleted because the *placement*, not the mechanism, is what failed +— the API allows up to 4 breakpoints, and a scheme that also marks the end of +history might beat implicit. **Do not re-enable without re-running that probe +and beating the implicit arm.** + +Untested idea worth its own measurement: `prompt_cache_key` is currently tied +to the explicit path, so it is off too. Applying it on the implicit path is +plausibly free and helps cross-request routing, but that is a fleet-level +effect a single-session probe cannot show — measure before shipping it. + +### PR-5 — Observability + +- ✅ **DONE.** `CACHE_TTL_SECONDS = 300` was wrong by 6× for OpenAI's + 30-minute TTL, so `classify_cache_status` called `miss_ttl_expired` on + entries that were still live. The TTL is now model-derived via + `cache_ttl_seconds_for(provider, model_id)`, threaded through + `classify_cache_status(ttl_seconds=...)` from the serving model's + `ModelInfo`. Only `bedrock-responses` gets the 30-minute window — + deliberately **not** the whole OpenAI family, since `mantle`'s + implicit-only caching has no documented 30m retention and guessing there + would over-report waste. `CACHE_TTL_SECONDS` stays as the default for + existing importers. + + Note the error direction, which is why this mattered: a TTL shorter than + the model's real one *hides* waste. `partial_miss` is gated on + `gap <= ttl`, so it degraded to `hit`; `miss_avoidable` degraded to + `miss_ttl_expired`. Both zero `wastedUsd` — the metric that exists to + catch this exact class of bug. +- Now that PR-1 has landed `cacheWriteInputTokens`, `partial_miss` / `miss_avoidable` / + `wastedUsd` start working for GPT-5.6 as they do for Claude. Until it lands, + every call classifies as `hit` or `uncached` and `wastedUsd` is structurally + $0 — a silent blind spot, which is exactly the failure mode that let the + compaction spiral run unnoticed. + +## Known non-blocking issues + +- **`use_native_token_count`** is set unconditionally on the Bedrock path in + `to_bedrock_config`, and CountTokens is unsupported for GPT-5.6. Strands + degrades to the chars/4 heuristic and caches the skip, so it costs one failed + call per model — but the code comment asserting "Every catalog model is Claude + family and supports the API" stops being true, and context attribution quietly + loses its authoritative counts. Fix the comment; consider gating the flag. +- **Auto-strategy warning noise.** If a GPT model is registered under the Bedrock + provider with `caching_enabled`, Strands logs "does not support automatic + caching" every turn. Harmless — `bedrock_cache_points_supported()` + (`model_config.py:329`) already prevents the tools/system cachePoints that would + otherwise be a `ValidationException` — but worth suppressing. +- **Structured outputs and server-side tool use** are unsupported for GPT-5.6 on + `bedrock-runtime`. Neither is on the agent path today; confirm before any + feature starts depending on them for this model. + +## ✅ VERIFIED END-TO-END — 2026-09-05, dev, through the agent loop + +Session `f76ab27a-51a6-4f70-be93-e94c81e01d85`, `us.openai.gpt-5.6-sol` selected in +the SPA model picker, four turns, read back from +`GET /admin/costs/sessions/{id}/calls`: + +| turn | cacheStatus | input | cacheRead | cacheWrite | output | cost | +|---|---|---:|---:|---:|---:|---:| +| 1 | `first_write` | 2 | 0 | 3,679 | 5 | $0.02038 | +| 2 | `hit` | 2 | 3,679 | 21 | 6 | $0.00190 | +| 3 | `hit` | 2 | 3,700 | 22 | 6 | $0.00192 | +| 4 | `hit` | 2 | 3,722 | 22 | 6 | $0.00193 | + +**Warm turns cost 10.6x less than the cold turn.** Every prediction this plan +made holds, and each PR is confirmed by a specific column: + +- **PR-1 — usage normalization.** `inputTokens` is **2** on every turn, not + ~3,681. OpenAI reports `input_tokens` inclusive of both cache buckets; the + buckets here are disjoint and sum exactly to the prefix + (2+0+3,679 = 3,681; 2+3,679+21 = 3,702 — matching the SPA's context-window + readout to the token). `cacheWriteInputTokens` is **populated at all**, which + is only possible because we recover `cache_write_tokens` — Strands drops it. + Turn 1 alone would otherwise have double-billed 3,679 tokens at the input + rate *plus* the 1.25x premium, which is precisely the correction #947 made to + this spec. +- **PR-2 — transport.** Reached a live model: base URL, per-request bearer + token, inference-profile id all correct. +- **PR-5 — model-derived TTL.** Gaps of 30s / 78s / 18s classified `hit`, and + `wastedUsd = $0.00` on every row with no avoidable re-writes. +- **#956 — explicit caching OFF.** This is the implicit shape the probe + predicted: `cacheRead` **grows with the conversation** (3,679 -> 3,700 -> + 3,722) while `cacheWrite` is just the appended delta (~21). Under explicit + mode `cacheRead` would be flat and uncached input would climb every turn. + The 57%-worse finding is confirmed in the agent loop, not just at the + transport. +- **#959 — IAM.** `bedrock:CallWithBearerToken`, without which none of the + above could run. + +Prefix fingerprints were stable exactly where they should be: `toolConfigHash` +`8eafb0765ed810a2` and `systemPromptHash` `5a71749b3bee37ab` identical across +all four calls, `historyHash` changing each turn. + +The cost math reproduces to the cent from the disjoint buckets and the +catalog rates, so `CostCalculator` is verified against live usage as well. + +⚠️ The **dollar amounts are provisional** — they use the model-card rates +seeded on the dev row (4.40 in / 26.40 out / 0.44 cache read / 5.50 cache +write), and 26.40 is *derived* from the GovCloud 1:6 input:output ratio rather +than published. The **ratios** above are real; the absolute dollars wait on +PR-3. + +## ⚠️ `global.*` inference profiles are blocked **in dev** by an organization SCP + +Discovered 2026-09-05 while adding GPT-5.6 Luna. Adding it as +`global.openai.gpt-5.6-luna` fails at the first turn: + +``` +401 ... not authorized to perform: bedrock:InvokeModel on resource: +arn:aws:bedrock:::foundation-model/openai.gpt-5.6-luna +with an explicit deny in a service control policy: +arn:aws:organizations::977099011063:policy/o-09d6ih8vwl/service_control_policy/p-r61tynkc +``` + +Editing that same row to `us.openai.gpt-5.6-luna` — same model, same account, +same region, **only the profile prefix changed** — succeeds. That isolates the +prefix as the cause: the deny is on the **region-less foundation-model ARN** +that a Global CRIS profile resolves to, not on the model. + +### Scope: dev only — **prod is not affected** + +Confirmed by Phil: `global.*` is **not** blocked in prod, which is the account +that matters for the cost model. The Decision section's preference for +`bedrock-runtime` on Global CRIS pricing ($4.00 vs $4.40 input for Sol +short-context) therefore **stands** — the ~9% discount is available where the +spend is. + +What this actually costs us is a **dev/prod model-id divergence**: a GPT-5.6 +row must be `us.*` in dev and can be `global.*` in prod. Anything that copies a +model row between environments — a seed script, a curated catalog entry, a +runbook — has to carry the prefix per environment rather than assume one id +works in both. + +⚠️ **Method note for whoever hits this next.** `aws iam +simulate-principal-policy` does **not** evaluate SCPs, only identity policies. +Every simulation of the runtime role came back `allowed` while the real call +was denied. An SCP deny is only observable by actually invoking, which is also +why this surfaced at the first turn rather than in any earlier check. + +Note the direction is the **opposite** of the Claude-family rule of thumb, +where `us.*` costs ~10% *more* than `global.*`. + +## ⚠️ Mantle's `openai.gpt-5.4` caches, and was priced at $0.00 + +Measured live 2026-09-05, two turns: + +| turn | status | input | cacheRead | cost | +|---|---|---:|---:|---:| +| 1 | `uncached` | 3,681 | 0 | $0.01021 | +| 2 | `hit` | 60 | **3,642** | $0.000264 | + +Turn 2's cost reproduces exactly from input + output alone, so the 3,642 cached +tokens contributed nothing — a ~5.5x under-report on that turn, on a model in +daily use. The Price List confirms the model has a cache-read SKU ($0.33 +GovCloud) and **no cache-write SKU**, exactly as PR-3 predicted. + +Root cause was the admin form, not the data: the caching block was gated to +`bedrock` / `bedrock-responses`, so for a Mantle model the checkbox and the +cache-rate fields were never rendered and the row could only carry the +provider default of `false`. Fixed in #963 by adding +`CACHING_CAPABLE_PROVIDERS` (wider than the defaults list — Mantle stays off by +default, it just becomes selectable). + +### The dashboard was wrong in BOTH directions + +`Cost Analytics` computes `savings = cacheRead x (inputPrice - cacheReadPrice)` +per message from the pricing snapshot +(`app_api/sessions/services/metadata.py:257`). With `cacheReadPricePerMtok` +absent it reads as `0`, so the same missing rate produced two compounding +errors on `gpt-5.4`: + +- **cost understated** — cached tokens priced at $0.00 +- **savings overstated** — credited as a *100%* saving rather than 90% + +So the model looked cheaper than it was *and* more efficient than it was, and +it carried ~5x the traffic of any GPT-5.6 row ($0.38 vs $0.07 in the window). +The GPT-5.6 rows were unaffected: they carry both rates, so their savings were +correct from the start. + +### ✅ Fixed 2026-09-05 (dev) + +Set through the admin UI once #963 deployed: `supportsCaching: true`, +`cacheReadPricePerMillionTokens: 0.275` (0.1x our catalog's $2.75 input, +matching the family ratio), `cacheWritePricePerMillionTokens: 0` (the Price +List has no cache-write SKU for this model). + +Re-measured on an identical turn shape — 60 input, 3,642 cacheRead, 6 output: + +| | cost | +|---|---:| +| before | $0.000264 | +| after | **$0.0012655** | + +Reproduces exactly as `60x2.75 + 6x16.50 + 3,642x0.275` per MTok, so the +cached tokens now contribute $0.001002 instead of nothing — 4.8x more accurate +on that turn. + +### ✅ PROD fixed 2026-09-05T17:41Z + +Prod had exactly the same shape — `supportsCaching: False`, no cache-read rate, +on an **enabled** model that seven assistants hard-bind. Now set to +`supportsCaching: true` / `cacheRead 0.275` / `cacheWrite 0` and verified on +the record. + +Done through `PUT /api/admin/managed-models/{id}` rather than the admin form, +because #963 is a frontend change that has not reached prod — the backend has +always accepted these fields for Mantle. ⚠️ That endpoint enforces double-submit +CSRF: the `__Host-bff_csrf` cookie is JS-readable and must be echoed in +`X-CSRF-Token`, or it 403s. + +⚠️ **History is not corrected.** Pricing snapshots are captured per message at +write time, so prod turns before 17:41Z keep their $0.00 cache cost and +inflated savings. Any gpt-5.4 cost figure quoted for an earlier period is wrong +in both directions. + +⚠️ Deleting the row was considered and **rejected**: seven prod assistants +hard-bind it through `modelConfig: {modelId, provider}` and the `staff` role +grants it explicitly, so removing it would strand them. + +## Verification + +Prove the cost impact rather than assuming it — per the cost-effectiveness tenet: + +1. Drive real turns via the dev-ai experiment harness against a GPT-5.6 model with + a long stable prefix (system prompt + full tool config). +2. Read the session's `C#` rows: `cacheStatus`, `cacheReadInputTokens`, + `cacheWriteInputTokens`, and the fingerprint hashes. A working config shows + turn 1 `first_write`, turns 2+ `hit` with a `cacheRead` that tracks the prefix + and a near-zero `cacheWrite`. +3. Cross-check `GET /admin/costs/sessions/{id}/calls` against the model card rates + by hand for at least one turn — this is the step that catches a + double-counted-input regression, which looks like a plausible number rather + than an error. +4. Compare a Converse-routed control session to confirm the caching delta is real + and roughly the magnitude estimated above. + +## Out of scope + +- Migrating `openai.gpt-5.4` off Mantle. It works, it's implicit-only, and it has + no write fee; PR-1 and PR-3 fix its accounting where it sits. +- Guardrails for GPT-5.6 (Converse-only, and we're deliberately not on Converse). +- Chat Completions on either endpoint — no caching support, no reason. + +## Live verification, 2026-09-05 + +First end-to-end exercise of this work against a real model, via +`backend/scripts/probe_gpt56_cache_rates.py` on `us.openai.gpt-5.6-sol` +(dev-ai, us-west-2). The probe calls the transport directly — it touches no +shared catalog row, no RBAC, and not the agent loop — so it could run while +PR-3 is still blocked. + +| Step | Verdict | +|---|---| +| PR-1 usage normalization | ✅ buckets disjoint on every turn of every run | +| PR-2 bedrock-runtime transport | ✅ reached the model; base URL, per-request bearer token and inference-profile id all correct | +| PR-4 explicit breakpoints | ⛔ ~57% more expensive than implicit — shipped OFF | +| PR-5 model-derived TTL | ✅ warm turns read the prefix, so the 30m window is the one that matters | + +**The finding that mattered.** Turn 1 of the first run reported +`inputTokens=2, cacheWriteInputTokens=1,446`. Un-normalized, OpenAI reports +`input_tokens=1448` — inclusive of the write bucket. That is PR-1's +cache-write subtraction proving itself on live data: without it that turn +double-bills 1,446 tokens at the input rate *plus* the 1.25× write premium. +It also confirms the `cache_write_tokens` recovery works, since Strands drops +the field and the bucket would otherwise read 0. + +**Rates.** The runs consumed ~190k tokens on usage types nothing else in dev +uses, so Cost Explorer attribution is unambiguous. Recover the rates with: + + AWS_PROFILE=dev-ai uv run python scripts/probe_gpt56_cache_rates.py \ + --rates-only --since 2026-09-05 + +⚠️ Confirm the usage *unit* before trusting the derived $/MTok — Bedrock token +usage types are reported in 1K-token units, and the script's conversion assumes +that. Cross-check one row against the token totals the run printed. diff --git a/docs/specs/mid-turn-steering.md b/docs/specs/mid-turn-steering.md new file mode 100644 index 000000000..18ad4ae5c --- /dev/null +++ b/docs/specs/mid-turn-steering.md @@ -0,0 +1,425 @@ +# Mid-turn steering + +**Status:** SHIPPED and validated end to end on dev (2026-09-04). Risk 2 answered: the model reads and obeys the injection. +**Follow-up to:** PR #916 (`feature/queue-followup-instead-of-interrupt`) +**Refs:** `docs/kaizen/reviews/2026-08-28.md` proposal #5 + +## Problem + +PR #916 made Enter mean "say this" while a response is streaming: the follow-up +is queued in the composer and flushed on the turn's falling edge. That fixed the +destructive part of the old behaviour (Enter routed to Stop, killing a run the +user was waiting on) but it leaves the follow-up sitting until the whole turn +finishes. A user who sees the agent open the wrong file, search the wrong index, +or start down a plainly wrong path still has exactly two options: + +1. **Wait.** Pay for the rest of a turn whose direction is already known to be + wrong, then correct it on the next one. +2. **Stop and resend.** Discard a partially generated turn, and re-establish + against a prefix that the abandoned tail has now changed. + +Both are pure waste, and (2) is the more expensive of the two — the pattern the +cost-effectiveness tenet exists to catch. What the user wants is what every +mature agent harness does: the follow-up lands at the **next tool boundary**, so +the agent reads it before choosing its next action. + +The scope note on #916 said this "needs the backend to accept an injection into +a running turn, which it has no path for today." That is true of the *request* +path — `/invocations` is one-shot and the Runtime data plane proxies nothing +else — but both halves of the mechanism already exist in this codebase for other +reasons. They have simply never been connected. + +## What already exists + +**A side channel into a running turn.** Stop already does this. `request_session_cancel` +stamps `cancelRequestedFor` on the session's single-flight lease row +(`apis/shared/sessions/session_lease.py:298`); the container running the turn +observes it on its next heartbeat (`_heartbeat_session_lease`, +`apis/inference_api/chat/routes.py:145`) and flips `session_manager.cancelled`. +The arming endpoint is app-api's `POST /sessions/{id}/interrupt` +(`apis/app_api/sessions/routes.py:644`). The hard part — an owner-scoped, +cross-container signal that survives the Runtime routing the arming request to a +different container than the one streaming — is built, in production, and +already proven by the Stop path. + +**An injection point in the agent loop.** Strands 1.51 fires `AfterToolsEvent` +with the assembled tool-result message **before** it is appended to history: + +``` +strands/event_loop/event_loop.py:857 after_tools_event = AfterToolsEvent(agent=agent, message=tool_result_message, ...) +strands/event_loop/event_loop.py:886 await agent._append_messages(tool_result_message) +``` + +A hook that appends a `{"text": ...}` block to `event.message["content"]` puts +the user's words into the same user-role message that carries the tool results. +That is a valid Bedrock Converse shape, it persists through the normal +`append_message` path (`turn_based_session_manager.py:125`), and it is +append-only against the cached prefix. + +Async hook callbacks are supported (`HookRegistry.invoke_callbacks_async` awaits +coroutine callbacks), so the hook can read the inbox itself rather than waiting +on the 10 s heartbeat. + +## Decision summary + +| Question | Decision | +|----------|----------| +| Transport into the running turn | **Lease-row inbox** — same item, same owner-scoping, as `cancelRequestedFor` | +| Arming endpoint | **app-api** `POST /sessions/{id}/steer` (mirrors `/interrupt`) | +| Injection point | **`AfterToolsEvent`** — append a text block to the tool-result message | +| Injection granularity | **Next tool boundary.** Not mid-generation, not per-token | +| Pickup latency | **Read in the hook** (one GetItem per tool boundary), heartbeat as backstop | +| Consumption semantics | **Commit-on-append** — the inbox entry is cleared only once the message is actually in history | +| Turns with no tool call | **Fall back to #916's end-of-turn flush.** Both paths stay | +| Client contract | New SSE event **`steering_applied`** acknowledges the injection | +| Feature flag | `MID_TURN_STEERING_ENABLED`, **default on with a kill switch** (house style) | +| Failure posture | **Fail-soft** — any error degrades to #916 behaviour, never drops the user's text | + +## Design + +### D1 — Transport: the lease row is the inbox + +`POST /sessions/{id}/steer` on app-api, body `{"text": "...", "clientId": ""}`. +It reads the lease item's current `leaseOwner` and conditionally writes: + +``` +SET steerFor = :owner, steerQueue = list_append(if_not_exists(steerQueue, :empty), :entries) +ConditionExpression: leaseOwner = :owner +``` + +Each entry is `{"id": clientId, "text": text, "at": iso8601}`. + +- **Owner-scoped, exactly like cancel.** If the turn ended between the read and + the write, the condition fails, the endpoint returns `409`, and the SPA falls + back to sending the message as a normal turn. That race is the *correct* + outcome, not an error to paper over. +- **No new item, no new GC.** `release_session_lease` deletes the whole lease + row at turn end, so an unconsumed inbox cannot outlive its turn. +- **Why app-api, not inference-api.** The Runtime data plane proxies only + `/invocations` and `/ping`; a steer route on inference-api would 404 in cloud. + Same reasoning that put `/interrupt` on app-api. + +Size: composer text is small and the row is deleted per turn, so the 400 KB item +limit is not a live concern. Cap the queue at a small N (say 5 entries / 8 KB +total) and reject beyond it, so a pathological client cannot grow the row. + +### D2 — Injection: `AfterToolsEvent`, not the alternatives + +A new `SteeringHook` registered in `BaseAgent._create_hooks` +(`agents/main_agent/base_agent.py:285`) alongside `StopHook`: + +```python +async def _inject(self, event: AfterToolsEvent) -> None: + if not event.message["content"]: # nothing committed this batch + return + pending = await self._inbox.peek() # does NOT consume + if not pending: + return + event.message["content"].append({"text": _wrap(pending)}) +``` + +`_wrap` frames the injection so the model reads it as the user speaking, e.g. + +``` + +{text} + +``` + +Alternatives considered: + +- **Interrupt / resume** (`BeforeToolCallEvent` → `event.interrupt(...)`, SPA + resumes with the steering text as the interrupt response). Reuses fully + sanctioned machinery — the same path OAuth consent and tool approval take — + and is the safest option on paper. Rejected as the primary design because it + costs a stream teardown, a re-invocation, and a `PausedTurnSnapshot` rebuild + *per steer*, on a path whose whole point is being faster than waiting for the + turn to end. Kept as the documented fallback if D2 proves unstable (see Risks). +- **Mutating `agent.messages` directly from a `BeforeModelCallEvent` hook.** + Racier and less well-defined: the tool-result message is already committed by + then, so the injection becomes a second user message, breaking the + user/assistant alternation Bedrock expects after a `toolUse` turn. +- **A synthetic tool the model can call to check for user input.** Costs a + `toolConfig` entry on every turn for every user, forever — exactly the kind of + always-on prefix growth the cost tenet forbids — and only fires when the model + chooses to look. + +**SDK-boundary caveat.** `HookEvent.__setattr__` is write-guarded by +`_can_write`, which for `AfterToolsEvent` allows only `end_turn`. Mutating the +message dict *in place* is not blocked, but it is also not explicitly sanctioned. +Pin `strands-agents` (already exact-pinned per house rules) and add a contract +test that asserts the mutation still reaches `agent.messages`, in the same spirit +as `tests/agents/main_agent/core/test_bedrock_cache_points.py`. That test is the +canary for an SDK bump; the interrupt/resume variant is the escape hatch. + +### D3 — Consumption: commit-on-append, never commit-on-read + +`AfterToolsEvent` fires from a `finally` block, so it also fires on the cancel, +error, and **interrupt** paths. On the interrupt path `_stop_for_interrupts` +runs and `agent._append_messages(tool_result_message)` is **never reached** — the +message the hook just mutated is discarded and the turn pauses. + +A hook that deletes the inbox entry when it reads it therefore destroys the +user's message on every steer that happens to land on the same tool batch as an +OAuth consent or an approval prompt. Silent data loss, low frequency, very hard +to reproduce. + +So: the hook **peeks**. The entry is cleared only when the message is confirmed +in history — on the `MessageAddedEvent` for that same message object, which is +the first point at which the injection is real. The clear is a conditional +DynamoDB write scoped to both the lease owner and the entry id, so a re-delivery +after a lost ack is idempotent rather than duplicated. + +If the turn ends (or is cancelled) with entries still in the inbox, the row is +deleted with the lease and the SPA's un-acked queue entries flush the #916 way. +Fail-soft in every direction: the user's text is either injected exactly once or +sent as a normal turn — never both, never neither. + +### D4 — Latency: read in the hook, heartbeat as backstop + +The heartbeat polls every 10 s (`LEASE_HEARTBEAT_SECONDS`). Riding it alone +would mean a steer typed 1 s after a tool started could miss that tool boundary +and several after it. + +Instead the hook does its own `GetItem` at each tool boundary (async callbacks +are supported), which is a single-digit-millisecond read against a hot key, +bounded by the number of tool boundaries in a turn — negligible next to the tool +call that just ran. The heartbeat additionally caches "an inbox exists" on the +session manager, so the hook's read can be skipped entirely for the overwhelming +majority of turns where nobody is steering: + +- heartbeat sees `steerFor == owner` → set `session_manager.steering_pending = True` +- hook reads the inbox only when that flag is set, **or** when the SPA's steer + POST is newer than the last heartbeat (a cheap always-read for the first N + boundaries after the arming write is not observable — simplest correct version + is: read when the flag is set, and have the arming endpoint's `409`/`204` + answer tell the SPA whether it landed). + +Start with the unconditional per-boundary read (simple, provably correct) and +only add the flag gate if the read shows up in latency telemetry. + +### D5 — No tool boundary, no steering + +A pure-text turn fires no `AfterToolsEvent`. #916's end-of-turn flush therefore +stays permanently as the fallback, and both behaviours coexist. This is a +product constraint, not a temporary one — every harness with mid-turn steering +has it. + +Consequence for the UI: the composer cannot promise "this goes in mid-turn" +unconditionally. The placeholder introduced in #916 becomes conditional on +whether a tool is currently running: + +- tool running → "Send a follow-up — it goes in at the next step" +- otherwise → "Send a follow-up — it goes out when this response finishes" + +The SPA already knows which, from the live `tool_use` / `tool_result` events. + +### D6 — Client contract: `steering_applied` + +New SSE event, added to the table in `CLAUDE.md`: + +| Event | Purpose | +|-------|---------| +| `steering_applied` | A queued follow-up was injected into the running turn at a tool boundary — payload `{type, sessionId, entryId, text}`. Emitted from the stream coordinator once the mutated tool-result message is committed to history (never on the cancel/interrupt path, where the message is discarded). The SPA drops the matching entry from the composer queue and renders it as a user message in the live thread. Gated by `MID_TURN_STEERING_ENABLED` | + +Emitted after the `message` / `tool_result` events for that batch, so the thread +renders in the order the model will see. Added to the SPA parser's event switch +(`stream-parser-core.ts`) next to `compaction` and `session_title`. + +### D7 — Feature flag + +`mid_turn_steering_enabled()` in `apis/shared/feature_flags.py`, **default on +with a kill switch** (`MID_TURN_STEERING_ENABLED=false` to opt out), matching +`SKILLS_ENABLED` / `SCHEDULED_RUNS_ENABLED`. Watch the empty-string +workflow-variable case the house pattern already guards for. + +While off: the hook is registered but returns immediately, the steer endpoint +returns `404`, and the SPA never POSTs — #916 behaviour exactly. + +## Prompt-cache and cost analysis + +Required by the cost-effectiveness tenet: *what does this add to the prompt, on +every turn, for the life of every session?* + +**Nothing, on a turn with no steering.** No `toolConfig` entry, no system-prompt +text, no extra history. The hook is inert. + +**On a turn with steering:** one text block appended to a user message that was +about to be written anyway. The three cachePoints are tools-tail, system-tail, +and the `strategy="auto"` message-level point at the last user message +(`core/model_config.py:365`). The injection lands *inside* the segment that +point covers, behind both static points — it is append-only against the cached +prefix, so the next model call still reads the stable ~28 k-token prefix from +cache. No prefix rewrite, no `partial_miss`. + +**Net effect is a saving, not a cost.** The behaviour it replaces is Stop + +resend: a discarded partial generation, an abandoned tail that changes history, +and a re-established prefix. Measure it the way the tenet says to — compare +`cacheStatus` and `wastedUsd` on sessions that steer against sessions that +stop-and-resend, via `GET /admin/costs/sessions/{id}/calls`. + +## Persistence, history repair, and multi-agent safety + +- **Reload survival is free.** The steering text rides inside the persisted + tool-result message, so it is in AgentCore Memory and comes back on restore + with no separate hydration path (unlike pending interrupts). +- **History repair.** `_drop_abandoned_turn_tail` and `_repair_tool_pairing` now + see a user message with mixed `toolResult` + `text` content. Neither should + care — both key on tool pairing — but this needs explicit coverage in + `agents/main_agent/session/tests/test_history_repair.py`, because a repair + helper that strips the message strips the user's words with it. +- **Compaction.** The injected block is deterministic and append-only, so the + byte-stability contract holds. Confirm the truncation anchor still lands on a + message boundary when the anchored message is a mixed one. +- **One session, more than one agent.** Per the CLAUDE.md rule, an `@`-mention + turn runs a second `Agent` with its own session manager. The inbox must not be + cached on an agent instance: it is read per tool boundary and cleared by + conditional write, both owner-scoped to the lease. Two agents cannot both + consume an entry, because the lease has exactly one owner. +- **Cancel beats steering.** If `cancelRequestedFor` and `steerFor` are both set + for our owner, the cancel wins and the inbox is left alone — the SPA's queue + entry survives the stop and the user can resend it. +- **Paused turns.** A steer that arrives while the turn is paused for OAuth + consent or tool approval has no running loop to receive it. The resume request + goes through `/invocations`, so the resume path can simply carry the pending + entries in its payload and prepend them to the resumed turn. Phase 3. + +## Frontend changes + +#916 already owns the queue (`chat-input.component.ts`, `queuedMessages`). The +changes are additive: + +1. On queueing while a tool is running, POST to `/sessions/{id}/steer` and mark + the entry **pending-ack** (still visible, still removable — removal also + DELETEs the inbox entry). +2. On `steering_applied` with a matching `entryId`, drop the entry from the + queue and let the message-list render it as a user message inside the turn. +3. On the turn's falling edge, any entry that is still un-acked flushes exactly + the way it does today. This is the whole fallback: the edge-triggered effect + from #916 is unchanged, it just sees a shorter list. +4. A `409` from the steer endpoint (turn already ended) leaves the entry queued + for that same falling edge — no toast, no error state. +5. Conditional placeholder per D5. + +Turn grouping (`message-list.component.ts:360`) starts a new group on every +user-role message. Confirm how a mid-turn user bubble renders inside a turn that +is still streaming — this is the one piece of visual design work in the change. + +## PR breakdown + +| PR | Scope | Status | +|----|-------|--------| +| 1 | `session_lease` inbox helpers (`request_session_steer`, `peek_steer_queue`, `clear_steer_entry`, `remove_steer_entry`) + unit tests. No callers. | **built** | +| 2 | `SteeringHook` + registration + commit-on-append clearing + the SDK contract test. Behind the flag, no client path yet. | **built** | +| 3 | app-api `POST /sessions/{id}/steer` + `DELETE .../steer/{entryId}`, `get_current_user_from_session` auth per the house rule. | **built** | +| 4 | `steering_applied` SSE event: coordinator emit, parser case, CLAUDE.md table row. | **built** | +| 5 | SPA: pending-ack queue state, POST/DELETE wiring, conditional placeholder, mid-turn user bubble rendering. | **built** | +| 6 | Paused-turn carry-through on the resume path (D "Paused turns"). Optional, ships after the rest is live in dev. | **built + verified live** | + +Three defects surfaced only by driving it against dev, none by reading it: + +* **#930** — a steer rendered ~36 times live. `syncStreamingMessages` truncates + to the last user message; a steer is a user message that does not start a + turn, so it became its own truncation point and re-appended per sync tick. + Persisted history was always correct, so nothing but counting live bubbles + would have caught it. +* **#934** — PR-6's hold was unreachable. A pause CLOSES the stream, so + `isChatLoading` is false while the prompt waits; `onSubmit` gated the queue on + loading alone and Enter went straight to a new turn, abandoning the paused + one. The tests here queued while streaming and then paused — a real user types + *after* the pause. +* **#935** — the steer bubble had its own tint on top of the caption. Two + signals for one distinction; it now uses the standard user bubble. + +**Reproducing a paused turn:** dev's `sk_hello_approval` (live MCP server, no +OAuth, `say_hello` flagged `needsApproval`) gives a real Strands pause that +resumes on one Approve click, no credentials. The `localhost:8026` MCP-stub +recipe is stale — `canvas_faculty` moved to a Lambda URL. + +Four implementation notes worth carrying forward: + +* **The ack drain runs before each SSE event is yielded, not after.** An + injection confirmed on the turn's *final* tool batch would otherwise be + stranded behind `done`, which the SPA's stream-state gate drops. +* **`_repair_tool_pairing` needed a fix, not just coverage.** It rebuilds + result turns from the toolUseId map rather than copying them, so it dropped + every non-toolResult block — the injection included. It now carries the + residual across exactly once. The concern the spec raised as "confirm this is + fine" was real. +* **The paused-turn case needed a client behaviour change, not just carriage.** + The spec framed PR-6 as "the resume path can carry the pending entries", but + the resume never got the chance: a pause closes the SSE stream, so + `isChatLoading` falls and PR #916's falling edge fires the follow-up as a + *new* turn — abandoning the paused turn the user is mid-answer on, and racing + the resume that follows into the single-flight guard. So the composer now + **holds** its queue while a resumable prompt is awaiting an answer, and the + resume carries it. The hold is bounded by an action the user already has: + dismissing the prompt clears it and the ordinary flush runs. +* **Carrying is seeding, not prepending.** The resume prompt is Strands' + interrupt-response list, and text in it would stop `_is_interrupt_resume_prompt` + recognising it as a resume at all. `/invocations` seeds the carried entries + onto the lease it just acquired instead, so the ordinary `SteeringHook` + injects them at the resumed turn's first tool boundary — one injection path + and one ack path however an entry arrived. This made an existing latent bug + load-bearing: `acquire_session_lease` cleared the cancel markers but not the + steering inbox, and seeding stamps `steerFor` to the *new* owner, which would + have made a previous turn's leftovers visible and injected them into a turn + they were never meant for. Acquire now clears the inbox too. +* **"Reload survival is free" was half true.** The text does come back with no + separate hydration path, but it comes back *raw*: a user message of + `[toolResult…, text]` whose text is still wrapped in the tags written for the + model, and which turn grouping would treat as the start of a new turn. The + SPA normalizes both on load (`normalizeSteeringMessages`) so a reloaded + steered turn reads exactly as it did live. + +## Testing + +- **Backend unit.** Hook injects into `event.message["content"]`; does *not* + clear the inbox on read; clears on `MessageAddedEvent`; no-ops on an empty + tool-result batch; no-op when the flag is off. +- **Backend integration (the one that matters).** A turn that interrupts on the + same tool batch as a steer must leave the entry in the inbox. Build it on the + local MCP stub harness that produces a genuinely paused turn — a mocked + interrupt proves nothing here. +- **Lease.** Steer against an ended turn fails the owner condition; steer from a + different user is a no-op; cancel + steer armed together resolves to cancel. +- **Prompt cache.** Assert the cachePoint positions are unchanged with a mixed + message present (extend `test_bedrock_cache_points.py`). +- **History repair.** Mixed `toolResult` + `text` message survives + `_repair_tool_pairing` and `_drop_abandoned_turn_tail` intact. +- **SPA.** Ack drops exactly the matching entry; no ack → falling-edge flush + still fires once (the #916 edge-trigger test must keep passing); `409` leaves + the entry queued; removal DELETEs. +- **Manual, in dev.** A long tool-using turn, steered once at boundary 1 and + once at boundary 3, then reloaded — both injections present in restored + history, in order. + +## Risks and open questions + +1. **In-place mutation of a write-guarded event.** The main technical risk. + Mitigated by the exact pin, the contract test, and the interrupt/resume + escape hatch. Re-check on every `strands-agents` bump. +2. **Model compliance.** A steering block appended after tool results is a + less-conventional shape than a fresh user turn; the model may under-weight it. + Needs a real evaluation in dev before the flag defaults on in prod — this is + the item most likely to change the design. +3. **Two behaviours, one affordance.** Users will not reliably predict whether a + follow-up lands mid-turn or at the end. D5's conditional placeholder is the + mitigation; whether it is enough is a product question. +4. **Steering into a tool batch that is about to fail.** The injection lands + next to error tool results. Probably fine — it is exactly when a user is most + likely to steer — but worth watching in the dev evaluation. +5. **Quota and cost attribution.** A steered turn is longer than an unsteered + one and its cost lands on a single `C#` row set. No change needed, but the + session-notice thresholds will fire slightly differently. + +## Out of scope + +- Steering mid-generation (between tokens, with no tool boundary). Requires + aborting and re-issuing the model call; strictly more expensive than waiting + for the turn to end. +- Editing or retracting a message the agent has already read. +- Steering another user's session, or steering from a second device. The lease + is user-scoped; cross-device steering would need its own design. +- Queueing steers across turns (an entry that misses its turn is sent as a + normal turn, per D3). diff --git a/docs/specs/response-feedback.md b/docs/specs/response-feedback.md new file mode 100644 index 000000000..8768952fa --- /dev/null +++ b/docs/specs/response-feedback.md @@ -0,0 +1,322 @@ +# Response feedback + +**Status:** PROPOSED — no code. Written 2026-09-04 from the "how would we +benefit?" conversation. +**Refs:** `docs/specs/agentcore-evaluations-spike-findings.md` (the eval +harness this feeds), `docs/specs/mid-turn-steering.md` (the injection path +Phase 1 reuses), `docs/specs/agent-marketplace.md` D15 (the *other* feedback +channel — see §3), `docs/kaizen/research/2026-08-28.md` (built-in skill +evaluators) + +## 1. Problem + +Two commented-out lines have been sitting in the codebase for a long time: + +``` +backend/src/apis/shared/sessions/models.py:645 # Note: Feedback will be added in future implementation +backend/src/apis/shared/sessions/models.py:646 # feedback: Optional[Feedback] = None +backend/src/apis/app_api/messages/models.py:132 # Note: Feedback will be added in future implementation +backend/src/apis/app_api/messages/models.py:133 # feedback: Optional[Feedback] = None +``` + +Nothing was ever built behind them. The question this spec answers is not "can +we add a thumb" — that is an afternoon — but **what a thumb is worth here, and +what shape makes it worth that.** + +The honest starting position is that thumbs data is usually near-worthless. +Coverage runs 1–5% of turns, skews negative, and a single down-thumb conflates +"factually wrong", "ignored my instructions", "far too long", and "the MCP tool +500'd and the model apologised". Anyone treating the raw rate as a KPI is +reading noise. + +What changes the calculus here is the **join surface this platform already +has, and the one axis it is missing.** + +Every model call already writes a `C#` row carrying model id, attribution, +cost breakdown, token usage, `cacheStatus`, `cacheGapSeconds`, `wastedUsd`, +and `agentSwitched`. There is an admin anatomy endpoint over it +(`GET /admin/costs/sessions/{id}/calls`), EMF metrics, a fleet dashboard, and +a five-workstream cost-effectiveness roadmap. **Cost is instrumented to three +decimal places. Quality is not instrumented at all.** + +CLAUDE.md's cost tenet states: *"When cost and answer quality genuinely +conflict, quality wins."* Today there is no instrument that could ever detect +that conflict. Every compaction tuning decision, every model downgrade, every +context-offload threshold has been made against a cost number with quality +asserted rather than measured. A thumb is the cheapest possible counterweight. + +## 2. The thesis + +> **Feedback is a sampler and a label. It is not a metric.** + +Three consequences follow, and they drive every decision below: + +1. **As a sampler:** a down-thumb marks the small subset of turns worth + spending expensive LLM-judge tokens on. The Evaluations spike proved the + judging pipeline works end to end today, but judging is per-trace and + costs real money — it cannot run fleet-wide. Human-marked failures are the + priority queue that makes it affordable. +2. **As a label:** joined against the config dimensions already stamped on + every call, a down-thumb answers questions currently unanswerable — does + quality actually drop after compaction? is Sonnet worth 3× Haiku on *this* + workload? Relative comparison between config arms, never absolute rates. +3. **Never as a metric:** the absolute rate over a self-selected 3% means + nothing. It must not appear on any leadership dashboard as a quality score. + §9 makes this a hard rule, not a caution. + +## 3. What this is NOT — de-conflicting with agent reports + +Marketplace Phase 8 (D15) already shipped a feedback channel, and it is a +different object. `AgentReport` (`apis/shared/assistants/models.py:1621`) is +agent-scoped, free-text, identity-bearing, and lands in a **moderation queue** +sorted by `REPORT_REASON_SEVERITY`. `ReportReason` deliberately includes +`suggestion` so that a user who does not know whether they hit a defect or a +missing capability is not asked to pick the right intake form. It surfaces as +`app-agent-feedback-link` at the foot of a conversation +(`message-list.component.html`, `showFeedbackLink()`), published agents only. + +Response feedback is **per-message, fixed-set, high-volume, and analytical.** +It has no queue and no moderator. Nobody reads an individual down-thumb. + +| | Agent report (D15, shipped) | Response feedback (this spec) | +|---|---|---| +| Scope | One published Agent | One assistant message | +| Volume | Rare, hand-written | Frequent, one click | +| Consumer | A human admin, one at a time | Aggregation + the eval harness | +| Identity | `reporterId` shown to admin, never to author | Same rule (§8) | +| Surface | Foot of conversation, published agents only | Every assistant message | + +**They must not be merged, and they must not both shout.** The conversation +tail can hold one report link and per-message thumbs without collision, but the +down-thumb reason sheet must include an escape hatch — *"this is a problem with +the Agent itself"* — that hands off to the existing report dialog rather than +inventing a second moderation path. That handoff is the only coupling. + +## 4. What already exists + +Almost every mechanism this needs is built for another reason. + +**A per-message row family keyed the right way.** `sessions-metadata` already +holds two SK prefixes under `PK = USER#`: `C#` cost rows +(`SK C#{timestamp}#{uuid}`, `GSI_SK C#{timestamp}`, carrying `messageId`) and +`D#` display rows (`SK D#{session_id}#{message_id}`, `GSI_SK D#{message_id}`). +The `D#` row is the closer model: deterministic key, one row per message, no +collision uuid. A feedback row is a third prefix in a table that already +supports it, with a `GSI_PK = SESSION#` read path that already exists. + +**A reader that already unions prefixes.** `get_session_cost_anatomy` +(`admin/costs/routes.py:183`) queries `C#` and `D#` in parallel over +`SessionLookupIndex` (`metadata.py:1987`). Adding `F#` is a third leg of a +query that is already fan-out shaped. + +**A verified evaluation pipeline.** Per the Evaluations spike: 16 built-in +`ACTIVE` evaluators, `EvaluationClient.run()` returning scored results with +explanations quoting real dev conversation text, and — critically — session +correlation that needs no new plumbing, since +`runtime_session_id_for()` (`apis/shared/harness/runner.py:63`) is +`sid-`. A feedback row carrying `sessionId` + +`messageId` is already joinable to the spans. + +**Built-in skill evaluators.** `Builtin.SkillSelectionAccuracy` and +`Builtin.SkillInstructionFollowing` (2026-08-28 research) target the exact +failure mode the Skills v2 epic left unmeasured. They need a sampling signal +to be affordable. + +**An injection path for the retry loop.** `POST /sessions/{id}/steer` +(`app_api/sessions/routes.py:741`) already accepts a correction into a turn, +gated by `mid_turn_steering_enabled()`. "Retry with this correction" is the +same shape aimed at a finished turn instead of a running one. + +**A place to put the buttons.** `message-actions.component.ts` already renders +Copy and Continue under every assistant message. Thumbs belong in that rail, +not in a new component. + +**A precedent for attaching conversation content to feedback.** +`report_service.file_report` verifies an attached `sessionId` belongs to the +reporter before storing it. Reuse that check verbatim. + +## 5. Decision summary + +| Question | Decision | +|---|---| +| Storage | Third SK prefix `F#` on `sessions-metadata`, **never** on the message itself | +| Key | `SK F#{session_id}#{message_id}`, `GSI_SK F#{message_id}` — deterministic, idempotent upsert | +| Mutability | Overwrite in place. Changing or clearing your mind is a normal act, not an audit event | +| Signal set | `up` / `down` + optional fixed-set reason + optional free text | +| Reason set | Fixed and small (§6). Free text is secondary and never required | +| Prompt cache | Feedback is a pure side-channel write. It never enters conversation history, `toolConfig`, or the system prompt | +| Consequence | Phase 1 ships a **visible** consequence (retry-with-correction), not a suggestion box | +| Implicit signals | Captured in the same row family, same phase. They are denser than thumbs and cost no UI | +| LLM-judged signals | Offline batch over persisted turns only. Never an inline per-turn model call | +| Reporting | Relative comparison between config arms. Absolute rate is never published as a quality score | +| Identity | Stored. Visible to admins with the scope; never to an agent author | +| Flag | `RESPONSE_FEEDBACK_ENABLED`, default on with a kill switch (house convention) | + +## 6. The signal set + +Down-thumb opens a chip row. One tap, no typing, dismissible: + +- **Wrong or made up** → routes to `Correctness` / `Faithfulness` evaluators +- **Didn't follow my instructions** → `InstructionFollowing` +- **Too long / too short** → a style signal, and a Memory Spaces input (§7) +- **A tool or search failed** → **ops, not model.** Auto-corroborated against + the turn's `tool_result` blocks +- **Out of date** → KB freshness; joins to `kb_sync` state +- **Something else** → free text + +The mapping to evaluators is the point of a fixed set. An unbucketed +down-thumb is noise; a bucketed one is a routing decision. + +Up-thumb takes no reason. Asking for one on a positive signal collapses the +response rate and buys almost nothing. + +## 7. What it unlocks + +### Platform + +**Negative-sample mining.** Down-thumbed turns become the eval harness's input +queue. This flips the harness from "run against a synthetic suite someone wrote" +to "run against observed real-world failures" — a categorically better asset, +and the reason this spec is worth more than the sum of its UI. + +**Config-dimension attribution.** The prize. Join down-thumb rate against +dimensions already on the `C#` row and against session state: + +- **Compaction.** Does quality degrade after a compaction event? Today there is + a `compaction` SSE event, a checkpoint, and a truncation anchor — and zero + evidence about whether users notice. The compaction-death-spiral incident + cost a faculty member $27 of a $30 quota in five days; nobody knows what it + cost in answer quality. +- **Model.** Haiku vs Sonnet per workload, measured rather than assumed. +- **`agentSwitched`.** The `@`-mention history fork is a known cache cost. Is + it also a quality cost? +- **KB freshness.** Do stale-sync assistants draw more "out of date"? +- **Skills.** Which granted skills correlate with down-thumbs, as the cheap + pre-filter for `SkillSelectionAccuracy`. + +**Rework cost.** Sum the call cost of a down-thumbed turn plus the retries that +follow it. This denominates quality in dollars, on the dashboards that already +exist. Strategically this is the most valuable single number in the spec: it +makes quality legible in the budget conversation instead of being the thing we +assert but cannot defend. + +**Marketplace ranking and version regression.** Store tiles rank on nothing +user-derived. A helpfulness rate per **published `AgentVersion`** gives ranking +*and* an alarm when a new version's rate drops — a trigger for the §8 rollback +path that currently has no automated reason to fire. + +**Tool and MCP health.** "A tool failed" + an errored `tool_result` is an ops +alert, not a model signal. Worth noting that dev ran with zero working MCP +tools for weeks and nothing surfaced it. + +**Preference data.** A `fine_tuning` domain already exists, and paired +up/down on the same prompt is DPO-shaped. Explicitly **out of scope until +Phase 5** and gated on a policy decision, not an engineering one (§8). + +### User + +**The retry loop is the actual user-facing feature.** A down-thumb that offers +*"retry with that in mind"* turns feedback from a suggestion box into a +steering act: the user gets a better answer in seconds, and we get a labeled +pair for free. Ship the thumb without this and the response rate decays to +zero within weeks — at which point the data is too sparse to use and the whole +spec is dead. **The consequence is not a Phase 2 nicety; it is the thing that +keeps Phase 1 alive.** + +**Personalization.** Repeated "too long" → a durable style preference in a +Memory Space. The primitive exists; this is the missing input to it. + +**Agent authors are a distinct user class.** A marketplace publisher currently +receives no signal about whether their instructions and tool bindings work in +the wild. Aggregate-only, never per-user (§8). + +## 8. Governance + +An admin reading a down-thumbed turn is an admin reading someone's +conversation. Three rules: + +1. **Aggregates by default; content behind a scope.** Rate, reason + distribution, and config joins need no content. Reading the actual message + requires a delegated-admin scope (`granular-admin-permissions.md`) and + writes an audit row. +2. **Authors get substance, never identity.** Same rule as D15.2, for the same + reason: authors need to know what went wrong, admins need identity to spot a + brigade. +3. **Training use is a separate consent decision.** Aggregation and evaluation + are platform operation. Building a preference dataset from user + conversations is not, and it does not ride in on this spec's flag. Per the + governance-via-identity-claims principle, that gate belongs at the claim + level, decided before Phase 5 is scoped — not inferred from the fact that + the rows exist. + +## 9. Risks and the rules that answer them + +| Risk | Rule | +|---|---| +| Someone publishes the raw rate as a quality KPI | Aggregate endpoints return **comparisons between arms**, and every response carries its `n` and coverage %. No single-number "quality score" field exists to be quoted | +| Sparse, self-selected, negative-skewed data | Treat as a sampler. Implicit signals (§10) carry the density; thumbs carry the intent | +| Feedback write mutates the cacheable prefix | Structural: `F#` is its own DynamoDB item. Nothing in this spec touches conversation history, `toolConfig`, or the system prompt. A design that puts feedback on the message object is rejected for this reason | +| An inline LLM judge is added "just to classify" | Batch/offline over persisted turns only. An inline per-turn model call on every session for the life of the platform is exactly what the cost tenet exists to stop | +| Two feedback affordances confuse users | The report link stays agent-scoped at the tail; thumbs are per-message. The down-thumb sheet hands off to the report dialog rather than duplicating it (§3) | +| Feedback becomes a void | Phase 1 ships the consequence with the button, or Phase 1 does not ship | + +## 10. Implicit signals + +Denser than thumbs, ~100% coverage, and free of UI cost: + +- **Copy to clipboard** — already a click in `message-actions.component.ts` +- **Continue** on a truncated or interrupted response +- **Edit-and-resend** of the preceding user message +- **Abandonment** — session goes idle immediately after a response +- **Dissatisfaction in the *next* user message** — the strongest dense proxy, + and the one that most needs the offline-batch rule from §9 + +These write the same `F#` row family with a `signal: "implicit"` discriminator. +Explicit and implicit must never be summed into one rate; they answer +different questions and have wildly different base rates. + +## 11. Phasing + +Sized so each phase is independently shippable and each earns the next. + +**PR-1 — Capture + consequence.** `F#` row, `POST/DELETE +/sessions/{id}/messages/{message_id}/feedback` on **app-api** (per the +inference-api boundary rule), thumbs in `message-actions.component.ts`, the +reason sheet, and retry-with-correction. Uncomment and fill the `Feedback` +model at the two placeholder sites. Flag `RESPONSE_FEEDBACK_ENABLED`. +*User value on day one; no analytics build.* + +**PR-2 — Implicit signals.** Copy / continue / edit-resend / abandonment into +the same row family. *Density.* + +**PR-3 — Read model + admin panel.** Third leg on the anatomy query; a quality +panel beside the cost panels, joined to model / compaction / `agentSwitched` / +skills. *The attribution payoff.* + +**PR-4 — Eval sampling.** Down-thumbed turns feed `EvaluationClient.run()`, +routed to evaluators by reason bucket. *Makes judging affordable.* + +**PR-5 — Author and marketplace surfaces.** Per-version helpfulness rate, +regression alarm against the rollback path. + +**Phase 6 (not scoped) — preference dataset.** Policy-gated per §8, and only +if PR-3 proves the signal is real. + +## 12. Open questions + +1. **Does the retry-with-correction path reuse `/steer` or need its own?** + `/steer` targets a *running* turn via the lease row. Correcting a finished + turn is a normal new turn carrying a structured preamble. Probably a + different endpoint with the same UX — worth confirming before PR-1. +2. **Does a corrected retry write a linked pair?** It should, for Phase 6 — + but that is the training-consent question, so PR-1 may need to record the + link without recording the content. +3. **Coverage floor for publishing a comparison.** Below some `n`, an arm + comparison is theatre. Pick the number before the panel exists, not after + someone dislikes a result. +4. **Do preview sessions participate?** The `D#` write skips them + (`metadata.py:156`); the `C#` cost write has **no such guard**, so the two + prefixes already disagree — settle the rule for `F#` deliberately rather + than copying whichever neighbour is read first. Agent Designer previews are + exactly where an author would want to thumb their own work, but that data + must never reach fleet aggregates. diff --git a/frontend/ai.client/FAVICON_GENERATION.md b/frontend/ai.client/FAVICON_GENERATION.md new file mode 100644 index 000000000..149b16988 --- /dev/null +++ b/frontend/ai.client/FAVICON_GENERATION.md @@ -0,0 +1,90 @@ +# Favicon Generation + +## Overview + +Favicons are automatically generated at build-time from a single source PNG image. This process runs during `npm run prebuild` (which is called before `npm run build` and `npm start`). + +## How It Works + +1. **Source Image**: Place a square PNG at `public/favicon-source.png` (512x512 or larger recommended) +2. **Automatic Generation**: The build process generates: + - `favicon-16x16.png` (browser tab) + - `favicon-32x32.png` (browser tab, high DPI) + - `apple-touch-icon.png` (180x180 for iOS) + - `android-chrome-192x192.png` (Android PWA) + - `android-chrome-512x512.png` (Android PWA splash screen) + - `site.webmanifest` (PWA manifest with brand colors and app name) + +3. **Manifest Updates**: The `site.webmanifest` is automatically updated with: + - App name from `brand.config.ts` (`appName`) + - Theme color from `brand.config.ts` (`backgroundColors.dark`) + - Background color from `brand.config.ts` (`backgroundColors.light`) + +## Rebranding + +To change the favicon when rebranding: + +1. **Create a new source image** (512x512 or larger, PNG format, ideally square with transparent background) +2. **Place it at** `frontend/ai.client/public/favicon-source.png` +3. **Run the build**: `npm run build` or `npm start` +4. **Done**: All favicon sizes are automatically generated and the manifest is updated + +## Generated Files Location + +All generated favicons are written to `public/favicon/`: +- `favicon-16x16.png` +- `favicon-32x32.png` +- `apple-touch-icon.png` +- `android-chrome-192x192.png` +- `android-chrome-512x512.png` +- `site.webmanifest` (dynamically generated) + +The `favicon.ico` file (multi-resolution ICO format) is kept as-is from the existing build. + +## Skipping Generation + +If `public/favicon-source.png` is missing, the build logs a warning and skips favicon generation. The existing favicon files are retained. + +## Implementation Details + +- **Tool**: Built with [Sharp](https://sharp.pixelplumbing.com/) image processing library +- **Script**: `scripts/branding/generate-favicons.ts` +- **Build Integration**: Runs as part of `npm run prebuild` +- **PNG Support**: Reads PNG source images and generates PNG outputs +- **Transparent Backgrounds**: All generated PNGs preserve transparency +- **Quality**: Images are resized with proper interpolation and gamma correction + +## Customizing the Generation + +To modify favicon generation (e.g., add new sizes or formats), edit `scripts/branding/generate-favicons.ts`: + +```typescript +// Add a new size +const FAVICON_SIZES: Array<[number, string]> = [ + [16, 'favicon-16x16.png'], + [32, 'favicon-32x32.png'], + [180, 'apple-touch-icon.png'], + [192, 'android-chrome-192x192.png'], + [512, 'android-chrome-512x512.png'], + [1024, 'favicon-1024x1024.png'], // New size +]; +``` + +Then update `index.html` and `site.webmanifest` to reference the new size. + +## Troubleshooting + +### Favicon not updating +- Ensure `favicon-source.png` exists in `public/` +- Clear browser cache (Ctrl+Shift+Delete) +- Run `npm run prebuild` manually to verify generation + +### Image quality issues +- Use a source image at least 512x512 pixels +- Ensure the source image has good contrast and detail +- Avoid heavily compressed source images + +### Build failure +- Check that `favicon-source.png` is a valid PNG +- Ensure the image file is not corrupted +- Try a different PNG file to isolate the issue diff --git a/frontend/ai.client/package-lock.json b/frontend/ai.client/package-lock.json index 300f0c354..76fb3551f 100644 --- a/frontend/ai.client/package-lock.json +++ b/frontend/ai.client/package-lock.json @@ -1,20 +1,20 @@ { "name": "ai.client", - "version": "1.17.0", + "version": "1.18.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ai.client", - "version": "1.17.0", + "version": "1.18.0", "dependencies": { "@angular/cdk": "21.2.14", - "@angular/common": "21.2.17", - "@angular/compiler": "21.2.17", - "@angular/core": "21.2.17", - "@angular/forms": "21.2.17", - "@angular/platform-browser": "21.2.17", - "@angular/router": "21.2.17", + "@angular/common": "21.2.19", + "@angular/compiler": "21.2.19", + "@angular/core": "21.2.19", + "@angular/forms": "21.2.19", + "@angular/platform-browser": "21.2.19", + "@angular/router": "21.2.19", "@ctrl/ngx-emoji-mart": "9.3.0", "@microsoft/fetch-event-source": "2.0.1", "@ng-icons/core": "33.2.2", @@ -23,7 +23,7 @@ "clipboard": "2.0.11", "katex": "0.16.45", "marked": "17.0.6", - "mermaid": "11.15.0", + "mermaid": "11.16.1", "ng2-charts": "10.0.0", "ngx-markdown": "21.2.0", "prismjs": "1.30.0", @@ -36,7 +36,7 @@ "@analogjs/vitest-angular": "3.0.0-alpha.30", "@angular/build": "21.2.16", "@angular/cli": "21.2.16", - "@angular/compiler-cli": "21.2.17", + "@angular/compiler-cli": "21.2.19", "@playwright/test": "1.59.1", "@tailwindcss/postcss": "4.2.4", "@types/node": "25.6.0", @@ -44,8 +44,10 @@ "dotenv": "17.4.2", "fast-check": "4.7.0", "jsdom": "29.1.0", - "postcss": "8.5.12", + "postcss": "8.5.28", + "sharp": "0.33.0", "tailwindcss": "4.2.4", + "tsx": "4.23.12", "typescript": "5.9.3", "vitest": "4.1.5" } @@ -956,9 +958,9 @@ } }, "node_modules/@angular/common": { - "version": "21.2.17", - "resolved": "https://registry.npmjs.org/@angular/common/-/common-21.2.17.tgz", - "integrity": "sha512-hqAQxRfi5ldFE42suAXRcY+JCANrUh7fuSQ/DtZ7L896id5BT/exuv6dWNBC1PyAfQmRbpD5Pt6/pd+tNLyhDQ==", + "version": "21.2.19", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-21.2.19.tgz", + "integrity": "sha512-Rvo/VXI0kUmfQT7+OeAjv526OJlf/WrLnJq1Tz84Jkyv9bs9SOMTCT6m4+boo8gxVNdrcxvGU3Q0o2jZzKceSg==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -967,14 +969,14 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/core": "21.2.17", + "@angular/core": "21.2.19", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/compiler": { - "version": "21.2.17", - "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.2.17.tgz", - "integrity": "sha512-p+NdjYiwAz9Zmu2yul0LlMXaFjMISVVa24+/MVMoKFeQeI82QE8jDywPlnOSHQHvdCcQVpS7saeEriZzX3JuBQ==", + "version": "21.2.19", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.2.19.tgz", + "integrity": "sha512-vuF5i1t14ftJiHXVVLgDYLWkT99QuajphcUHy1VZaMX3FiqSGXRV62g+R2RMxL0OJ5C1ai8xTHKPx9n1lQFyFg==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -984,13 +986,13 @@ } }, "node_modules/@angular/compiler-cli": { - "version": "21.2.17", - "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-21.2.17.tgz", - "integrity": "sha512-KithZ3b0HBFH0NbUcswBcjpN9y09vLbarMD7qmGWTnGUBk4W8cn4sbT8zJyv9CRKg9ZcuUBeJYKUfUPn/u/5OQ==", + "version": "21.2.19", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-21.2.19.tgz", + "integrity": "sha512-QxWqUvhTWgyYPPkfpupOUIEa3Y5cbzMilaFgRNpkvFy6teARw5Izsd7RxUbj3Tp3xJOi1MtumNmkxLxmv3iv7A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "7.29.0", + "@babel/core": "7.29.7", "@jridgewell/sourcemap-codec": "^1.4.14", "chokidar": "^5.0.0", "convert-source-map": "^1.5.1", @@ -1007,7 +1009,7 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/compiler": "21.2.17", + "@angular/compiler": "21.2.19", "typescript": ">=5.9 <6.1" }, "peerDependenciesMeta": { @@ -1017,9 +1019,9 @@ } }, "node_modules/@angular/core": { - "version": "21.2.17", - "resolved": "https://registry.npmjs.org/@angular/core/-/core-21.2.17.tgz", - "integrity": "sha512-wYHpwIdnUnjQFOJJNqRcGx7LS3u64jT+R9L0TnMR/ViBM9dQgGYImlSikkftg2yrFCNo5aKRxhG2LLskQurVdg==", + "version": "21.2.19", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-21.2.19.tgz", + "integrity": "sha512-PVoXD1kBexOJLkFzKx2zBY/0oZJXGru0eGn2hu0q5n3vkZlYsR1yRolfGenK1gZE48Qibiw/ttg/X/pAIPEZGg==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -1028,7 +1030,7 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/compiler": "21.2.17", + "@angular/compiler": "21.2.19", "rxjs": "^6.5.3 || ^7.4.0", "zone.js": "~0.15.0 || ~0.16.0" }, @@ -1042,9 +1044,9 @@ } }, "node_modules/@angular/forms": { - "version": "21.2.17", - "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-21.2.17.tgz", - "integrity": "sha512-WKu8XeRSNZo+a+aDDZ3M5OtReF7KYqR/PmZ2l1lSf6N5EEAmc+Ky4aqbRhTL/mTSfHrO4+TDJ4C5A2tFmuwIeA==", + "version": "21.2.19", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-21.2.19.tgz", + "integrity": "sha512-tEw8cz2UU6VSB+ReJN86k87nRdLRXGi+8SZhoRl2dFu2iReIO3S6kJAVTH7Y9kdrokqTPhWG+KNEzWgqhdjXOg==", "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -1054,16 +1056,16 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "21.2.17", - "@angular/core": "21.2.17", - "@angular/platform-browser": "21.2.17", + "@angular/common": "21.2.19", + "@angular/core": "21.2.19", + "@angular/platform-browser": "21.2.19", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/platform-browser": { - "version": "21.2.17", - "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.2.17.tgz", - "integrity": "sha512-ROdSliejY37g1EphYmweYdm5cHM8HY3X4tbWt4ubxmhTyYgfN3nxrxfGQ/n7Mz5tDY9VXVLIGDgjLOGYOo4uTQ==", + "version": "21.2.19", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.2.19.tgz", + "integrity": "sha512-YMguYVhdkV8tr9MvbN+VpMjbdmsYq6g12M9WPvAYM51BkL2iREbWeoXDGmfCw9G0it6+0pMdgrSPFFUFuOqpAQ==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -1072,9 +1074,9 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/animations": "21.2.17", - "@angular/common": "21.2.17", - "@angular/core": "21.2.17" + "@angular/animations": "21.2.19", + "@angular/common": "21.2.19", + "@angular/core": "21.2.19" }, "peerDependenciesMeta": { "@angular/animations": { @@ -1083,9 +1085,9 @@ } }, "node_modules/@angular/router": { - "version": "21.2.17", - "resolved": "https://registry.npmjs.org/@angular/router/-/router-21.2.17.tgz", - "integrity": "sha512-RSCtK5ppAV6y6wfRLHSK2a9Wc/vm8j0wsC+/j9PH9yQmppWFVXDWsg5E39MKOIpnoYVx2+hI6eak6+wYtZTe1A==", + "version": "21.2.19", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-21.2.19.tgz", + "integrity": "sha512-cKO/aq1xEMvgS29jFUc/Y3KsgEzHnwOw6sEL+UQZkZTmGD5YrYv8Yvj05GDMEUYbvDxBd5DixVVEbH38lyQKqA==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -1094,9 +1096,9 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "21.2.17", - "@angular/core": "21.2.17", - "@angular/platform-browser": "21.2.17", + "@angular/common": "21.2.19", + "@angular/core": "21.2.19", + "@angular/platform-browser": "21.2.19", "rxjs": "^6.5.3 || ^7.4.0" } }, @@ -2186,6 +2188,486 @@ "import-meta-resolve": "^4.2.0" } }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.0.tgz", + "integrity": "sha512-070tEheekI1LJWTGPC9WlQEa5UoKTXzzlORBHMX4TbfUxMiL336YHR8vBEUNsjse0RJCX8dZ4ZXwT595aEF1ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "glibc": ">=2.26", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.0" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.0.tgz", + "integrity": "sha512-pu/nvn152F3qbPeUkr+4e9zVvEhD3jhwzF473veQfMPkOYo9aoWXSfdZH/E6F+nYC3qvFjbxbvdDbUtEbghLqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "glibc": ">=2.26", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.0" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.0.tgz", + "integrity": "sha512-VzYd6OwnUR81sInf3alj1wiokY50DjsHz5bvfnsFpxs5tqQxESoHtJO6xyksDs3RIkyhMWq2FufXo6GNSU9BMw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "macos": ">=11", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.0.tgz", + "integrity": "sha512-dD9OznTlHD6aovRswaPNEy8dKtSAmNo4++tO7uuR4o5VxbVAOoEQ1uSmN4iFAdQneTHws1lkTZeiXPrcCkh6IA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "macos": ">=10.13", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.0.tgz", + "integrity": "sha512-VwgD2eEikDJUk09Mn9Dzi1OW2OJFRQK+XlBTkUNmAWPrtj8Ly0yq05DFgu1VCMx2/DqCGQVi5A1dM9hTmxf3uw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.28", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.0.tgz", + "integrity": "sha512-xTYThiqEZEZc0PRU90yVtM3KE7lw1bKdnDQ9kCTHWbqWyHOe4NpPOtMGy27YnN51q0J5dqRrvicfPbALIOeAZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.26", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.0.tgz", + "integrity": "sha512-o9E46WWBC6JsBlwU4QyU9578G77HBDT1NInd+aERfxeOPbk0qBZHgoDsQmA2v9TbqJRWzoBPx1aLOhprBMgPjw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.28", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.0.tgz", + "integrity": "sha512-naldaJy4hSVhWBgEjfdBY85CAa4UO+W1nx6a1sWStHZ7EUfNiuBTTN2KUYT5dH1+p/xij1t2QSXfCiFJoC5S/Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.26", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.0.tgz", + "integrity": "sha512-OdorplCyvmSAPsoJLldtLh3nLxRrkAAAOHsGWGDYfN0kh730gifK+UZb3dWORRa6EusNqCTjfXV4GxvgJ/nPDQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "musl": ">=1.2.2", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.0.tgz", + "integrity": "sha512-FW8iK6rJrg+X2jKD0Ajhjv6y74lToIBEvkZhl42nZt563FfxkCYacrXZtd+q/sRQDypQLzY5WdLkVTbJoPyqNg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "musl": ">=1.2.2", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.0.tgz", + "integrity": "sha512-4horD3wMFd5a0ddbDY8/dXU9CaOgHjEHALAddXgafoR5oWq5s8X61PDgsSeh4Qupsdo6ycfPPSSNBrfVQnwwrg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.28", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.0" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.0.tgz", + "integrity": "sha512-dcomVSrtgF70SyOr8RCOCQ8XGVThXwe71A1d8MGA+mXEVRJ/J6/TrCbBEJh9ddcEIIsrnrkolaEvYSHqVhswQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.26", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.0" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.0.tgz", + "integrity": "sha512-TiVJbx38J2rNVfA309ffSOB+3/7wOsZYQEOlKqOUdWD/nqkjNGrX+YQGz7nzcf5oy2lC+d37+w183iNXRZNngQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.28", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.0" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.0.tgz", + "integrity": "sha512-PaZM4Zi7/Ek71WgTdvR+KzTZpBqrQOFcPe7/8ZoPRlTYYRe43k6TWsf4GVH6XKRLMYeSp8J89RfAhBrSP4itNA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.26", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.0" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.0.tgz", + "integrity": "sha512-1QLbbN0zt+32eVrg7bb1lwtvEaZwlhEsY1OrijroMkwAqlHqFj6R33Y47s2XUv7P6Ie1PwCxK/uFnNqMnkd5kg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "musl": ">=1.2.2", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.0" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.0.tgz", + "integrity": "sha512-CecqgB/CnkvCWFhmfN9ZhPGMLXaEBXl4o7WtA6U3Ztrlh/s7FUKX4vNxpMSYLIrWuuzjiaYdfU3+Tdqh1xaHfw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "musl": ">=1.2.2", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.0" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.0.tgz", + "integrity": "sha512-Hn4js32gUX9qkISlemZBUPuMs0k/xNJebUNl/L6djnU07B/HAA2KaxRVb3HvbU5fL242hLOcp0+tR+M8dvJUFw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^0.44.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-wasm32/node_modules/@emnapi/runtime": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-0.44.0.tgz", + "integrity": "sha512-ZX/etZEZw8DR7zAB1eVQT40lNo0jeqpb6dCgOvctB6FIQ5PoXfMuNY8+ayQfu8tNQbAB8gQWSSJupR8NxeiZXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.0.tgz", + "integrity": "sha512-5HfcsCZi3l5nPRF2q3bllMVMDXBqEWI3Q8KQONfzl0TferFE5lnsIG0A1YrntMAGqvkzdW6y1Ci1A2uTvxhfzg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.0.tgz", + "integrity": "sha512-i3DtP/2ce1yKFj4OzOnOYltOEL/+dp4dc4dJXJBv6god1AFTcmkaA99H/7SwOmkCOBQkbVvA3lCGm3/5nDtf9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@inquirer/ansi": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", @@ -2730,12 +3212,12 @@ ] }, "node_modules/@mermaid-js/parser": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.1.tgz", - "integrity": "sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.2.1.tgz", + "integrity": "sha512-n12NohV3mrUyUL2o93IgG/ifeW9FTyeJn3zDxkhwa8MJ9Fxg3HQMlA3RiGmD/3UnJvheztkjjQAjA2T4LmUcpw==", "license": "MIT", "dependencies": { - "@chevrotain/types": "~11.1.1" + "@chevrotain/types": "~11.1.2" } }, "node_modules/@microsoft/fetch-event-source": { @@ -6151,9 +6633,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.38", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz", - "integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==", + "version": "2.11.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", + "integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -6241,9 +6723,9 @@ "license": "ISC" }, "node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { @@ -6254,9 +6736,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", "dev": true, "funding": [ { @@ -6274,11 +6756,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" @@ -6368,9 +6850,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001799", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", - "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "dev": true, "funding": [ { @@ -6571,6 +7053,20 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -6591,6 +7087,17 @@ "dev": true, "license": "MIT" }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, "node_modules/colorette": { "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", @@ -7389,9 +7896,9 @@ } }, "node_modules/dompurify": { - "version": "3.4.12", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", - "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", + "version": "3.4.14", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz", + "integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -7448,9 +7955,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.375", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.375.tgz", - "integrity": "sha512-ZWP5eB4BVPW/ZYo9252hQZHZ5XavtsTgpbhcmMmRwymavC5AsLWQWBPaKMeNd2LW0KGby5HPXvj7+sr4ta5j/Q==", + "version": "1.5.421", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.421.tgz", + "integrity": "sha512-cUhfpHQy+PGbt+X90DMcAVazDCziIZr73hpxD4LRs4BGQoJCifPzTfQWa7S6c+uhokTOBe8tot09GBSEO6c9LA==", "dev": true, "license": "ISC" }, @@ -7790,9 +8297,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", "funding": [ { "type": "github", @@ -8071,9 +8578,9 @@ } }, "node_modules/hono": { - "version": "4.12.32", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.32.tgz", - "integrity": "sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==", + "version": "4.13.5", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.5.tgz", + "integrity": "sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw==", "dev": true, "license": "MIT", "engines": { @@ -8286,9 +8793,9 @@ } }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", + "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", "dev": true, "license": "MIT", "engines": { @@ -8305,6 +8812,13 @@ "node": ">= 0.10" } }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "dev": true, + "license": "MIT" + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -9180,26 +9694,26 @@ } }, "node_modules/mermaid": { - "version": "11.15.0", - "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.15.0.tgz", - "integrity": "sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==", + "version": "11.16.1", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.16.1.tgz", + "integrity": "sha512-TQsq6u22fAn3rek5VOubrhKPo1g5hwC3FXUN9hiyupTckcYiGuuKGkNQrKYwGJkXUxZdojwRG46gsSCFZMDp4g==", "license": "MIT", "dependencies": { - "@braintree/sanitize-url": "^7.1.1", + "@braintree/sanitize-url": "^7.1.2", "@iconify/utils": "^3.0.2", - "@mermaid-js/parser": "^1.1.1", + "@mermaid-js/parser": "^1.2.0", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", - "cytoscape": "^3.33.1", + "cytoscape": "^3.33.3", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", - "dayjs": "^1.11.19", - "dompurify": "^3.3.1", + "dayjs": "^1.11.20", + "dompurify": "^3.3.3", "es-toolkit": "^1.45.1", - "katex": "^0.16.25", + "katex": "^0.16.45", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", @@ -9470,9 +9984,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.13", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.13.tgz", - "integrity": "sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -9631,9 +10145,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.48", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz", - "integrity": "sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==", + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", "dev": true, "license": "MIT", "engines": { @@ -10259,9 +10773,9 @@ } }, "node_modules/postcss": { - "version": "8.5.12", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", - "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", "dev": true, "funding": [ { @@ -10279,7 +10793,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -10382,13 +10896,14 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -10713,6 +11228,47 @@ "dev": true, "license": "ISC" }, + "node_modules/sharp": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.0.tgz", + "integrity": "sha512-99DZKudjm/Rmz+M0/26t4DKpXyywAOJaayGS9boEn7FvgtG0RYBi46uPE2c+obcJRtA3AZa0QwJot63gJQ1F0Q==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.2", + "semver": "^7.5.4" + }, + "engines": { + "libvips": ">=8.15.0", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.0", + "@img/sharp-darwin-x64": "0.33.0", + "@img/sharp-libvips-darwin-arm64": "1.0.0", + "@img/sharp-libvips-darwin-x64": "1.0.0", + "@img/sharp-libvips-linux-arm": "1.0.0", + "@img/sharp-libvips-linux-arm64": "1.0.0", + "@img/sharp-libvips-linux-s390x": "1.0.0", + "@img/sharp-libvips-linux-x64": "1.0.0", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.0", + "@img/sharp-libvips-linuxmusl-x64": "1.0.0", + "@img/sharp-linux-arm": "0.33.0", + "@img/sharp-linux-arm64": "0.33.0", + "@img/sharp-linux-s390x": "0.33.0", + "@img/sharp-linux-x64": "0.33.0", + "@img/sharp-linuxmusl-arm64": "0.33.0", + "@img/sharp-linuxmusl-x64": "0.33.0", + "@img/sharp-wasm32": "0.33.0", + "@img/sharp-win32-ia32": "0.33.0", + "@img/sharp-win32-x64": "0.33.0" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -10849,6 +11405,16 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, "node_modules/slice-ansi": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", @@ -11246,6 +11812,40 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.23.12", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", + "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/tuf-js": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-4.1.0.tgz", @@ -11309,9 +11909,9 @@ } }, "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "dev": true, "license": "MIT", "engines": { @@ -11336,9 +11936,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", "dev": true, "funding": [ { @@ -11793,35 +12393,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/vite/node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.12", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, "node_modules/vite/node_modules/rolldown": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", diff --git a/frontend/ai.client/package.json b/frontend/ai.client/package.json index 93f9a805b..9aceb5a64 100644 --- a/frontend/ai.client/package.json +++ b/frontend/ai.client/package.json @@ -1,10 +1,11 @@ { "name": "ai.client", - "version": "1.17.0", + "version": "1.18.0", "scripts": { "ng": "ng", + "prestart": "tsx scripts/branding/generate-brand-theme.ts && tsx scripts/branding/generate-surface-theme.ts && tsx scripts/branding/generate-surface-colors.ts && tsx scripts/branding/generate-favicons.ts", "start": "ng serve", - "prebuild": "node scripts/gen-version.js", + "prebuild": "node scripts/gen-version.js && tsx scripts/branding/generate-brand-theme.ts && tsx scripts/branding/generate-surface-theme.ts && tsx scripts/branding/generate-surface-colors.ts && tsx scripts/branding/generate-favicons.ts", "build": "ng build", "watch": "ng build --watch --configuration development", "test": "ng test", @@ -29,12 +30,12 @@ "packageManager": "npm@11.2.0", "dependencies": { "@angular/cdk": "21.2.14", - "@angular/common": "21.2.17", - "@angular/compiler": "21.2.17", - "@angular/core": "21.2.17", - "@angular/forms": "21.2.17", - "@angular/platform-browser": "21.2.17", - "@angular/router": "21.2.17", + "@angular/common": "21.2.19", + "@angular/compiler": "21.2.19", + "@angular/core": "21.2.19", + "@angular/forms": "21.2.19", + "@angular/platform-browser": "21.2.19", + "@angular/router": "21.2.19", "@ctrl/ngx-emoji-mart": "9.3.0", "@microsoft/fetch-event-source": "2.0.1", "@ng-icons/core": "33.2.2", @@ -43,7 +44,7 @@ "clipboard": "2.0.11", "katex": "0.16.45", "marked": "17.0.6", - "mermaid": "11.15.0", + "mermaid": "11.16.1", "ng2-charts": "10.0.0", "ngx-markdown": "21.2.0", "prismjs": "1.30.0", @@ -56,7 +57,7 @@ "@analogjs/vitest-angular": "3.0.0-alpha.30", "@angular/build": "21.2.16", "@angular/cli": "21.2.16", - "@angular/compiler-cli": "21.2.17", + "@angular/compiler-cli": "21.2.19", "@playwright/test": "1.59.1", "@tailwindcss/postcss": "4.2.4", "@types/node": "25.6.0", @@ -64,21 +65,25 @@ "dotenv": "17.4.2", "fast-check": "4.7.0", "jsdom": "29.1.0", - "postcss": "8.5.12", + "postcss": "8.5.28", + "sharp": "0.33.0", "tailwindcss": "4.2.4", + "tsx": "4.23.12", "typescript": "5.9.3", "vitest": "4.1.5" }, "overrides": { - "undici": ">=7.28.0 <8.0.0", + "undici": ">=7.29.0 <8.0.0", "picomatch": ">=4.0.4", "vite": ">=8.0.16", "esbuild": ">=0.28.1", - "dompurify": ">=3.4.0", + "dompurify": ">=3.4.13", "lodash-es": ">=4.18.0", - "hono": ">=4.12.25", + "hono": ">=4.12.34", "@hono/node-server": ">=1.19.13", "piscina": ">=5.2.0", + "postcss": "8.5.28", + "brace-expansion": ">=5.0.9", "@babel/core": ">=7.29.6 <8.0.0", "mermaid": { "uuid": "14.0.0" diff --git a/frontend/ai.client/public/favicon-source.png b/frontend/ai.client/public/favicon-source.png new file mode 100644 index 000000000..17225e809 Binary files /dev/null and b/frontend/ai.client/public/favicon-source.png differ diff --git a/frontend/ai.client/public/favicon/site.webmanifest b/frontend/ai.client/public/favicon/site.webmanifest index 45dc8a206..73cdd78aa 100644 --- a/frontend/ai.client/public/favicon/site.webmanifest +++ b/frontend/ai.client/public/favicon/site.webmanifest @@ -1 +1,23 @@ -{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} \ No newline at end of file +{ + "name": "Boise State Logo", + "short_name": "Boise State ", + "icons": [ + { + "src": "/favicon/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/favicon/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + } + ], + "theme_color": "#0033a0", + "background_color": "#ffffff", + "display": "standalone", + "scope": "/", + "start_url": "/" +} \ No newline at end of file diff --git a/frontend/ai.client/public/img/ac_boise_logo.png b/frontend/ai.client/public/img/ac_boise_logo.png new file mode 100644 index 000000000..d189bcfbd Binary files /dev/null and b/frontend/ai.client/public/img/ac_boise_logo.png differ diff --git a/frontend/ai.client/scripts/branding/color-math.ts b/frontend/ai.client/scripts/branding/color-math.ts new file mode 100644 index 000000000..6c0959da8 --- /dev/null +++ b/frontend/ai.client/scripts/branding/color-math.ts @@ -0,0 +1,140 @@ +/** + * Color_Math — shared sRGB <-> OKLab/OKLCH conversion and WCAG contrast + * helpers, used by both the brand-color generator + * (generate-brand-theme.ts) and the surface-color generator + * (generate-surface-theme.ts), plus the config-side validation in + * brand-config.normalize.ts. + * + * Extracted so the two generators (and the runtime validation bands for + * `surfaces`) share one implementation instead of maintaining separate + * copies of the same sRGB/OKLab math and hex-format regex. + * + * Conversions follow Björn Ottosson's OKLab definition; sRGB output is + * gamut-clamped per channel, matching what a browser does when an + * `oklch()` value lands outside the display gamut. + */ + +/** Matches a 6-digit hex color, with an optional leading '#'. Case-insensitive. */ +export const HEX_COLOR_REGEX = /^#?[0-9a-fA-F]{6}$/; + +/** Normalize a hex input by ensuring it has a leading '#'. Assumes the value already matches HEX_COLOR_REGEX. */ +export function normalizeHex(hex: string): string { + return hex.startsWith('#') ? hex : `#${hex}`; +} + +/** sRGB channel (0-1) -> linear-light value. */ +export function srgbToLinear(c: number): number { + return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; +} + +/** Linear-light value -> sRGB channel, clamped to the displayable [0,1] range. */ +export function linearToSrgb(c: number): number { + const v = c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055; + return Math.min(1, Math.max(0, v)); +} + +/** Parse '#rrggbb' (or 'rrggbb') into sRGB channels in 0-1. */ +export function hexToSrgb(hex: string): [number, number, number] { + const h = hex.replace('#', ''); + return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255) as [number, number, number]; +} + +/** OKLCH (l in 0-1, c, h in degrees) -> sRGB channels in 0-1, gamut-clamped. */ +export function oklchToSrgb(l: number, c: number, hDeg: number): [number, number, number] { + const hRad = (hDeg * Math.PI) / 180; + const a = c * Math.cos(hRad); + const b = c * Math.sin(hRad); + + const lp = l + 0.3963377774 * a + 0.2158037573 * b; + const mp = l - 0.1055613458 * a - 0.0638541728 * b; + const sp = l - 0.0894841775 * a - 1.291485548 * b; + + const lc = lp ** 3; + const mc = mp ** 3; + const sc = sp ** 3; + + return [ + linearToSrgb(4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc), + linearToSrgb(-1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc), + linearToSrgb(-0.0041960863 * lc - 0.7034186147 * mc + 1.707614701 * sc), + ]; +} + +/** '#rrggbb' -> OKLCH { l (0-1), c, h (degrees, 0-360) }. */ +export function hexToOklch(hex: string): { l: number; c: number; h: number } { + const [r, g, b] = hexToSrgb(normalizeHex(hex)).map(srgbToLinear); + + const lp = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b); + const mp = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b); + const sp = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b); + + const l = 0.2104542553 * lp + 0.793617785 * mp - 0.0040720468 * sp; + const a = 1.9779984951 * lp - 2.428592205 * mp + 0.4505937099 * sp; + const b2 = 0.0259040371 * lp + 0.7827717662 * mp - 0.808675766 * sp; + + const c = Math.hypot(a, b2); + let h = (Math.atan2(b2, a) * 180) / Math.PI; + if (h < 0) h += 360; + + return { l, c, h }; +} + +/** WCAG 2.1 relative luminance of sRGB channels in 0-1. */ +export function relativeLuminance([r, g, b]: [number, number, number]): number { + const [lr, lg, lb] = [r, g, b].map(srgbToLinear); + return 0.2126 * lr + 0.7152 * lg + 0.0722 * lb; +} + +/** WCAG 2.1 contrast ratio between two sRGB colors. */ +export function contrastRatio(a: [number, number, number], b: [number, number, number]): number { + const la = relativeLuminance(a); + const lb = relativeLuminance(b); + const [hi, lo] = la > lb ? [la, lb] : [lb, la]; + return (hi + 0.05) / (lo + 0.05); +} + +/** WCAG 2.1 AA contrast target for normal-size text (1.4.3). */ +export const CONTRAST_TARGET_AA = 4.5; + +/** Granularity of the accessible-lightness search, in OKLCH lightness units. */ +const LIGHTNESS_SEARCH_STEP = 0.005; + +/** + * Find the OKLCH lightness delta that brings `hex` to at least `target` + * contrast against `background`, holding chroma and hue fixed. + * + * Returns 0 when the color already passes. `direction` is 'darken' for + * light backgrounds and 'lighten' for dark backgrounds. + */ +export function findAccessibleLightnessDelta( + hex: string, + background: [number, number, number], + direction: 'darken' | 'lighten', + target: number = CONTRAST_TARGET_AA, +): number { + const { l, c, h } = hexToOklch(normalizeHex(hex)); + + if (contrastRatio(oklchToSrgb(l, c, h), background) >= target) { + return 0; + } + + const sign = direction === 'darken' ? -1 : 1; + const limit = direction === 'darken' ? l : 1 - l; + + for (let magnitude = LIGHTNESS_SEARCH_STEP; magnitude <= limit; magnitude += LIGHTNESS_SEARCH_STEP) { + const candidate = l + sign * magnitude; + if (contrastRatio(oklchToSrgb(candidate, c, h), background) >= target) { + return Number((sign * magnitude).toFixed(3)); + } + } + + return Number((sign * limit).toFixed(3)); +} + +/** Shortest-arc delta (in degrees, range (-180, 180]) from `from` to `to`. */ +export function shortestHueDelta(from: number, to: number): number { + let diff = (to - from) % 360; + if (diff > 180) diff -= 360; + if (diff < -180) diff += 360; + return diff; +} diff --git a/frontend/ai.client/scripts/branding/generate-brand-theme.ts b/frontend/ai.client/scripts/branding/generate-brand-theme.ts new file mode 100644 index 000000000..9df9ba1da --- /dev/null +++ b/frontend/ai.client/scripts/branding/generate-brand-theme.ts @@ -0,0 +1,284 @@ +/** + * Color_Scale_Generator (build-time). + * + * Pure transformation from the three Brand_Color hex values in a + * BrandConfig into the Tailwind `@theme` color-scale CSS declarations + * (`--color-{role}-{step}`). See design.md "Color_Scale_Generator" for + * the authoritative description. + * + * This module exposes the pure generator functions (safe to import from + * tests or other tooling without side effects) plus a runnable entry + * point, guarded so it only executes when this file is run directly + * (e.g. via `npm run prebuild` / `npm run prestart`), that writes the + * generated `@theme` partial to `src/styles/generated/brand-theme.css`. + * + * sRGB/OKLab conversion and WCAG contrast helpers live in ./color-math.ts, + * shared with generate-surface-theme.ts and brand-config.normalize.ts, + * rather than being duplicated here. + */ + +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import type { BrandConfig, BrandConfigError } from '../../src/branding/brand.types'; +import { DEFAULT_COLORS, DEFAULT_SURFACES } from '../../src/branding/brand.defaults'; +import { BRAND_CONFIG } from '../../src/branding/brand.config'; +import { logSurfaceAcceptance, resolveSurfaces } from '../../src/branding/brand-config.normalize'; +import { + CONTRAST_TARGET_AA, + HEX_COLOR_REGEX, + contrastRatio, + findAccessibleLightnessDelta, + hexToOklch, + normalizeHex, + oklchToSrgb, +} from './color-math'; + +// Re-exported for backwards compatibility: existing specs import these +// symbols from this module. +export { HEX_COLOR_REGEX, contrastRatio, findAccessibleLightnessDelta, hexToOklch, oklchToSrgb, CONTRAST_TARGET_AA }; + +/** The 11 Tailwind steps in order. */ +export const STEPS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950] as const; + +/** + * Fixed lightness deltas applied via oklch(from #hex calc(l + delta) c h). + * step 500 is the literal hex (delta 0, emitted as the hex itself). + */ +export const LIGHTNESS_DELTA: Record = { + 50: 0.4, + 100: 0.35, + 200: 0.3, + 300: 0.2, + 400: 0.1, + 500: 0, + 600: -0.1, + 700: -0.15, + 800: -0.2, + 900: -0.25, + 950: -0.3, +}; + +/** A brand color role. */ +export type BrandColorRole = 'primary' | 'secondary' | 'tertiary'; + +/** + * The app's light-theme "accessible" reference surface, in sRGB (0-1). + * The darker of the configured `surfaces.light` and `surfaces.raised` + * anchors, since that is the binding constraint for legible text/fills — + * a color that clears AA against the darker of the two also clears it + * against the lighter one. Falls back to Default_Surfaces (pure white) so + * a clean checkout renders identically to before surfaces existed. + */ +function resolveLightSurfaceSrgb(surfaces: { + light: string; + raised: string; +}): [number, number, number] { + const lightOklch = hexToOklch(surfaces.light); + const raisedOklch = hexToOklch(surfaces.raised); + const darker = lightOklch.l <= raisedOklch.l ? lightOklch : raisedOklch; + return oklchToSrgb(darker.l, darker.c, darker.h); +} + +/** + * The app's dark-theme "accessible" reference surface, in sRGB (0-1), + * derived from the configured `surfaces.dark` anchor. Falls back to + * Default_Surfaces (Tailwind gray-900) so a clean checkout renders + * identically to before surfaces existed. Kept in sync with the + * `html.dark body` background, which is driven by the same + * `surfaces.dark` value via generate-surface-theme.ts. + */ +function resolveDarkSurfaceSrgb(surfaces: { dark: string }): [number, number, number] { + const { l, c, h } = hexToOklch(surfaces.dark); + return oklchToSrgb(l, c, h); +} + +/** + * Emit the two contrast-guaranteed aliases for one role. + * + * `--color-{role}-accessible` clears AA against the resolved light surface + * (the darker of `surfaces.light`/`surfaces.raised`), so it is safe both as + * a solid fill carrying white text and as brand-colored text on a light + * surface. `--color-{role}-accessible-dark` clears AA against the resolved + * dark surface (`surfaces.dark`), for the `dark:` half of the same call + * site. + * + * Each is expressed as a lightness-only offset from the configured hex, so + * chroma and hue are untouched and the alias tracks any future Brand_Color + * edit automatically. + */ +export function generateAccessibleAliases( + role: BrandColorRole, + hex: string, + surfaces: { light: string; dark: string; raised: string } = DEFAULT_SURFACES, +): string { + const normalizedHex = normalizeHex(hex); + const lightSurface = resolveLightSurfaceSrgb(surfaces); + const darkSurface = resolveDarkSurfaceSrgb(surfaces); + + const render = (name: string, delta: number): string => { + if (delta === 0) { + return `--color-${role}-${name}: ${normalizedHex};`; + } + const sign = delta > 0 ? '+' : '-'; + return `--color-${role}-${name}: oklch(from ${normalizedHex} calc(l ${sign} ${Math.abs(delta)}) c h);`; + }; + + return [ + render('accessible', findAccessibleLightnessDelta(normalizedHex, lightSurface, 'darken')), + render('accessible-dark', findAccessibleLightnessDelta(normalizedHex, darkSurface, 'lighten')), + ].join('\n'); +} + +/** + * Produce the 11 CSS declarations for one role, given an already-normalized + * (leading '#') 6-digit hex value. + */ +export function generateScale(role: BrandColorRole, hex: string): string { + const normalizedHex = normalizeHex(hex); + + return STEPS.map((step) => { + if (step === 500) { + return `--color-${role}-500: ${normalizedHex};`; + } + + const delta = LIGHTNESS_DELTA[step]; + const sign = delta >= 0 ? '+' : '-'; + const magnitude = Math.abs(delta); + + return `--color-${role}-${step}: oklch(from ${normalizedHex} calc(l ${sign} ${magnitude}) c h);`; + }).join('\n'); +} + +/** + * Validate and resolve a single role's hex value against the config, + * falling back to the Default_Branding hex and recording an error when + * the provided value is not a valid 6-digit hex. + */ +function resolveRoleHex( + role: BrandColorRole, + value: string, + errors: BrandConfigError[] +): string { + if (HEX_COLOR_REGEX.test(value)) { + return normalizeHex(value); + } + + errors.push({ + field: `colors.${role}`, + value, + reason: `Invalid Brand_Color hex for role "${role}": expected a 6-digit hexadecimal value (optional leading '#'), got "${value}".`, + }); + + return normalizeHex(DEFAULT_COLORS[role]); +} + +/** + * Produce the full @theme color block for all three roles (primary, + * secondary, tertiary, in that order), validating each role's hex against + * the Brand_Color format and falling back to Default_Branding on failure. + * + * The accessible aliases are computed against `config.surfaces` (falling + * back to Default_Surfaces per-field on invalid input), so they track the + * app's actual light/dark backgrounds rather than a hardcoded reference. + */ +export function generateBrandTheme(config: BrandConfig): { + css: string; + errors: BrandConfigError[]; +} { + const errors: BrandConfigError[] = []; + const roles: BrandColorRole[] = ['primary', 'secondary', 'tertiary']; + + const resolved = roles.map((role) => ({ + role, + hex: resolveRoleHex(role, config.colors[role], errors), + })); + + const surfaces = resolveSurfaces(config.surfaces, errors); + + // The three 11-step scales come first, as a contiguous 33-line block, then + // the contrast-guaranteed aliases. Keeping the scales first and unbroken + // means the scale structure stays independently addressable (the property + // tests slice it by fixed offset). + const scales = resolved.map(({ role, hex }) => generateScale(role, hex)).join('\n'); + const aliases = resolved + .map(({ role, hex }) => generateAccessibleAliases(role, hex, surfaces)) + .join('\n'); + + return { css: `${scales}\n${aliases}`, errors }; +} + +/** Directory containing this script file (ESM-safe equivalent of `__dirname`). */ +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); + +/** Path to the generated Tailwind `@theme` color-scale partial, relative to this file. */ +const OUTPUT_PATH = resolve(SCRIPT_DIR, '../../src/styles/generated/brand-theme.css'); + +/** Wrap the generated color-scale declarations in an `@theme { ... }` block. */ +function wrapInThemeBlock(css: string): string { + const indented = css + .split('\n') + .map((line) => (line.length > 0 ? ` ${line}` : line)) + .join('\n'); + + return `/** + * Generated by scripts/branding/generate-brand-theme.ts. Do not edit by hand. + * + * Tailwind \`@theme\` color-scale declarations derived from the + * Brand_Color values in src/branding/brand.config.ts. Regenerated + * automatically by the \`prebuild\` / \`prestart\` npm scripts. + * + * Each role gets an 11-step scale plus two contrast-guaranteed aliases: + * --color-{role}-accessible WCAG AA (4.5:1) against the resolved + * light surface. Use for solid fills with + * white text, and for brand-colored text + * on light surfaces. + * --color-{role}-accessible-dark WCAG AA against the resolved dark + * surface. Use for the \`dark:\` half of + * the same call site. + * + * The aliases only move lightness — the configured hue and chroma are + * preserved — so a brand color that is already dark enough is emitted + * unchanged, and one that is too light is stepped down just far enough + * rather than being replaced. + */ +@theme { +${indented} +} +`; +} + +/** Run the generator against BRAND_CONFIG and write the output partial. */ +function run(): void { + const { css, errors } = generateBrandTheme(BRAND_CONFIG); + + for (const error of errors) { + console.warn( + `[generate-brand-theme] ${error.field}: ${error.reason}${ + error.value !== undefined ? ` (value: "${error.value}")` : '' + }` + ); + } + + logSurfaceAcceptance('generate-brand-theme', BRAND_CONFIG.surfaces, errors); + + mkdirSync(dirname(OUTPUT_PATH), { recursive: true }); + writeFileSync(OUTPUT_PATH, wrapInThemeBlock(css), 'utf8'); + console.log(`✏️ ${OUTPUT_PATH} ← Brand_Config colors`); +} + +// Runtime guard: only execute the file-writing logic when this script is +// run directly (e.g. `tsx scripts/branding/generate-brand-theme.ts`), not +// when the pure functions above are imported elsewhere (e.g. property tests). +const isMainModule = (() => { + try { + return resolve(process.argv[1] ?? '') === fileURLToPath(import.meta.url); + } catch { + return false; + } +})(); + +if (isMainModule) { + run(); +} diff --git a/frontend/ai.client/scripts/branding/generate-favicons.ts b/frontend/ai.client/scripts/branding/generate-favicons.ts new file mode 100644 index 000000000..7fe01ac66 --- /dev/null +++ b/frontend/ai.client/scripts/branding/generate-favicons.ts @@ -0,0 +1,195 @@ +/** + * Favicon_Generator (build-time). + * + * Generates multi-resolution favicon assets from a single source image. + * Takes a PNG source from `public/favicon-source.png` and generates: + * - favicon.ico (all sizes) + * - favicon-16x16.png + * - favicon-32x32.png + * - apple-touch-icon.png (180x180) + * - android-chrome-192x192.png + * - android-chrome-512x512.png + * - site.webmanifest (with colors from brand config) + * + * If the source image is missing, generation is skipped with a warning. + * This allows the build to proceed without hard-failing on missing/old branding. + */ + +import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import sharp from 'sharp'; + +import type { BrandConfig } from '../../src/branding/brand.types'; +import { BRAND_CONFIG } from '../../src/branding/brand.config'; + +/** Favicon sizes to generate. Format: [size, filename]. */ +const FAVICON_SIZES: Array<[number, string]> = [ + [16, 'favicon-16x16.png'], + [32, 'favicon-32x32.png'], + [180, 'apple-touch-icon.png'], + [192, 'android-chrome-192x192.png'], + [512, 'android-chrome-512x512.png'], +]; + +/** Directory containing this script file (ESM-safe equivalent of `__dirname`). */ +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); + +/** Path to the favicon output directory. */ +const FAVICON_OUTPUT_DIR = resolve(SCRIPT_DIR, '../../public/favicon'); + +/** Path to the source favicon PNG (placed in public root). */ +const FAVICON_SOURCE_PATH = resolve(SCRIPT_DIR, '../../public/favicon-source.png'); + +/** + * Generate the site.webmanifest JSON with colors from brand config. + * Includes Android Chrome icon sizes and theme/background colors. + */ +function generateWebmanifest(config: BrandConfig): string { + // Use the primary brand color for theme and a neutral light gray for background + const themeColor = config.colors.primary; + const backgroundColor = '#ffffff'; // Default light gray/white background + + const manifest = { + name: config.appName, + short_name: config.appName.substring(0, 12), + icons: [ + { + src: '/favicon/android-chrome-192x192.png', + sizes: '192x192', + type: 'image/png', + purpose: 'any', + }, + { + src: '/favicon/android-chrome-512x512.png', + sizes: '512x512', + type: 'image/png', + purpose: 'any', + }, + ], + theme_color: themeColor, + background_color: backgroundColor, + display: 'standalone', + scope: '/', + start_url: '/', + }; + + return JSON.stringify(manifest, null, 2); +} + +/** + * Generate a favicon.ico file from PNG data. + * ICO format can contain multiple resolutions in one file. + */ +async function generateIco(pngBuffer: Buffer): Promise { + // Sharp can convert to ICO, but we need to handle the conversion properly. + // For simplicity, we'll create the ICO from the 32x32 PNG + const icon32 = await sharp(pngBuffer).resize(32, 32, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }).toBuffer(); + + // Unfortunately, Sharp doesn't have built-in ICO support, so we'll use a simple approach: + // Generate from the 32x32 and let the browser fall back to it, or keep the existing ICO if available. + // For production use, you'd want a dedicated ICO library like 'icojs' or 'to-ico'. + // For now, we'll just return a warning and skip ICO generation, keeping existing one. + return null as any; // Will be handled specially below +} + +/** + * Generate all favicon sizes from the source PNG. + * Returns a map of filename -> buffer. + */ +async function generateFaviconSizes( + sourceBuffer: Buffer +): Promise> { + const results = new Map(); + + for (const [size, filename] of FAVICON_SIZES) { + const buffer = await sharp(sourceBuffer) + .resize(size, size, { + fit: 'contain', + background: { r: 0, g: 0, b: 0, alpha: 0 }, // Transparent background + }) + .png() + .toBuffer(); + + results.set(filename, buffer); + } + + return results; +} + +/** + * Run the favicon generator. + * Reads the source PNG, generates all sizes, and writes to public/favicon/. + */ +async function run(): Promise { + // Check if source exists + if (!existsSync(FAVICON_SOURCE_PATH)) { + console.warn( + `⚠️ Favicon source not found: ${FAVICON_SOURCE_PATH}` + ); + console.warn( + ` To generate favicons, place a square PNG image at: public/favicon-source.png` + ); + console.warn(` Generation skipped.`); + return; + } + + try { + // Create output directory + mkdirSync(FAVICON_OUTPUT_DIR, { recursive: true }); + + // Read source PNG + const sourceBuffer = readFileSync(FAVICON_SOURCE_PATH); + + // Validate it's a valid image by trying to get metadata + const metadata = await sharp(sourceBuffer).metadata(); + if (!metadata.width || !metadata.height) { + throw new Error('Invalid image: could not read dimensions'); + } + + if (metadata.width < 512 || metadata.height < 512) { + console.warn( + `⚠️ Warning: favicon source is ${metadata.width}x${metadata.height}, ` + + `but 512x512 or larger is recommended for best quality.` + ); + } + + // Generate all sizes + const sizes = await generateFaviconSizes(sourceBuffer); + for (const [filename, buffer] of sizes) { + const outputPath = resolve(FAVICON_OUTPUT_DIR, filename); + writeFileSync(outputPath, buffer); + console.log(`✏️ ${outputPath}`); + } + + // Generate site.webmanifest with brand colors + const manifest = generateWebmanifest(BRAND_CONFIG); + const manifestPath = resolve(FAVICON_OUTPUT_DIR, 'site.webmanifest'); + writeFileSync(manifestPath, manifest, 'utf8'); + console.log(`✏️ ${manifestPath} ← Brand_Config colors & app name`); + + // Note about ICO file + console.log(`ℹ️ favicon.ico: using existing file (set by preexisting build)`); + } catch (error) { + console.error( + `❌ Favicon generation failed: ${error instanceof Error ? error.message : String(error)}` + ); + process.exit(1); + } +} + +// Runtime guard: only execute when this script is run directly +const isMainModule = (() => { + try { + return resolve(process.argv[1] ?? '') === fileURLToPath(import.meta.url); + } catch { + return false; + } +})(); + +if (isMainModule) { + run().catch((error) => { + console.error(`Fatal error: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + }); +} diff --git a/frontend/ai.client/scripts/branding/generate-surface-colors.ts b/frontend/ai.client/scripts/branding/generate-surface-colors.ts new file mode 100644 index 000000000..33c015136 --- /dev/null +++ b/frontend/ai.client/scripts/branding/generate-surface-colors.ts @@ -0,0 +1,243 @@ +/** + * Surface_Colors_Generator (build-time). + * + * Emits a resolved-hex TypeScript module (`surface-colors.ts`) for + * Chart.js chrome (tooltip/axis/grid colors), which cannot consume CSS + * custom properties at canvas render time. Mirrors the existing + * `chart-colors.constants.ts` precedent (documented there as "these are + * intentionally resolved hex values for direct Chart.js consumption") + * and the reasoning `identity.css`'s closing comment already records for + * why this is build-time generation rather than a `getComputedStyle` + * bridge. + * + * Each named chrome color is anchored to its own *current* literal hex + * value (not re-derived from Tailwind's OKLCH gray ramp), and shifted by + * the same per-step OKLCH offset the surface ramp (generate-surface- + * theme.ts) applies to that role's nominal Tailwind step. This guarantees + * the zero-diff property byte-for-byte against today's hand-written + * `CHART_CHROME_COLORS` (Task 7's golden test), including for roles whose + * current literal hex is a pre-v4 (non-OKLCH-round-tripped) Tailwind gray + * — recomputing from `TAILWIND_GRAY_RAMP` instead would silently shift + * those by a channel or two even at default config. + */ + +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import type { BrandConfig, BrandConfigError, BrandSurfaces } from '../../src/branding/brand.types'; +import { DEFAULT_SURFACES } from '../../src/branding/brand.defaults'; +import { BRAND_CONFIG } from '../../src/branding/brand.config'; +import { logSurfaceAcceptance, resolveSurfaces } from '../../src/branding/brand-config.normalize'; +import { hexToOklch, normalizeHex, oklchToSrgb, shortestHueDelta } from './color-math'; +import { STEP_T, TAILWIND_GRAY_RAMP } from './generate-surface-theme'; + +/** The current hand-written chrome hex values (see chart-colors.constants.ts), used as the per-role base. */ +const BASE_CHROME_HEX = { + light: { + titleText: '#111827', // gray-900 + bodyText: '#4b5563', // gray-600 + border: '#e5e7eb', // gray-200 + axisText: '#6b7280', // gray-500 + }, + dark: { + background: '#1f2937', // gray-800 + bodyText: '#d1d5db', // gray-300 + border: '#374151', // gray-700 + axisText: '#9ca3af', // gray-400 + }, +} as const; + +/** Nominal Tailwind step each role's base hex represents, for choosing its blend position `t` (see STEP_T). */ +const ROLE_STEP = { + light: { titleText: 900, bodyText: 600, border: 200, axisText: 500 }, + dark: { background: 800, bodyText: 300, border: 700, axisText: 400 }, +} as const; + +/** sRGB (0-1 triple) -> nearest 6-digit hex. */ +function srgbToHex([r, g, b]: [number, number, number]): string { + const toByte = (c: number) => Math.round(Math.min(1, Math.max(0, c)) * 255); + return `#${[r, g, b].map((c) => toByte(c).toString(16).padStart(2, '0')).join('')}`; +} + +/** Shift `baseHex` by the OKLCH offset blended at position `t` between the light/dark deltas. */ +function shiftHex( + baseHex: string, + t: number, + deltaLight: { l: number; c: number; h: number }, + deltaDark: { l: number; c: number; h: number }, +): string { + const base = hexToOklch(baseHex); + const l = base.l + (t * deltaLight.l + (1 - t) * deltaDark.l); + const c = Math.max(0, base.c + (t * deltaLight.c + (1 - t) * deltaDark.c)); + const h = ((base.h + (t * deltaLight.h + (1 - t) * deltaDark.h)) % 360 + 360) % 360; + return srgbToHex(oklchToSrgb(l, c, h)); +} + +/** The resolved chart chrome colors this generator produces, mirroring CHART_CHROME_COLORS's shape. */ +export interface ResolvedSurfaceColors { + light: { + background: string; + titleText: string; + bodyText: string; + border: string; + axisText: string; + gridLine: string; + }; + dark: { + background: string; + titleText: string; + bodyText: string; + border: string; + axisText: string; + gridLine: string; + }; +} + +/** + * Resolve the chrome color set for the given (already-validated) surfaces. + * At `DEFAULT_SURFACES` every value is `BASE_CHROME_HEX`/the existing + * literals verbatim (zero-diff fast path), exactly matching today's + * hand-written `CHART_CHROME_COLORS`. + */ +export function resolveSurfaceColors(surfaces: BrandSurfaces): ResolvedSurfaceColors { + const lightHex = normalizeHex(surfaces.light); + const darkHex = normalizeHex(surfaces.dark); + const raisedHex = normalizeHex(surfaces.raised); + + const isDefault = lightHex === normalizeHex(DEFAULT_SURFACES.light) && darkHex === normalizeHex(DEFAULT_SURFACES.dark); + + if (isDefault) { + return { + light: { + background: raisedHex, + titleText: BASE_CHROME_HEX.light.titleText, + bodyText: BASE_CHROME_HEX.light.bodyText, + border: BASE_CHROME_HEX.light.border, + axisText: BASE_CHROME_HEX.light.axisText, + gridLine: 'rgba(0, 0, 0, 0.1)', + }, + dark: { + background: BASE_CHROME_HEX.dark.background, + titleText: raisedHex, // dark-mode tooltip text: pure white, tracks --color-white/surfaces.raised + bodyText: BASE_CHROME_HEX.dark.bodyText, + border: BASE_CHROME_HEX.dark.border, + axisText: BASE_CHROME_HEX.dark.axisText, + gridLine: 'rgba(255, 255, 255, 0.1)', + }, + }; + } + + const configuredLight = hexToOklch(lightHex); + const configuredDark = hexToOklch(darkHex); + const deltaLight = { + l: configuredLight.l - TAILWIND_GRAY_RAMP[50].l, + c: configuredLight.c - TAILWIND_GRAY_RAMP[50].c, + h: shortestHueDelta(TAILWIND_GRAY_RAMP[50].h, configuredLight.h), + }; + const deltaDark = { + l: configuredDark.l - TAILWIND_GRAY_RAMP[900].l, + c: configuredDark.c - TAILWIND_GRAY_RAMP[900].c, + h: shortestHueDelta(TAILWIND_GRAY_RAMP[900].h, configuredDark.h), + }; + + const shift = (baseHex: string, step: number) => shiftHex(baseHex, STEP_T[step], deltaLight, deltaDark); + + return { + light: { + background: raisedHex, + titleText: shift(BASE_CHROME_HEX.light.titleText, ROLE_STEP.light.titleText), + bodyText: shift(BASE_CHROME_HEX.light.bodyText, ROLE_STEP.light.bodyText), + border: shift(BASE_CHROME_HEX.light.border, ROLE_STEP.light.border), + axisText: shift(BASE_CHROME_HEX.light.axisText, ROLE_STEP.light.axisText), + gridLine: 'rgba(0, 0, 0, 0.1)', + }, + dark: { + background: shift(BASE_CHROME_HEX.dark.background, ROLE_STEP.dark.background), + titleText: raisedHex, + bodyText: shift(BASE_CHROME_HEX.dark.bodyText, ROLE_STEP.dark.bodyText), + border: shift(BASE_CHROME_HEX.dark.border, ROLE_STEP.dark.border), + axisText: shift(BASE_CHROME_HEX.dark.axisText, ROLE_STEP.dark.axisText), + gridLine: 'rgba(255, 255, 255, 0.1)', + }, + }; +} + +/** + * Validate `config.surfaces` (falling back to Default_Surfaces per-field + * on invalid input, recording a `BrandConfigError`, never throwing) and + * resolve the chart chrome color set. + */ +export function generateSurfaceColors(config: BrandConfig): { colors: ResolvedSurfaceColors; errors: BrandConfigError[] } { + const errors: BrandConfigError[] = []; + const surfaces = resolveSurfaces(config.surfaces, errors); + return { colors: resolveSurfaceColors(surfaces), errors }; +} + +/** Render the resolved colors as a TypeScript module matching CHART_CHROME_COLORS's shape. */ +function renderModule(colors: ResolvedSurfaceColors): string { + const renderMode = (mode: ResolvedSurfaceColors['light']): string => + `{ + background: '${mode.background}' as const, + titleText: '${mode.titleText}' as const, + bodyText: '${mode.bodyText}' as const, + border: '${mode.border}' as const, + axisText: '${mode.axisText}' as const, + gridLine: '${mode.gridLine}' as const, + }`; + + return `/** + * Generated by scripts/branding/generate-surface-colors.ts. Do not edit by + * hand. + * + * Resolved chart-chrome colors derived from the Brand_Surface values in + * src/branding/brand.config.ts, for direct Chart.js consumption (Chart.js + * needs a resolved string, not a CSS custom property). Regenerated + * automatically by the \`prebuild\` / \`prestart\` npm scripts. Consumed by + * chart-colors.constants.ts's \`CHART_CHROME_COLORS\`. + */ +export const GENERATED_SURFACE_CHROME_COLORS = { + light: ${renderMode(colors.light)}, + dark: ${renderMode(colors.dark)}, +} as const; +`; +} + +/** Directory containing this script file (ESM-safe equivalent of `__dirname`). */ +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); + +/** Path to the generated surface-colors TS module, relative to this file. */ +const OUTPUT_PATH = resolve(SCRIPT_DIR, '../../src/styles/generated/surface-colors.ts'); + +/** Run the generator against BRAND_CONFIG and write the output module. */ +function run(): void { + const { colors, errors } = generateSurfaceColors(BRAND_CONFIG); + + for (const error of errors) { + console.warn( + `[generate-surface-colors] ${error.field}: ${error.reason}${ + error.value !== undefined ? ` (value: "${error.value}")` : '' + }`, + ); + } + + logSurfaceAcceptance('generate-surface-colors', BRAND_CONFIG.surfaces, errors); + + mkdirSync(dirname(OUTPUT_PATH), { recursive: true }); + writeFileSync(OUTPUT_PATH, renderModule(colors), 'utf8'); + console.log(`✏️ ${OUTPUT_PATH} ← Brand_Config surfaces`); +} + +// Runtime guard: only execute the file-writing logic when this script is +// run directly, not when the pure functions above are imported elsewhere. +const isMainModule = (() => { + try { + return resolve(process.argv[1] ?? '') === fileURLToPath(import.meta.url); + } catch { + return false; + } +})(); + +if (isMainModule) { + run(); +} diff --git a/frontend/ai.client/scripts/branding/generate-surface-theme.ts b/frontend/ai.client/scripts/branding/generate-surface-theme.ts new file mode 100644 index 000000000..a99fb1f1b --- /dev/null +++ b/frontend/ai.client/scripts/branding/generate-surface-theme.ts @@ -0,0 +1,478 @@ +/** + * Surface_Ramp_Generator (build-time). + * + * Pure transformation from the three Brand_Surface hex anchors + * (`surfaces.light`, `surfaces.dark`, `surfaces.raised`) into a + * brand-configurable neutral ramp: an OKLCH-delta remap of Tailwind's own + * `--color-gray-*` scale, plus a `--color-white` override driven by + * `surfaces.raised`. + * + * This is a separate generator/output file from generate-brand-theme.ts + * (not an extension of it) because: + * - brand-theme-golden.spec.ts's `extractThemeDeclarations` locates the + * closing brace of brand-theme.css with `lastIndexOf('}')`, so a + * second `@theme` block in that file would break it. + * - generate-brand-theme.spec.ts's per-role isolation property test + * slices the generator's output by a fixed 11-line-per-role offset, + * which a surfaces block appended after would also break. + * + * ## The delta remap + * + * Tailwind's gray ramp is not linear in lightness (98.5% at step 50 down + * to 13% at step 950, with a large jump between steps 300 and 400), so + * naively interpolating between two configured anchors would visibly + * flatten or distort the ramp's character even at default values. Instead, + * each of the 11 steps gets an OKLCH *offset* added to its own literal + * Tailwind value: + * + * offset(step) = t · deltaLight + (1 - t) · deltaDark + * + * where `t` is fixed per step (not user-configurable) from where that + * step's literal Tailwind lightness sits between the dark anchor (t = 0, + * pinned to step 900) and the light anchor (t = 1, pinned to step 50). + * Step 950 extrapolates past t = 0 (it is already darker than 900), which + * is exactly what keeps it darker than 900 after remapping. + * + * `deltaLight` / `deltaDark` are computed against `TAILWIND_GRAY_RAMP[50]` + * / `[900]`'s own literal numeric OKLCH triples (not a hex round-trip of + * them), so the algebra is exact for *any* in-band anchor: `final(50) = + * TAILWIND_GRAY_RAMP[50] + (hexToOklch(light) - TAILWIND_GRAY_RAMP[50]) = + * hexToOklch(light)` precisely, and likewise `final(900) = hexToOklch(dark)` + * — this is the "endpoints land exactly" property. + * + * `DEFAULT_SURFACES.light` / `.dark` are the hex round-trips of Tailwind + * gray-50 / gray-900, so they are extremely close to (but not bit- + * identical to) the literal ramp due to 8-bit hex quantization — plugging + * them through the general formula above would perturb every step by a + * few ten-thousandths, which is visually nothing but would break a + * character-for-character text comparison. `generateSurfaceRamp` therefore + * special-cases surfaces that are string-identical to `DEFAULT_SURFACES` + * and emits `TAILWIND_GRAY_RAMP`'s literal declaration text verbatim in + * that case, bypassing the numeric path entirely — this is the "zero-diff + * at defaults" property. + * + * Hue offsets are computed via the shortest arc (`shortestHueDelta`) so + * anchors that straddle the 0/360 boundary interpolate the short way + * around rather than wrapping the long way. + * + * Chroma is clamped at zero (a configured anchor's delta could otherwise + * push a step's chroma negative, which is not a representable color). + * + * `surfaces.raised` does not participate in the gray-*-ramp delta remap — + * it only drives `--color-white` (see `generateWhiteOverride`), per the + * plan's Task 6 split. + */ + +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import type { BrandConfig, BrandConfigError, BrandSurfaces } from '../../src/branding/brand.types'; +import { DEFAULT_SURFACES } from '../../src/branding/brand.defaults'; +import { BRAND_CONFIG } from '../../src/branding/brand.config'; +import { logSurfaceAcceptance, resolveSurfaces } from '../../src/branding/brand-config.normalize'; +import { + contrastRatio, + hexToOklch, + normalizeHex, + oklchToSrgb, + shortestHueDelta, +} from './color-math'; + +/** The 11 Tailwind gray steps in order (same STEPS as generate-brand-theme.ts). */ +export const STEPS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950] as const; + +/** + * Tailwind's `--color-gray-*` scale, copied verbatim (as both the literal + * emitted string and the parsed numeric triple) from + * `node_modules/tailwindcss/theme.css`. Kept in `oklch()` rather than hex, + * matching the discipline `state.css` and `identity.css` document, so + * there is no gamut clamping or wide-gamut shift versus Tailwind's own + * palette. + */ +export const TAILWIND_GRAY_RAMP: Record = { + 50: { l: 0.985, c: 0.002, h: 247.839, text: '98.5% 0.002 247.839' }, + 100: { l: 0.967, c: 0.003, h: 264.542, text: '96.7% 0.003 264.542' }, + 200: { l: 0.928, c: 0.006, h: 264.531, text: '92.8% 0.006 264.531' }, + 300: { l: 0.872, c: 0.01, h: 258.338, text: '87.2% 0.01 258.338' }, + 400: { l: 0.707, c: 0.022, h: 261.325, text: '70.7% 0.022 261.325' }, + 500: { l: 0.551, c: 0.027, h: 264.364, text: '55.1% 0.027 264.364' }, + 600: { l: 0.446, c: 0.03, h: 256.802, text: '44.6% 0.03 256.802' }, + 700: { l: 0.373, c: 0.034, h: 259.733, text: '37.3% 0.034 259.733' }, + 800: { l: 0.278, c: 0.033, h: 256.848, text: '27.8% 0.033 256.848' }, + 900: { l: 0.21, c: 0.034, h: 264.665, text: '21% 0.034 264.665' }, + 950: { l: 0.13, c: 0.028, h: 261.692, text: '13% 0.028 261.692' }, +}; + +/** `t` position of each step between the dark anchor (t=0, step 900) and the light anchor (t=1, step 50), fixed by the literal ramp's own lightness values — never user-configurable. */ +export const STEP_T: Record = (() => { + const l50 = TAILWIND_GRAY_RAMP[50].l; + const l900 = TAILWIND_GRAY_RAMP[900].l; + const span = l50 - l900; + const result: Record = {}; + for (const step of STEPS) { + result[step] = (TAILWIND_GRAY_RAMP[step].l - l900) / span; + } + return result; +})(); + +/** An OKLCH offset (delta), as applied additively to a base ramp step. */ +interface OklchOffset { + l: number; + c: number; + h: number; +} + +/** Compute the OKLCH delta of `configured` versus a ramp anchor step's own literal triple, hue on the shortest arc. */ +function deltaAgainstAnchorStep( + configured: { l: number; c: number; h: number }, + anchorStep: number, +): OklchOffset { + const base = TAILWIND_GRAY_RAMP[anchorStep]; + return { + l: configured.l - base.l, + c: configured.c - base.c, + h: shortestHueDelta(base.h, configured.h), + }; +} + +/** Format a lightness fraction (0-1) as a Tailwind-style percentage, trimming trailing zeros. */ +function formatPercent(l: number): string { + const pct = l * 100; + const rounded = Math.round(pct * 1000) / 1000; + return `${trimTrailingZeros(rounded)}%`; +} + +/** Format a chroma or hue number, trimming trailing zeros, matching Tailwind's own literal style. */ +function formatNumber(n: number): string { + const rounded = Math.round(n * 1000) / 1000; + return trimTrailingZeros(rounded); +} + +function trimTrailingZeros(n: number): string { + // Avoid "-0" and unnecessary trailing ".000"/".0" artifacts. + const normalized = n === 0 ? 0 : n; + return String(normalized); +} + +/** + * Compute the remapped OKLCH ramp for the given surfaces (light/dark only — + * `raised` does not participate; see the module header). Returns, for each + * step, both the literal emit-ready declaration line and the resolved + * numeric OKLCH triple (the latter is what Task 3's contrast clamp reads + * and rewrites). + */ +export function generateSurfaceRamp( + surfaces: Pick, +): Record { + const lightHex = normalizeHex(surfaces.light); + const darkHex = normalizeHex(surfaces.dark); + + // Zero-diff fast path: string-identical to DEFAULT_SURFACES means every + // step's declaration is TAILWIND_GRAY_RAMP verbatim — no float + // arithmetic involved, so there is no risk of a hex-round-trip + // quantization difference perturbing the committed golden output. + if (lightHex === normalizeHex(DEFAULT_SURFACES.light) && darkHex === normalizeHex(DEFAULT_SURFACES.dark)) { + const result: Record = {}; + for (const step of STEPS) { + const base = TAILWIND_GRAY_RAMP[step]; + result[step] = { l: base.l, c: base.c, h: base.h, line: `--color-gray-${step}: oklch(${base.text});` }; + } + return result; + } + + const configuredLight = hexToOklch(lightHex); + const configuredDark = hexToOklch(darkHex); + + // Deltas are computed against the anchor steps' own literal numeric + // triples (not a hex round-trip of them), so the remap lands the + // configured anchor exactly at t=1 (step 50) and t=0 (step 900) — see + // the module header's "endpoints land exactly" note. + const deltaLight = deltaAgainstAnchorStep(configuredLight, 50); + const deltaDark = deltaAgainstAnchorStep(configuredDark, 900); + + const result: Record = {}; + + for (const step of STEPS) { + const base = TAILWIND_GRAY_RAMP[step]; + const t = STEP_T[step]; + + const offset: OklchOffset = { + l: t * deltaLight.l + (1 - t) * deltaDark.l, + c: t * deltaLight.c + (1 - t) * deltaDark.c, + h: t * deltaLight.h + (1 - t) * deltaDark.h, + }; + + const l = base.l + offset.l; + const c = Math.max(0, base.c + offset.c); + const h = ((base.h + offset.h) % 360 + 360) % 360; + + result[step] = { + l, + c, + h, + line: `--color-gray-${step}: oklch(${formatPercent(l)} ${formatNumber(c)} ${formatNumber(h)});`, + }; + } + + return result; +} + +/** + * Light-mode text steps that sit on a light surface (`text-gray-{step}` + * call sites): darkened as needed to clear AA against the *darker* of + * `surfaces.light` and `surfaces.raised` (the binding constraint — a step + * that clears contrast against the darker surface also clears it against + * the lighter one). + */ +const LIGHT_TEXT_STEPS = [500, 600, 700, 900] as const; + +/** + * Dark-mode text steps that sit on the dark surface + * (`dark:text-gray-{step}` call sites): lightened as needed to clear AA + * against `surfaces.dark`. + */ +const DARK_TEXT_STEPS = [200, 300, 400] as const; + +/** + * Step 500 is consumed as *both* `text-gray-500` (860 uses, light mode) + * and `dark:text-gray-500` (209 uses, dark mode) — the same CSS variable + * under two opposing constraints. It is clamped for the light-mode + * constraint (it's in LIGHT_TEXT_STEPS above); this constant marks it for + * a dark-mode contrast *check* that only warns, never clamps, since + * clamping it lighter to satisfy dark mode would un-satisfy the light-mode + * clamp already applied. + */ +const OPPOSING_CONSTRAINT_STEP = 500; + +/** Granularity of the accessible-lightness search, in OKLCH lightness units (mirrors color-math.ts's LIGHTNESS_SEARCH_STEP). */ +const LIGHTNESS_SEARCH_STEP = 0.0005; + +/** + * Find the smallest lightness (holding chroma/hue fixed) that clears + * `target` contrast against `background`, searching directly in OKLCH + * float space rather than round-tripping through an 8-bit hex quantization + * (which is what color-math.ts's findAccessibleLightnessDelta does, and + * which can leave the result a hair under target for some inputs). A + * finer step (0.0005 vs. 0.005) than the brand-color generator's search + * because this clamp's correctness is asserted by an exact property test, + * not just visually. + */ +function searchAccessibleLightness( + l: number, + c: number, + h: number, + background: [number, number, number], + direction: 'darken' | 'lighten', + target = 4.5, +): number { + const sign = direction === 'darken' ? -1 : 1; + const limit = direction === 'darken' ? l : 1 - l; + + for (let magnitude = LIGHTNESS_SEARCH_STEP; magnitude <= limit; magnitude += LIGHTNESS_SEARCH_STEP) { + const candidate = Math.min(1, Math.max(0, l + sign * magnitude)); + if (contrastRatio(oklchToSrgb(candidate, c, h), background) >= target) { + return candidate; + } + } + + return direction === 'darken' ? 0 : 1; +} + +/** Re-render a ramp step's line after its lightness has been clamped, preserving chroma/hue. */ +function renderStepLine(step: number, l: number, c: number, h: number): string { + return `--color-gray-${step}: oklch(${formatPercent(l)} ${formatNumber(c)} ${formatNumber(h)});`; +} + +/** + * Apply the WCAG AA contrast clamp (Task 3) to an already-remapped ramp, + * in place conceptually (returns a new ramp object; does not mutate the + * input). For each `LIGHT_TEXT_STEPS` / `DARK_TEXT_STEPS` entry that fails + * 4.5:1 against its bound surface, nudges lightness only (hue/chroma + * untouched) just far enough to clear the target, and records an + * informational `BrandConfigError` naming the step and measured ratio. + * Step 500's dark-mode pairing is checked and reported as a warning + * without being clamped (see `OPPOSING_CONSTRAINT_STEP`). + * + * Inert when every step already clears its target — including, by + * construction, at Default_Surfaces (Task 2's zero-diff ramp starts from + * Tailwind's own well-contrasted defaults). + */ +export function applyContrastClamp( + ramp: Record, + surfaces: BrandSurfaces, + errors: BrandConfigError[], +): Record { + const result: Record = { ...ramp }; + + const lightSurfaceOklch = hexToOklch(surfaces.light); + const raisedSurfaceOklch = hexToOklch(surfaces.raised); + const lightBgOklch = lightSurfaceOklch.l <= raisedSurfaceOklch.l ? lightSurfaceOklch : raisedSurfaceOklch; + const lightBg = oklchToSrgb(lightBgOklch.l, lightBgOklch.c, lightBgOklch.h); + + const darkSurfaceOklch = hexToOklch(surfaces.dark); + const darkBg = oklchToSrgb(darkSurfaceOklch.l, darkSurfaceOklch.c, darkSurfaceOklch.h); + + const clamp = ( + step: number, + background: [number, number, number], + direction: 'darken' | 'lighten', + backgroundLabel: string, + ): void => { + const entry = result[step]; + const srgb = oklchToSrgb(entry.l, entry.c, entry.h); + const ratio = contrastRatio(srgb, background); + if (ratio >= 4.5) return; + + // Search directly in OKLCH float space (not via a hex round-trip, + // which quantizes to 8-bit-per-channel and can leave the result a + // hair under target) for the smallest lightness step, in the given + // direction, that clears 4.5:1. + const newL = searchAccessibleLightness(entry.l, entry.c, entry.h, background, direction); + + result[step] = { + l: newL, + c: entry.c, + h: entry.h, + line: renderStepLine(step, newL, entry.c, entry.h), + }; + + errors.push({ + field: `surfaces.gray-${step}`, + value: undefined, + reason: `--color-gray-${step} was clamped darker/lighter to clear WCAG AA (4.5:1) against ${backgroundLabel} (measured ratio: ${ratio.toFixed(2)}:1)`, + }); + }; + + for (const step of LIGHT_TEXT_STEPS) { + clamp(step, lightBg, 'darken', 'the resolved light surface'); + } + + for (const step of DARK_TEXT_STEPS) { + clamp(step, darkBg, 'lighten', 'surfaces.dark'); + } + + // Step 500's dark-mode pairing (`dark:text-gray-500`) is checked but never + // clamped: it would conflict with the light-mode clamp already applied + // to the same variable above. + const step500 = result[OPPOSING_CONSTRAINT_STEP]; + const step500Srgb = oklchToSrgb(step500.l, step500.c, step500.h); + const step500DarkRatio = contrastRatio(step500Srgb, darkBg); + if (step500DarkRatio < 4.5) { + errors.push({ + field: `surfaces.gray-${OPPOSING_CONSTRAINT_STEP}`, + value: undefined, + reason: + `dark:text-gray-${OPPOSING_CONSTRAINT_STEP} does not clear WCAG AA (4.5:1) against surfaces.dark ` + + `(measured ratio: ${step500DarkRatio.toFixed(2)}:1). This step is shared with the light-mode ` + + `text-gray-${OPPOSING_CONSTRAINT_STEP} constraint and is clamped for that constraint instead — ` + + `this is a known limitation, not a bug.`, + }); + } + + return result; +} + +/** + * The `--color-white` declaration line, driven by `surfaces.raised` + * (Task 6). Emitted as the literal configured hex — `raised` is a flat + * anchor, not a scale, so there is no delta remap to apply. + */ +function generateWhiteOverride(raisedHex: string): string { + return `--color-white: ${raisedHex};`; +} + +/** + * Produce the full surface `@theme` declaration block (11 `--color-gray-*` + * lines plus `--color-white`), validating each anchor against the + * Brand_Surface hex format and falling back to Default_Surfaces on + * per-field failure — never throwing. + */ +export function generateSurfaceTheme(config: BrandConfig): { css: string; errors: BrandConfigError[] } { + const errors: BrandConfigError[] = []; + const { light, dark, raised } = resolveSurfaces(config.surfaces, errors); + + const remapped = generateSurfaceRamp({ light, dark }); + const ramp = applyContrastClamp(remapped, { light, dark, raised }, errors); + const rampLines = STEPS.map((step) => ramp[step].line).join('\n'); + const whiteLine = generateWhiteOverride(raised); + + return { css: `${rampLines}\n${whiteLine}`, errors }; +} + +/** Directory containing this script file (ESM-safe equivalent of `__dirname`). */ +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); + +/** Path to the generated Tailwind `@theme` surface partial, relative to this file. */ +const OUTPUT_PATH = resolve(SCRIPT_DIR, '../../src/styles/generated/surface-theme.css'); + +/** Wrap the generated surface declarations in an `@theme { ... }` block. */ +function wrapInThemeBlock(css: string): string { + const indented = css + .split('\n') + .map((line) => (line.length > 0 ? ` ${line}` : line)) + .join('\n'); + + return `/** + * Generated by scripts/branding/generate-surface-theme.ts. Do not edit by + * hand. + * + * Tailwind \`@theme\` neutral-ramp declarations derived from the + * Brand_Surface values (\`surfaces.light\`, \`surfaces.dark\`, + * \`surfaces.raised\`) in src/branding/brand.config.ts. Regenerated + * automatically by the \`prebuild\` / \`prestart\` npm scripts. + * + * Overrides Tailwind's default \`--color-gray-*\` scale and + * \`--color-white\`, so every \`bg-gray-*\`, \`border-gray-*\`, + * \`text-gray-*\`, and \`bg-white\` utility (and every \`var(--color-gray-*)\` + * reference in styles.css/component stylesheets) picks up the configured + * surfaces automatically — not just \`body\`. + * + * At Default_Surfaces (surfaces.light = #f9fafb, surfaces.dark = #101828, + * surfaces.raised = #ffffff) every declaration below is emitted verbatim + * from Tailwind's own published gray scale — see + * generate-surface-theme.spec.ts's zero-diff property test. + */ +@theme { +${indented} +} +`; +} + +/** Run the generator against BRAND_CONFIG and write the output partial. */ +function run(): void { + const { css, errors } = generateSurfaceTheme(BRAND_CONFIG); + + for (const error of errors) { + console.warn( + `[generate-surface-theme] ${error.field}: ${error.reason}${ + error.value !== undefined ? ` (value: "${error.value}")` : '' + }`, + ); + } + + // Confirm what each anchor actually resolved to, so acceptance is just + // as visible as rejection — a forker staring at a blank console after + // editing `surfaces` has no way to tell "accepted, rendering now" from + // "silently still running with a stale build" otherwise. + logSurfaceAcceptance('generate-surface-theme', BRAND_CONFIG.surfaces, errors); + + mkdirSync(dirname(OUTPUT_PATH), { recursive: true }); + writeFileSync(OUTPUT_PATH, wrapInThemeBlock(css), 'utf8'); + console.log(`✏️ ${OUTPUT_PATH} ← Brand_Config surfaces`); +} + +// Runtime guard: only execute the file-writing logic when this script is +// run directly (e.g. `tsx scripts/branding/generate-surface-theme.ts`), not +// when the pure functions above are imported elsewhere (e.g. property tests). +const isMainModule = (() => { + try { + return resolve(process.argv[1] ?? '') === fileURLToPath(import.meta.url); + } catch { + return false; + } +})(); + +if (isMainModule) { + run(); +} diff --git a/frontend/ai.client/src/app/admin/admin-scope.model.ts b/frontend/ai.client/src/app/admin/admin-scope.model.ts index 799b926e8..88d9f9b8f 100644 --- a/frontend/ai.client/src/app/admin/admin-scope.model.ts +++ b/frontend/ai.client/src/app/admin/admin-scope.model.ts @@ -26,6 +26,7 @@ export const ADMIN_SCOPE_IDS = [ 'admin.users', 'admin.system_prompts', 'admin.user_menu_links', + 'admin.announcements', 'admin.roles', 'admin.auth_providers', 'admin.audit', diff --git a/frontend/ai.client/src/app/admin/admin.layout.ts b/frontend/ai.client/src/app/admin/admin.layout.ts index 08c2beab2..55707ecd6 100644 --- a/frontend/ai.client/src/app/admin/admin.layout.ts +++ b/frontend/ai.client/src/app/admin/admin.layout.ts @@ -25,6 +25,7 @@ import { heroFingerPrint, heroClipboardDocumentList, heroBars3, + heroMegaphone, heroSparkles, heroInbox, heroFlag, @@ -77,6 +78,7 @@ interface NavGroup { heroFingerPrint, heroClipboardDocumentList, heroBars3, + heroMegaphone, heroSparkles, heroInbox, heroFlag, @@ -117,7 +119,7 @@ interface NavGroup {

Lowercase letters, numbers, and hyphens only.

@if (providerForm.controls.providerId.invalid && providerForm.controls.providerId.touched) { -

+

@if (providerForm.controls.providerId.errors?.['required']) { Provider ID is required } @else if (providerForm.controls.providerId.errors?.['pattern']) { @@ -143,18 +142,18 @@ interface ProviderFormGroup {

@if (providerForm.controls.displayName.invalid && providerForm.controls.displayName.touched) { -

Display name is required

+

Display name is required

}
@@ -164,7 +163,7 @@ interface ProviderFormGroup { type="checkbox" id="enabled" formControlName="enabled" - class="size-4 rounded-xs border-gray-300 text-blue-600 focus:ring-3 focus:ring-blue-500/50 dark:border-gray-600 dark:bg-gray-700" + class="size-4 rounded-xs border-gray-300 text-primary-600 focus:ring-3 focus:ring-primary-500/50 dark:border-gray-600 dark:bg-gray-700" />
-
+

OIDC Configuration

@@ -184,24 +183,24 @@ interface ProviderFormGroup { @if (cognitoRedirectUri()) { -
+
- +
-

+

Required: Add this Redirect URI to your identity provider

-

+

In your IdP's app registration (e.g., Azure Portal, Okta Admin), add the following as an allowed redirect URI:

- + {{ cognitoRedirectUri() }} @@ -215,7 +214,7 @@ interface ProviderFormGroup {
@if (providerForm.controls.issuerUrl.invalid && providerForm.controls.issuerUrl.touched) { -

Issuer URL is required

+

Issuer URL is required

} @if (discoveryResult()) { -

+

Endpoints discovered successfully

} @if (discoveryError()) { -

+

{{ discoveryError() }}

} @@ -258,18 +257,18 @@ interface ProviderFormGroup {
@if (providerForm.controls.clientId.invalid && providerForm.controls.clientId.touched) { -

Client ID is required

+

Client ID is required

}
@@ -278,7 +277,7 @@ interface ProviderFormGroup { @if (isEditMode()) {

@@ -305,7 +304,7 @@ interface ProviderFormGroup { id="scopes" formControlName="scopes" placeholder="openid profile email" - class="mt-1 block w-full rounded-sm border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-blue-500 focus:outline-hidden focus:ring-3 focus:ring-blue-500/50 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500" + class="mt-1 block w-full rounded-xs border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-primary-500 focus:outline-hidden focus:ring-3 focus:ring-primary-500/50 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500" />

Space-separated OAuth scopes. @@ -319,7 +318,7 @@ interface ProviderFormGroup { type="checkbox" id="pkceEnabled" formControlName="pkceEnabled" - class="size-4 rounded-xs border-gray-300 text-blue-600 focus:ring-3 focus:ring-blue-500/50 dark:border-gray-600 dark:bg-gray-700" + class="size-4 rounded-xs border-gray-300 text-primary-600 focus:ring-3 focus:ring-primary-500/50 dark:border-gray-600 dark:bg-gray-700" />

@@ -344,7 +343,7 @@ interface ProviderFormGroup {
-
+

Endpoints

@@ -362,7 +361,7 @@ interface ProviderFormGroup { id="authorizationEndpoint" formControlName="authorizationEndpoint" placeholder="Auto-discovered from issuer" - class="mt-1 block w-full rounded-sm border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-blue-500 focus:outline-hidden focus:ring-3 focus:ring-blue-500/50 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500" + class="mt-1 block w-full rounded-xs border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-primary-500 focus:outline-hidden focus:ring-3 focus:ring-primary-500/50 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500" />
@@ -375,7 +374,7 @@ interface ProviderFormGroup { id="tokenEndpoint" formControlName="tokenEndpoint" placeholder="Auto-discovered from issuer" - class="mt-1 block w-full rounded-sm border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-blue-500 focus:outline-hidden focus:ring-3 focus:ring-blue-500/50 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500" + class="mt-1 block w-full rounded-xs border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-primary-500 focus:outline-hidden focus:ring-3 focus:ring-primary-500/50 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500" />
@@ -388,7 +387,7 @@ interface ProviderFormGroup { id="jwksUri" formControlName="jwksUri" placeholder="Auto-discovered from issuer" - class="mt-1 block w-full rounded-sm border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-blue-500 focus:outline-hidden focus:ring-3 focus:ring-blue-500/50 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500" + class="mt-1 block w-full rounded-xs border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-primary-500 focus:outline-hidden focus:ring-3 focus:ring-primary-500/50 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500" />
@@ -401,7 +400,7 @@ interface ProviderFormGroup { id="userinfoEndpoint" formControlName="userinfoEndpoint" placeholder="Auto-discovered from issuer" - class="mt-1 block w-full rounded-sm border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-blue-500 focus:outline-hidden focus:ring-3 focus:ring-blue-500/50 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500" + class="mt-1 block w-full rounded-xs border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-primary-500 focus:outline-hidden focus:ring-3 focus:ring-primary-500/50 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500" />
@@ -414,14 +413,14 @@ interface ProviderFormGroup { id="endSessionEndpoint" formControlName="endSessionEndpoint" placeholder="Auto-discovered from issuer" - class="mt-1 block w-full rounded-sm border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-blue-500 focus:outline-hidden focus:ring-3 focus:ring-blue-500/50 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500" + class="mt-1 block w-full rounded-xs border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-primary-500 focus:outline-hidden focus:ring-3 focus:ring-primary-500/50 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500" />
-
+

JWT Claim Mappings

@@ -439,7 +438,7 @@ interface ProviderFormGroup { id="userIdClaim" formControlName="userIdClaim" placeholder="sub" - class="mt-1 block w-full rounded-sm border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-blue-500 focus:outline-hidden focus:ring-3 focus:ring-blue-500/50 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500" + class="mt-1 block w-full rounded-xs border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-primary-500 focus:outline-hidden focus:ring-3 focus:ring-primary-500/50 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500" />

Supports URI-style claims (e.g., http://schemas.example.com/claims/id) @@ -455,7 +454,7 @@ interface ProviderFormGroup { id="emailClaim" formControlName="emailClaim" placeholder="email" - class="mt-1 block w-full rounded-sm border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-blue-500 focus:outline-hidden focus:ring-3 focus:ring-blue-500/50 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500" + class="mt-1 block w-full rounded-xs border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-primary-500 focus:outline-hidden focus:ring-3 focus:ring-primary-500/50 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500" />

@@ -468,7 +467,7 @@ interface ProviderFormGroup { id="nameClaim" formControlName="nameClaim" placeholder="name" - class="mt-1 block w-full rounded-sm border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-blue-500 focus:outline-hidden focus:ring-3 focus:ring-blue-500/50 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500" + class="mt-1 block w-full rounded-xs border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-primary-500 focus:outline-hidden focus:ring-3 focus:ring-primary-500/50 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500" />
@@ -481,7 +480,7 @@ interface ProviderFormGroup { id="rolesClaim" formControlName="rolesClaim" placeholder="roles" - class="mt-1 block w-full rounded-sm border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-blue-500 focus:outline-hidden focus:ring-3 focus:ring-blue-500/50 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500" + class="mt-1 block w-full rounded-xs border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-primary-500 focus:outline-hidden focus:ring-3 focus:ring-primary-500/50 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500" />
@@ -494,7 +493,7 @@ interface ProviderFormGroup { id="pictureClaim" formControlName="pictureClaim" placeholder="picture" - class="mt-1 block w-full rounded-sm border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-blue-500 focus:outline-hidden focus:ring-3 focus:ring-blue-500/50 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500" + class="mt-1 block w-full rounded-xs border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-primary-500 focus:outline-hidden focus:ring-3 focus:ring-primary-500/50 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500" />
@@ -507,7 +506,7 @@ interface ProviderFormGroup { id="firstNameClaim" formControlName="firstNameClaim" placeholder="given_name" - class="mt-1 block w-full rounded-sm border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-blue-500 focus:outline-hidden focus:ring-3 focus:ring-blue-500/50 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500" + class="mt-1 block w-full rounded-xs border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-primary-500 focus:outline-hidden focus:ring-3 focus:ring-primary-500/50 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500" />
@@ -520,7 +519,7 @@ interface ProviderFormGroup { id="lastNameClaim" formControlName="lastNameClaim" placeholder="family_name" - class="mt-1 block w-full rounded-sm border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-blue-500 focus:outline-hidden focus:ring-3 focus:ring-blue-500/50 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500" + class="mt-1 block w-full rounded-xs border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-primary-500 focus:outline-hidden focus:ring-3 focus:ring-primary-500/50 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500" />

Used as fallback when the full name claim is empty. @@ -530,7 +529,7 @@ interface ProviderFormGroup { -

+

Validation Rules

@@ -548,7 +547,7 @@ interface ProviderFormGroup { id="userIdPattern" formControlName="userIdPattern" placeholder="e.g., ^\\d{9}$ for 9-digit IDs" - class="mt-1 block w-full rounded-sm border border-gray-300 bg-white px-3 py-2 font-mono text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-blue-500 focus:outline-hidden focus:ring-3 focus:ring-blue-500/50 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500" + class="mt-1 block w-full rounded-xs border border-gray-300 bg-white px-3 py-2 font-mono text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-primary-500 focus:outline-hidden focus:ring-3 focus:ring-primary-500/50 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500" />

If set, user IDs must match this regex pattern during JWT validation. @@ -564,7 +563,7 @@ interface ProviderFormGroup { id="allowedAudiences" formControlName="allowedAudiences" placeholder="Comma-separated audience values" - class="mt-1 block w-full rounded-sm border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-blue-500 focus:outline-hidden focus:ring-3 focus:ring-blue-500/50 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500" + class="mt-1 block w-full rounded-xs border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-primary-500 focus:outline-hidden focus:ring-3 focus:ring-primary-500/50 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500" />

If set, JWT audience must match one of these values. @@ -580,14 +579,14 @@ interface ProviderFormGroup { id="requiredScopes" formControlName="requiredScopes" placeholder="Comma-separated required scopes" - class="mt-1 block w-full rounded-sm border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-blue-500 focus:outline-hidden focus:ring-3 focus:ring-blue-500/50 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500" + class="mt-1 block w-full rounded-xs border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-primary-500 focus:outline-hidden focus:ring-3 focus:ring-primary-500/50 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500" />

-
+

Appearance

@@ -605,7 +604,7 @@ interface ProviderFormGroup { id="logoUrl" formControlName="logoUrl" placeholder="https://example.com/logo.svg" - class="mt-1 block w-full rounded-sm border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-blue-500 focus:outline-hidden focus:ring-3 focus:ring-blue-500/50 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500" + class="mt-1 block w-full rounded-xs border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-primary-500 focus:outline-hidden focus:ring-3 focus:ring-primary-500/50 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500" />
@@ -620,8 +619,8 @@ interface ProviderFormGroup { formControlName="buttonColor" placeholder="#0078D4" maxlength="7" - class="block w-full rounded-sm border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-blue-500 focus:outline-hidden focus:ring-3 focus:ring-blue-500/50 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500" - [class.border-red-500]="providerForm.controls.buttonColor.invalid && providerForm.controls.buttonColor.touched" + class="block w-full rounded-xs border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-primary-500 focus:outline-hidden focus:ring-3 focus:ring-primary-500/50 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500" + [class.border-state-danger-500]="providerForm.controls.buttonColor.invalid && providerForm.controls.buttonColor.touched" /> @if (providerForm.controls.buttonColor.value) {
@if (providerForm.controls.buttonColor.invalid && providerForm.controls.buttonColor.touched) { -

+

Must be a valid hex color (e.g., #0078D4)

} @@ -647,7 +646,7 @@ interface ProviderFormGroup { diff --git a/frontend/ai.client/src/app/admin/auth-providers/pages/provider-list.page.ts b/frontend/ai.client/src/app/admin/auth-providers/pages/provider-list.page.ts index 2fc47b63b..c0d3801a9 100644 --- a/frontend/ai.client/src/app/admin/auth-providers/pages/provider-list.page.ts +++ b/frontend/ai.client/src/app/admin/auth-providers/pages/provider-list.page.ts @@ -22,11 +22,12 @@ import { } from '@ng-icons/heroicons/outline'; import { AuthProvidersService } from '../services/auth-providers.service'; import { AuthProvider } from '../models/auth-provider.model'; +import { SpinnerComponent } from '../../../components/spinner/spinner.component'; @Component({ selector: 'app-auth-provider-list', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [RouterLink, FormsModule, NgIcon], + imports: [RouterLink, FormsModule, NgIcon, SpinnerComponent], providers: [ provideIcons({ heroPlus, @@ -54,7 +55,7 @@ import { AuthProvider } from '../models/auth-provider.model';
Add Provider @@ -72,7 +73,7 @@ import { AuthProvider } from '../models/auth-provider.model'; type="text" [(ngModel)]="searchQuery" placeholder="Search by name or ID..." - class="w-full rounded-sm border border-gray-300 bg-white py-2 pl-10 pr-10 focus:border-blue-500 focus:ring-2 focus:ring-blue-500 dark:border-gray-500 dark:bg-gray-800 dark:text-white dark:placeholder-gray-400" + class="w-full rounded-xs border border-gray-300 bg-white py-2 pl-10 pr-10 focus:border-primary-500 focus:ring-2 focus:ring-primary-500 dark:border-gray-500 dark:bg-gray-800 dark:text-white dark:placeholder-gray-400" /> @if (searchQuery()) { @@ -108,9 +109,7 @@ import { AuthProvider } from '../models/auth-provider.model'; @if (providersResource.isLoading() && providers().length === 0) {
-
+

Loading providers...

@@ -120,7 +119,7 @@ import { AuthProvider } from '../models/auth-provider.model'; @if (providersResource.error()) { -
+

Failed to load authentication providers. Please try again.

@@ -41,7 +41,7 @@

Bedrock Foundatio id="provider" [ngModel]="providerFilter()" (ngModelChange)="providerFilter.set($event)" - class="rounded-2xl border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white" + class="rounded-2xl border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white" > @for (provider of availableProviders(); track provider) { @@ -54,7 +54,7 @@

Bedrock Foundatio id="outputModality" [ngModel]="outputModalityFilter()" (ngModelChange)="outputModalityFilter.set($event)" - class="rounded-2xl border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white" + class="rounded-2xl border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white" > @for (modality of availableOutputModalities(); track modality) { @@ -67,7 +67,7 @@

Bedrock Foundatio id="inferenceType" [ngModel]="inferenceTypeFilter()" (ngModelChange)="inferenceTypeFilter.set($event)" - class="rounded-2xl border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white" + class="rounded-2xl border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white" > @for (type of availableInferenceTypes(); track type) { @@ -80,7 +80,7 @@

Bedrock Foundatio id="customizationType" [ngModel]="customizationTypeFilter()" (ngModelChange)="customizationTypeFilter.set($event)" - class="rounded-2xl border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white" + class="rounded-2xl border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white" > @for (type of availableCustomizationTypes(); track type) { @@ -97,12 +97,12 @@

Bedrock Foundatio placeholder="Max" min="1" max="1000" - class="w-24 rounded-2xl border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder:text-gray-500" + class="w-24 rounded-2xl border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder:text-gray-500" /> @@ -125,9 +125,9 @@

Bedrock Foundatio @if (error()) { -
-

Error loading models

-

{{ error() }}

+
+

Error loading models

+

{{ error() }}

} @@ -156,7 +156,7 @@

Error loading m [attr.aria-expanded]="isExpanded(model.modelId)" [attr.aria-controls]="'model-detail-' + model.modelId" [attr.aria-label]="(isExpanded(model.modelId) ? 'Hide' : 'Show') + ' details for ' + model.modelName" - class="flex size-7 shrink-0 items-center justify-center rounded-2xl text-gray-400 hover:bg-gray-100 hover:text-gray-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:text-gray-500 dark:hover:bg-gray-700 dark:hover:text-gray-200" + class="flex size-7 shrink-0 items-center justify-center rounded-2xl text-gray-400 hover:bg-gray-100 hover:text-gray-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-500 dark:text-gray-500 dark:hover:bg-gray-700 dark:hover:text-gray-200" > Error loading m @if (isModelAdded(model.modelId)) { - + } @else { @@ -208,9 +209,9 @@ const ICON_ACCEPTED_MIME_TYPES = [ type="button" (click)="selectConnectorType(preset.type)" [class.ring-3]="connectorForm.controls.providerType.value === preset.type" - [class.ring-blue-500]="connectorForm.controls.providerType.value === preset.type" - [class.border-blue-500]="connectorForm.controls.providerType.value === preset.type" - class="flex flex-col items-center gap-2 rounded-sm border border-gray-200 bg-white p-4 text-center transition-all hover:border-gray-300 hover:shadow-xs focus:outline-hidden focus:ring-3 focus:ring-blue-500/50 dark:border-gray-600 dark:bg-gray-700 dark:hover:border-gray-500" + [class.ring-primary-500]="connectorForm.controls.providerType.value === preset.type" + [class.border-primary-500]="connectorForm.controls.providerType.value === preset.type" + class="flex flex-col items-center gap-2 rounded-sm border border-gray-200 bg-white p-4 text-center transition-all hover:border-gray-300 hover:shadow-xs focus:outline-hidden focus:ring-3 focus:ring-primary-500/50 dark:border-gray-600 dark:bg-gray-700 dark:hover:border-gray-500" >
@@ -230,7 +231,7 @@ const ICON_ACCEPTED_MIME_TYPES = [

Unique identifier. Lowercase letters, numbers, and hyphens only.

@if (connectorForm.controls.providerId.invalid && connectorForm.controls.providerId.touched) { -

+

@if (connectorForm.controls.providerId.errors?.['required']) { Connector ID is required } @else if (connectorForm.controls.providerId.errors?.['pattern']) { Must be lowercase letters, numbers, and hyphens only } @else if (connectorForm.controls.providerId.errors?.['maxlength']) { Must be at most 64 characters } @@ -255,18 +256,18 @@ const ICON_ACCEPTED_MIME_TYPES = [

@if (connectorForm.controls.displayName.invalid && connectorForm.controls.displayName.touched) { -

Display name is required

+

Display name is required

}
@@ -293,7 +294,7 @@ const ICON_ACCEPTED_MIME_TYPES = [
@@ -327,7 +328,7 @@ const ICON_ACCEPTED_MIME_TYPES = [ type="checkbox" id="enabled" formControlName="enabled" - class="size-4 rounded-xs border-gray-300 text-blue-600 focus:ring-3 focus:ring-blue-500/50 dark:border-gray-600 dark:bg-gray-700" + class="size-4 rounded-xs border-gray-300 text-primary-600 focus:ring-3 focus:ring-primary-500/50 dark:border-gray-600 dark:bg-gray-700" />
@@ -351,7 +352,7 @@ const ICON_ACCEPTED_MIME_TYPES = [
Add Connector @@ -87,7 +88,7 @@ import { type="text" [(ngModel)]="searchQuery" placeholder="Search connectors..." - class="w-full rounded-sm border border-gray-300 bg-white py-2.5 pl-10 pr-10 text-sm/6 placeholder:text-gray-400 focus:border-blue-500 focus:outline-hidden focus:ring-3 focus:ring-blue-500/50 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder:text-gray-500" + class="w-full rounded-sm border border-gray-300 bg-white py-2.5 pl-10 pr-10 text-sm/6 placeholder:text-gray-400 focus:border-primary-500 focus:outline-hidden focus:ring-3 focus:ring-primary-500/50 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder:text-gray-500" /> @if (searchQuery()) { @@ -138,9 +139,7 @@ import { @if (connectorsResource.isLoading() && connectors().length === 0) {
-
+

Loading connectors...

@@ -150,7 +149,7 @@ import { @if (connectorsResource.error()) { -
+

Failed to load connectors

Please check your connection and try again.

@@ -327,7 +326,7 @@ import {

Add Connector @@ -339,9 +338,9 @@ import { @if (connectors().length > 0) { -
-

About Connectors

-
+
+

About Connectors

+

Connector Types: Choose from common presets (Google, Microsoft, GitHub, Canvas) or configure a custom OAuth 2.0 connector.

@@ -434,15 +433,17 @@ export class ConnectorListPage { const baseClasses = 'flex size-10 shrink-0 items-center justify-center rounded-sm'; switch (type) { case 'google': - return `${baseClasses} bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400`; + // Matches the vendor-google mapping already established in + // settings/connectors-settings.page.ts for the same providerType. + return `${baseClasses} bg-vendor-google-100 text-vendor-google-600 dark:bg-vendor-google-900/30 dark:text-vendor-google-400`; case 'microsoft': - return `${baseClasses} bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400`; + return `${baseClasses} bg-vendor-microsoft-100 text-vendor-microsoft-600 dark:bg-vendor-microsoft-900/30 dark:text-vendor-microsoft-400`; case 'github': return `${baseClasses} bg-gray-800 text-white dark:bg-gray-700`; case 'canvas': - return `${baseClasses} bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400`; + return `${baseClasses} bg-vendor-canvas-100 text-vendor-canvas-600 dark:bg-vendor-canvas-900/30 dark:text-vendor-canvas-400`; default: - return `${baseClasses} bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400`; + return `${baseClasses} bg-vendor-generic-100 text-vendor-generic-600 dark:bg-vendor-generic-900/30 dark:text-vendor-generic-400`; } } @@ -450,15 +451,15 @@ export class ConnectorListPage { const baseClasses = 'inline-flex items-center rounded-xs px-2 py-0.5 text-xs font-medium'; switch (type) { case 'google': - return `${baseClasses} bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300`; + return `${baseClasses} bg-vendor-google-100 text-vendor-google-700 dark:bg-vendor-google-900/30 dark:text-vendor-google-300`; case 'microsoft': - return `${baseClasses} bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300`; + return `${baseClasses} bg-vendor-microsoft-100 text-vendor-microsoft-700 dark:bg-vendor-microsoft-900/30 dark:text-vendor-microsoft-400`; case 'github': return `${baseClasses} bg-gray-800 text-white dark:bg-gray-700`; case 'canvas': - return `${baseClasses} bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300`; + return `${baseClasses} bg-vendor-canvas-100 text-vendor-canvas-700 dark:bg-vendor-canvas-900/30 dark:text-vendor-canvas-300`; default: - return `${baseClasses} bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300`; + return `${baseClasses} bg-vendor-generic-100 text-vendor-generic-700 dark:bg-vendor-generic-900/30 dark:text-vendor-generic-300`; } } diff --git a/frontend/ai.client/src/app/admin/costs/admin-costs.page.ts b/frontend/ai.client/src/app/admin/costs/admin-costs.page.ts index 4f7ac47a0..0d7dff973 100644 --- a/frontend/ai.client/src/app/admin/costs/admin-costs.page.ts +++ b/frontend/ai.client/src/app/admin/costs/admin-costs.page.ts @@ -23,6 +23,7 @@ import { TopUsersTableComponent } from './components/top-users-table.component'; import { TopSessionsTableComponent } from './components/top-sessions-table.component'; import { CostTrendsChartComponent } from './components/cost-trends-chart.component'; import { ModelBreakdownComponent } from './components/model-breakdown.component'; +import { SpinnerComponent } from '../../components/spinner/spinner.component'; /** * Admin cost dashboard page. @@ -39,6 +40,7 @@ import { ModelBreakdownComponent } from './components/model-breakdown.component' TopSessionsTableComponent, CostTrendsChartComponent, ModelBreakdownComponent, + SpinnerComponent, ], providers: [provideIcons({ heroArrowLeft, heroArrowDownTray, heroMagnifyingGlass })], changeDetection: ChangeDetectionStrategy.OnPush, @@ -64,7 +66,7 @@ import { ModelBreakdownComponent } from './components/model-breakdown.component' type="button" (click)="onExport()" [disabled]="loading()" - class="inline-flex items-center gap-2 px-4 py-2 bg-white border border-gray-300 rounded-sm text-sm font-medium text-gray-700 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-gray-800 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700 transition-colors" + class="inline-flex items-center gap-2 px-4 py-2 bg-white border border-gray-300 rounded-sm text-sm font-medium text-gray-700 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-gray-800 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700 transition-colors" > Export @@ -98,13 +100,13 @@ import { ModelBreakdownComponent } from './components/model-breakdown.component' [ngModel]="sessionLookupId()" (ngModelChange)="sessionLookupId.set($event)" placeholder="Session ID…" - class="block w-full rounded-2xl border border-gray-300 bg-white py-2 pl-9 pr-3 font-mono text-sm/6 text-gray-900 placeholder:font-sans placeholder:text-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder:text-gray-500" + class="block w-full rounded-2xl border border-gray-300 bg-white py-2 pl-9 pr-3 font-mono text-sm/6 text-gray-900 placeholder:font-sans placeholder:text-gray-400 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder:text-gray-500" />
@@ -115,9 +117,7 @@ import { ModelBreakdownComponent } from './components/model-breakdown.component'
-
+

Loading dashboard data...

@@ -126,12 +126,12 @@ import { ModelBreakdownComponent } from './components/model-breakdown.component' } @else if (error()) {
@@ -143,16 +143,16 @@ import { ModelBreakdownComponent } from './components/model-breakdown.component'
-

+

Failed to load dashboard

-

+

{{ error() }}

diff --git a/frontend/ai.client/src/app/admin/costs/components/cost-trends-chart.component.ts b/frontend/ai.client/src/app/admin/costs/components/cost-trends-chart.component.ts index 6b2860e8f..f483ae9e0 100644 --- a/frontend/ai.client/src/app/admin/costs/components/cost-trends-chart.component.ts +++ b/frontend/ai.client/src/app/admin/costs/components/cost-trends-chart.component.ts @@ -9,6 +9,11 @@ import { } from '@angular/core'; import { Chart, ChartConfiguration, ChartData } from 'chart.js/auto'; import { CostTrend } from '../models'; +import { + CHART_SERIES_COLORS, + CHART_FILL_COLORS, + getChromeColorsForMode, +} from '../../../shared/constants/chart-colors.constants'; /** * Cost trends line chart component. @@ -25,13 +30,16 @@ import { CostTrend } from '../models';

Cost Trends

+
- + Cost
- + Requests
@@ -82,6 +90,9 @@ import { CostTrend } from '../models'; export class CostTrendsChartComponent { data = input.required(); + // Export constant for template access + protected readonly CHART_SERIES_COLORS = CHART_SERIES_COLORS; + private chartCanvas = viewChild>('chartCanvas'); private chart: Chart | null = null; @@ -125,8 +136,7 @@ export class CostTrendsChartComponent { const maxRequests = Math.max(...requestsData); const isDarkMode = document.documentElement.classList.contains('dark'); - const gridColor = isDarkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; - const textColor = isDarkMode ? '#9ca3af' : '#6b7280'; + const chromeColors = getChromeColorsForMode(isDarkMode); const chartData: ChartData<'line'> = { labels, @@ -134,8 +144,8 @@ export class CostTrendsChartComponent { { label: 'Cost ($)', data: costData, - borderColor: '#3b82f6', - backgroundColor: 'rgba(59, 130, 246, 0.1)', + borderColor: CHART_SERIES_COLORS.cost, + backgroundColor: CHART_FILL_COLORS.cost, fill: true, tension: 0.3, yAxisID: 'y', @@ -145,8 +155,8 @@ export class CostTrendsChartComponent { { label: 'Requests', data: requestsData, - borderColor: '#10b981', - backgroundColor: 'rgba(16, 185, 129, 0.1)', + borderColor: CHART_SERIES_COLORS.requests, + backgroundColor: CHART_FILL_COLORS.requests, fill: false, tension: 0.3, yAxisID: 'y1', @@ -171,10 +181,10 @@ export class CostTrendsChartComponent { display: false, }, tooltip: { - backgroundColor: isDarkMode ? '#1f2937' : '#ffffff', - titleColor: isDarkMode ? '#ffffff' : '#111827', - bodyColor: isDarkMode ? '#d1d5db' : '#4b5563', - borderColor: isDarkMode ? '#374151' : '#e5e7eb', + backgroundColor: chromeColors.background, + titleColor: chromeColors.titleText, + bodyColor: chromeColors.bodyText, + borderColor: chromeColors.border, borderWidth: 1, padding: 12, callbacks: { @@ -192,10 +202,10 @@ export class CostTrendsChartComponent { scales: { x: { grid: { - color: gridColor, + color: chromeColors.gridLine, }, ticks: { - color: textColor, + color: chromeColors.axisText, maxRotation: 45, minRotation: 0, }, @@ -207,13 +217,13 @@ export class CostTrendsChartComponent { title: { display: true, text: 'Cost ($)', - color: textColor, + color: chromeColors.axisText, }, grid: { - color: gridColor, + color: chromeColors.gridLine, }, ticks: { - color: textColor, + color: chromeColors.axisText, callback: value => this.formatCurrencyShort(Number(value)), }, suggestedMin: 0, @@ -226,13 +236,13 @@ export class CostTrendsChartComponent { title: { display: true, text: 'Requests', - color: textColor, + color: chromeColors.axisText, }, grid: { drawOnChartArea: false, }, ticks: { - color: textColor, + color: chromeColors.axisText, callback: value => this.formatNumberShort(Number(value)), }, suggestedMin: 0, diff --git a/frontend/ai.client/src/app/admin/costs/components/model-breakdown.component.ts b/frontend/ai.client/src/app/admin/costs/components/model-breakdown.component.ts index 563ba0b78..851f3aa47 100644 --- a/frontend/ai.client/src/app/admin/costs/components/model-breakdown.component.ts +++ b/frontend/ai.client/src/app/admin/costs/components/model-breakdown.component.ts @@ -10,6 +10,11 @@ import { } from '@angular/core'; import { Chart, ChartConfiguration, ChartData } from 'chart.js/auto'; import { ModelUsageSummary } from '../models'; +import { + CHART_CATEGORICAL_PALETTE, + getChromeColorsForMode, + getCategoricalColor, +} from '../../../shared/constants/chart-colors.constants'; type ChartView = 'pie' | 'bar'; @@ -36,10 +41,10 @@ type ChartView = 'pie' | 'bar'; type="button" (click)="setChartView('pie')" class="px-3 py-1 text-sm font-medium rounded-md transition-colors" - [class.bg-blue-100]="chartView() === 'pie'" - [class.text-blue-700]="chartView() === 'pie'" - [class.dark:bg-blue-900/30]="chartView() === 'pie'" - [class.dark:text-blue-400]="chartView() === 'pie'" + [class.bg-primary-100]="chartView() === 'pie'" + [class.text-primary-accessible]="chartView() === 'pie'" + [class.dark:bg-primary-900/30]="chartView() === 'pie'" + [class.dark:text-primary-accessible-dark]="chartView() === 'pie'" [class.text-gray-600]="chartView() !== 'pie'" [class.dark:text-gray-400]="chartView() !== 'pie'" [class.hover:text-gray-900]="chartView() !== 'pie'" @@ -51,10 +56,10 @@ type ChartView = 'pie' | 'bar'; type="button" (click)="setChartView('bar')" class="px-3 py-1 text-sm font-medium rounded-md transition-colors" - [class.bg-blue-100]="chartView() === 'bar'" - [class.text-blue-700]="chartView() === 'bar'" - [class.dark:bg-blue-900/30]="chartView() === 'bar'" - [class.dark:text-blue-400]="chartView() === 'bar'" + [class.bg-primary-100]="chartView() === 'bar'" + [class.text-primary-accessible]="chartView() === 'bar'" + [class.dark:bg-primary-900/30]="chartView() === 'bar'" + [class.dark:text-primary-accessible-dark]="chartView() === 'bar'" [class.text-gray-600]="chartView() !== 'bar'" [class.dark:text-gray-400]="chartView() !== 'bar'" [class.hover:text-gray-900]="chartView() !== 'bar'" @@ -126,20 +131,6 @@ export class ModelBreakdownComponent { private chartCanvas = viewChild>('chartCanvas'); private chart: Chart | null = null; - // Color palette for charts - private readonly colors = [ - '#3b82f6', // blue - '#10b981', // emerald - '#f59e0b', // amber - '#ef4444', // red - '#8b5cf6', // violet - '#ec4899', // pink - '#06b6d4', // cyan - '#84cc16', // lime - '#f97316', // orange - '#6366f1', // indigo - ]; - // Sort data by cost descending sortedData = computed(() => { return [...this.data()].sort((a, b) => b.totalCost - a.totalCost); @@ -167,7 +158,7 @@ export class ModelBreakdownComponent { } getColor(index: number): string { - return this.colors[index % this.colors.length]; + return getCategoricalColor(index); } getPercentage(cost: number): string { @@ -191,13 +182,12 @@ export class ModelBreakdownComponent { const backgroundColors = models.map((_, i) => this.getColor(i)); const isDarkMode = document.documentElement.classList.contains('dark'); - const textColor = isDarkMode ? '#9ca3af' : '#6b7280'; - const gridColor = isDarkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; + const chromeColors = getChromeColorsForMode(isDarkMode); if (view === 'pie') { - this.renderPieChart(canvas, labels, costData, backgroundColors, isDarkMode); + this.renderPieChart(canvas, labels, costData, backgroundColors, isDarkMode, chromeColors); } else { - this.renderBarChart(canvas, labels, costData, backgroundColors, textColor, gridColor, isDarkMode); + this.renderBarChart(canvas, labels, costData, backgroundColors, chromeColors, isDarkMode); } } @@ -206,7 +196,8 @@ export class ModelBreakdownComponent { labels: string[], data: number[], colors: string[], - isDarkMode: boolean + isDarkMode: boolean, + chromeColors: ReturnType ): void { const chartData: ChartData<'doughnut'> = { labels, @@ -214,7 +205,7 @@ export class ModelBreakdownComponent { { data, backgroundColor: colors, - borderColor: isDarkMode ? '#1f2937' : '#ffffff', + borderColor: isDarkMode ? chromeColors.background : '#ffffff', borderWidth: 2, hoverOffset: 4, }, @@ -233,10 +224,10 @@ export class ModelBreakdownComponent { display: false, }, tooltip: { - backgroundColor: isDarkMode ? '#1f2937' : '#ffffff', - titleColor: isDarkMode ? '#ffffff' : '#111827', - bodyColor: isDarkMode ? '#d1d5db' : '#4b5563', - borderColor: isDarkMode ? '#374151' : '#e5e7eb', + backgroundColor: chromeColors.background, + titleColor: chromeColors.titleText, + bodyColor: chromeColors.bodyText, + borderColor: chromeColors.border, borderWidth: 1, padding: 12, callbacks: { @@ -260,8 +251,7 @@ export class ModelBreakdownComponent { labels: string[], data: number[], colors: string[], - textColor: string, - gridColor: string, + chromeColors: ReturnType, isDarkMode: boolean ): void { const chartData: ChartData<'bar'> = { @@ -288,10 +278,10 @@ export class ModelBreakdownComponent { display: false, }, tooltip: { - backgroundColor: isDarkMode ? '#1f2937' : '#ffffff', - titleColor: isDarkMode ? '#ffffff' : '#111827', - bodyColor: isDarkMode ? '#d1d5db' : '#4b5563', - borderColor: isDarkMode ? '#374151' : '#e5e7eb', + backgroundColor: chromeColors.background, + titleColor: chromeColors.titleText, + bodyColor: chromeColors.bodyText, + borderColor: chromeColors.border, borderWidth: 1, padding: 12, callbacks: { @@ -304,10 +294,10 @@ export class ModelBreakdownComponent { scales: { x: { grid: { - color: gridColor, + color: chromeColors.gridLine, }, ticks: { - color: textColor, + color: chromeColors.axisText, callback: value => this.formatCurrencyShort(Number(value)), }, }, @@ -316,7 +306,7 @@ export class ModelBreakdownComponent { display: false, }, ticks: { - color: textColor, + color: chromeColors.axisText, }, }, }, diff --git a/frontend/ai.client/src/app/admin/costs/components/period-selector.component.ts b/frontend/ai.client/src/app/admin/costs/components/period-selector.component.ts index 202004a1c..9603afbcf 100644 --- a/frontend/ai.client/src/app/admin/costs/components/period-selector.component.ts +++ b/frontend/ai.client/src/app/admin/costs/components/period-selector.component.ts @@ -33,7 +33,7 @@ interface PeriodOption { Grant step="1" [(ngModel)]="newQuota" name="quota" - class="mt-1 block w-full rounded-sm border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white" + class="mt-1 block w-full rounded-sm border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white" />
} @else { - {{ grant.monthly_quota_hours }} hrs + ${{ grant.monthly_quota_usd.toFixed(2) }} } @@ -156,15 +156,15 @@

Grant
-
+
- {{ grant.current_month_usage_hours.toFixed(1) }} / {{ grant.monthly_quota_hours }} hrs + {{ grant.current_month_usage_usd.toFixed(1) }} / ${{ grant.monthly_quota_usd.toFixed(2) }}
@@ -178,10 +178,10 @@

Grant @if (confirmingRevoke() === grant.email) {
- Revoke? + Revoke? @@ -195,8 +195,8 @@

Grant } @else {
@@ -85,8 +80,8 @@

{{ formatCurrency(data.total_cost_usd) }}

-
- +
+

@@ -100,8 +95,8 @@

{{ formatHours(data.total_gpu_hours) }}h

-
- +
+

@@ -115,8 +110,8 @@

{{ data.active_user_count }}

-
- +
+
@@ -130,8 +125,8 @@

{{ formatCurrency(avgCostPerUser()) }}

-
- +
+
@@ -251,7 +246,7 @@

Cost by Use
diff --git a/frontend/ai.client/src/app/admin/fine-tuning-costs/fine-tuning-costs.page.ts b/frontend/ai.client/src/app/admin/fine-tuning-costs/fine-tuning-costs.page.ts index 295d6c4bd..c0fed9541 100644 --- a/frontend/ai.client/src/app/admin/fine-tuning-costs/fine-tuning-costs.page.ts +++ b/frontend/ai.client/src/app/admin/fine-tuning-costs/fine-tuning-costs.page.ts @@ -20,12 +20,13 @@ import { } from '@ng-icons/heroicons/outline'; import { FineTuningAdminStateService } from '../fine-tuning-access/services/fine-tuning-admin-state.service'; import { UserCostBreakdown } from '../fine-tuning-access/models/fine-tuning-access.models'; +import { SpinnerComponent } from '../../components/spinner/spinner.component'; type SortField = 'email' | 'total_cost_usd' | 'total_gpu_hours' | 'training_job_count' | 'inference_job_count'; @Component({ selector: 'app-fine-tuning-costs-page', - imports: [FormsModule, NgIcon], + imports: [FormsModule, NgIcon, SpinnerComponent], providers: [ provideIcons({ heroArrowLeft, diff --git a/frontend/ai.client/src/app/admin/gemini-models/gemini-models.page.css b/frontend/ai.client/src/app/admin/gemini-models/gemini-models.page.css index a74b67fd9..d6baad460 100644 --- a/frontend/ai.client/src/app/admin/gemini-models/gemini-models.page.css +++ b/frontend/ai.client/src/app/admin/gemini-models/gemini-models.page.css @@ -1,7 +1,5 @@ /* Page-specific styles for Gemini Models page */ -@import "tailwindcss"; - -@custom-variant dark (&:where(.dark, .dark *)); +@reference "../../../styles/theme.css"; /* Smooth transitions for hover states */ button { diff --git a/frontend/ai.client/src/app/admin/gemini-models/gemini-models.page.html b/frontend/ai.client/src/app/admin/gemini-models/gemini-models.page.html index 39f9251e9..3a2ff1a8a 100644 --- a/frontend/ai.client/src/app/admin/gemini-models/gemini-models.page.html +++ b/frontend/ai.client/src/app/admin/gemini-models/gemini-models.page.html @@ -32,7 +32,7 @@

Google Gemini Mod [ngModel]="searchQuery()" (ngModelChange)="searchQuery.set($event)" placeholder="Search by name or description…" - class="block w-full rounded-2xl border border-gray-300 bg-white py-2 pl-9 pr-3 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder:text-gray-500" + class="block w-full rounded-2xl border border-gray-300 bg-white py-2 pl-9 pr-3 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder:text-gray-500" />

@@ -45,12 +45,12 @@

Google Gemini Mod placeholder="Max" min="1" max="1000" - class="w-24 rounded-2xl border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder:text-gray-500" + class="w-24 rounded-2xl border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder:text-gray-500" /> @@ -73,9 +73,9 @@

Google Gemini Mod @if (error()) { -
-

Error loading models

-

{{ error() }}

+
+

Error loading models

+

{{ error() }}

} @@ -102,7 +102,7 @@

Error loading m [attr.aria-expanded]="isExpanded(model.name)" [attr.aria-controls]="'model-detail-' + model.name" [attr.aria-label]="(isExpanded(model.name) ? 'Hide' : 'Show') + ' details for ' + model.displayName" - class="flex size-7 shrink-0 items-center justify-center rounded-2xl text-gray-400 hover:bg-gray-100 hover:text-gray-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:text-gray-500 dark:hover:bg-gray-700 dark:hover:text-gray-200" + class="flex size-7 shrink-0 items-center justify-center rounded-2xl text-gray-400 hover:bg-gray-100 hover:text-gray-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-500 dark:text-gray-500 dark:hover:bg-gray-700 dark:hover:text-gray-200" > Error loading m {{ model.displayName }} @if (model.thinking) { - + Thinking } @@ -133,14 +133,14 @@

Error loading m @if (isModelAdded(model.name)) { - + } @else { + } +

+ @if (selectedRoles().length === 0) { +

+ Pick at least one role, or choose Everyone. +

+ } + } +

+ } + +
+ + +
+ +
+ + +
+
+
+ + +
+
+ + + @if (ctaIncomplete()) { +

+ A label and an http(s) URL must be given together, or both left blank. +

+ } +
+
+
+ + @if (submitError()) { +
+ {{ submitError() }} +
+ } + +
+ +
+ `, +}) +export class AnnouncementFormPage implements OnInit { + private readonly service = inject(AnnouncementsAdminService); + private readonly rolesService = inject(AppRolesService); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + + protected readonly TITLE_MAX = TITLE_MAX; + protected readonly BODY_MAX_BYTES = BODY_MAX_BYTES; + + protected readonly form = new FormGroup({ + title: new FormControl('', { + nonNullable: true, + validators: [Validators.required, Validators.maxLength(TITLE_MAX)], + }), + summary: new FormControl('', { nonNullable: true }), + body_markdown: new FormControl('', { + nonNullable: true, + validators: [Validators.required], + }), + banner: new FormControl(false, { nonNullable: true }), + modal: new FormControl(false, { nonNullable: true }), + requires_ack: new FormControl(false, { nonNullable: true }), + severity: new FormControl('info', { nonNullable: true }), + publish_at: new FormControl('', { nonNullable: true }), + expires_at: new FormControl('', { nonNullable: true }), + all_roles: new FormControl(true, { nonNullable: true }), + show_to_new_users: new FormControl(false, { nonNullable: true }), + cta_label: new FormControl('', { nonNullable: true }), + cta_url: new FormControl('', { nonNullable: true }), + }); + + protected readonly isSubmitting = signal(false); + protected readonly submitError = signal(null); + protected readonly loadError = signal(null); + private readonly editingId = signal(null); + protected readonly isEdit = computed(() => this.editingId() !== null); + protected readonly selectedRoles = signal([]); + + // FormControl.valueChanges is rxjs, so mirror what the template reacts to. + private readonly titleSig = signal(''); + private readonly bodySig = signal(''); + private readonly bannerSig = signal(false); + private readonly modalSig = signal(false); + private readonly allRolesSig = signal(true); + private readonly publishAtSig = signal(''); + private readonly expiresAtSig = signal(''); + private readonly ctaLabelSig = signal(''); + private readonly ctaUrlSig = signal(''); + // Form validity is not a signal on FormGroup, so mirror it like the rest. + private readonly formValid = signal(false); + + protected readonly modalSelected = this.modalSig.asReadonly(); + protected readonly allRolesSelected = this.allRolesSig.asReadonly(); + + protected readonly previewMarkdown = computed( + () => this.bodySig() || '*(nothing to preview yet)*', + ); + protected readonly titleRemaining = computed(() => + Math.max(0, TITLE_MAX - this.titleSig().length), + ); + protected readonly bodyBytes = computed(() => byteLength(this.bodySig())); + protected readonly bodyOverLimit = computed(() => this.bodyBytes() > BODY_MAX_BYTES); + protected readonly loudSurfaceSelected = computed( + () => this.bannerSig() || this.modalSig(), + ); + protected readonly expiryMissing = computed( + () => this.loudSurfaceSelected() && !this.expiresAtSig(), + ); + protected readonly expiryBeforePublish = computed(() => { + const publish = this.publishAtSig(); + const expires = this.expiresAtSig(); + if (!publish || !expires) return false; + return new Date(expires).getTime() <= new Date(publish).getTime(); + }); + protected readonly ctaIncomplete = computed(() => { + const label = this.ctaLabelSig().trim(); + const url = this.ctaUrlSig().trim(); + if (!label && !url) return false; + if (!label || !url) return true; + return !URL_PATTERN.test(url); + }); + + protected readonly roles = computed(() => this.rolesService.getRoles()); + + /** + * Whether the form can be submitted. + * + * ⚠️ **Every dependency is read unconditionally, and form validity comes from + * a signal.** Both details are load-bearing, and getting either wrong + * deadlocks the button. + * + * A `computed` tracks the signals actually read during its *last* execution, + * so an early `return` shortens its dependency set. This began as a chain of + * guard clauses with `if (this.form.invalid) return false` near the top — + * and `FormGroup.invalid` is a plain getter, not a signal. On the first + * evaluation the form was empty, so it returned there having read only + * `isSubmitting()`; nothing else was tracked, no later edit could schedule a + * recompute, and `isSubmitting` only changes inside `onSubmit`, which the + * disabled button prevented. The submit button could never enable. + * + * So: mirror validity into `formValid` (fed by `statusChanges`), read every + * input before combining them, and never guard-clause out of this computed. + */ + protected readonly canSubmit = computed(() => { + const submitting = this.isSubmitting(); + const formValid = this.formValid(); + const overLimit = this.bodyOverLimit(); + const missingExpiry = this.expiryMissing(); + const badExpiry = this.expiryBeforePublish(); + const badCta = this.ctaIncomplete(); + const rolesChosen = + this.allRolesSig() || this.roles().length === 0 || this.selectedRoles().length > 0; + + return ( + !submitting && formValid && !overLimit && !missingExpiry && !badExpiry && + !badCta && rolesChosen + ); + }); + + async ngOnInit(): Promise { + const c = this.form.controls; + c.title.valueChanges.subscribe(v => this.titleSig.set(v)); + c.body_markdown.valueChanges.subscribe(v => this.bodySig.set(v)); + c.banner.valueChanges.subscribe(v => this.bannerSig.set(v)); + c.modal.valueChanges.subscribe(v => { + this.modalSig.set(v); + // requiresAck is modal-only; clear it so an unchecked modal cannot leave + // a stale flag on the record. + if (!v) c.requires_ack.setValue(false, { emitEvent: false }); + }); + c.all_roles.valueChanges.subscribe(v => this.allRolesSig.set(v)); + c.publish_at.valueChanges.subscribe(v => this.publishAtSig.set(v)); + c.expires_at.valueChanges.subscribe(v => this.expiresAtSig.set(v)); + c.cta_label.valueChanges.subscribe(v => this.ctaLabelSig.set(v)); + c.cta_url.valueChanges.subscribe(v => this.ctaUrlSig.set(v)); + this.form.statusChanges.subscribe(status => this.formValid.set(status === 'VALID')); + this.formValid.set(this.form.valid); + + const id = this.route.snapshot.paramMap.get('id'); + if (!id) return; + + this.editingId.set(id); + try { + const a = await this.service.get(id); + const targeted = (a.target_roles ?? []).filter(r => r !== '*'); + const everyone = (a.target_roles ?? []).includes('*') || targeted.length === 0; + + this.form.patchValue({ + title: a.title, + summary: a.summary ?? '', + body_markdown: a.body_markdown, + banner: a.surfaces.includes('banner'), + modal: a.surfaces.includes('modal'), + requires_ack: a.requires_ack, + severity: a.severity, + publish_at: toLocalInput(a.publish_at), + expires_at: toLocalInput(a.expires_at), + all_roles: everyone, + show_to_new_users: a.show_to_new_users, + cta_label: a.cta_label ?? '', + cta_url: a.cta_url ?? '', + }); + this.selectedRoles.set(targeted); + } catch (err) { + this.loadError.set( + err instanceof Error ? err.message : 'Failed to load the announcement.', + ); + } + } + + protected isRoleSelected(roleId: string): boolean { + return this.selectedRoles().includes(roleId); + } + + protected toggleRole(roleId: string): void { + this.selectedRoles.update(prev => + prev.includes(roleId) ? prev.filter(r => r !== roleId) : [...prev, roleId], + ); + } + + protected roleChipClass(roleId: string): string { + return this.isRoleSelected(roleId) + ? 'border-primary-600 bg-primary-600 text-white' + : 'border-gray-300 bg-white text-gray-700 hover:bg-gray-100 dark:border-gray-500 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'; + } + + protected showError(name: 'title' | 'body_markdown'): boolean { + const c = this.form.get(name); + return !!c && c.invalid && (c.touched || c.dirty); + } + + protected async onSubmit(): Promise { + if (!this.canSubmit()) { + this.form.markAllAsTouched(); + return; + } + this.isSubmitting.set(true); + this.submitError.set(null); + + const raw = this.form.getRawValue(); + const surfaces: AnnouncementSurface[] = ['panel']; + if (raw.banner) surfaces.push('banner'); + if (raw.modal) surfaces.push('modal'); + + const label = raw.cta_label.trim(); + const url = raw.cta_url.trim(); + + const payload: AnnouncementCreateRequest = { + title: raw.title.trim(), + body_markdown: raw.body_markdown, + summary: raw.summary.trim() || null, + surfaces, + severity: raw.severity, + state: 'draft', + publish_at: toIsoOrNull(raw.publish_at), + expires_at: toIsoOrNull(raw.expires_at), + target_roles: raw.all_roles ? ['*'] : this.selectedRoles(), + show_to_new_users: raw.show_to_new_users, + requires_ack: raw.modal && raw.requires_ack, + cta_label: label || null, + cta_url: url || null, + }; + + try { + const id = this.editingId(); + if (id) { + // `state` is not sent on an edit — publish/archive own that transition. + const { state: _state, ...updates } = payload; + await this.service.update(id, updates); + } else { + await this.service.create(payload); + } + this.router.navigate(['/admin/manage-announcements']); + } catch (err: unknown) { + const detail = + (err as { error?: { detail?: string }; message?: string })?.error?.detail ?? + (err as Error)?.message ?? + 'Failed to save the announcement.'; + this.submitError.set( + typeof detail === 'string' ? detail : 'Failed to save the announcement.', + ); + } finally { + this.isSubmitting.set(false); + } + } +} + +/** ISO-8601 → the `datetime-local` shape, in the admin's own timezone. */ +function toLocalInput(iso: string | null | undefined): string { + if (!iso) return ''; + const date = new Date(iso.replace('+00:00Z', 'Z')); + if (Number.isNaN(date.getTime())) return ''; + const pad = (n: number) => String(n).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + +/** `datetime-local` → ISO-8601 UTC, which is what the server stores. */ +function toIsoOrNull(local: string): string | null { + if (!local) return null; + const date = new Date(local); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); +} diff --git a/frontend/ai.client/src/app/admin/manage-announcements/manage-announcements.page.spec.ts b/frontend/ai.client/src/app/admin/manage-announcements/manage-announcements.page.spec.ts new file mode 100644 index 000000000..ab65e7cf3 --- /dev/null +++ b/frontend/ai.client/src/app/admin/manage-announcements/manage-announcements.page.spec.ts @@ -0,0 +1,296 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { signal } from '@angular/core'; +import { ManageAnnouncementsPage } from './manage-announcements.page'; +import { AnnouncementsAdminService } from './services/announcements-admin.service'; +import { + Announcement, + AnnouncementState, + AnnouncementStats, +} from './models/announcement.model'; + +function makeAnnouncement(overrides: Partial = {}): Announcement { + return { + announcement_id: 'a1', + title: 'Skills are here', + body_markdown: '# Skills', + summary: null, + surfaces: ['panel'], + severity: 'info', + state: 'draft', + publish_at: '2026-01-01T00:00:00Z', + expires_at: null, + target_roles: ['*'], + show_to_new_users: false, + requires_ack: false, + cta_label: null, + cta_url: null, + revision: 1, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + created_by: 'admin@example.com', + ...overrides, + }; +} + +function makeStats(overrides: Partial = {}): AnnouncementStats { + return { + announcement_id: 'a1', + revision: 1, + seen: 12, + dismissed: 8, + acknowledged: 3, + targeted: 40, + ...overrides, + }; +} + +describe('ManageAnnouncementsPage', () => { + let items: ReturnType>; + let statsById: ReturnType>>; + let service: any; + let confirmSpy: ReturnType; + + beforeEach(() => { + TestBed.resetTestingModule(); + items = signal([]); + statsById = signal>(new Map()); + service = { + ensureLoaded: vi.fn(), + announcements: items, + loadStats: vi.fn(async () => undefined), + statsFor: (id: string) => statsById().get(id) ?? null, + announcementsResource: { isLoading: () => false, error: () => null }, + publish: vi.fn(async () => makeAnnouncement({ state: 'published' })), + archive: vi.fn(async () => makeAnnouncement({ state: 'archived' })), + revise: vi.fn(async () => makeAnnouncement({ revision: 2 })), + remove: vi.fn(async () => undefined), + }; + TestBed.configureTestingModule({ + providers: [{ provide: AnnouncementsAdminService, useValue: service }], + }); + confirmSpy = vi.spyOn(globalThis, 'confirm').mockReturnValue(true); + }); + + afterEach(() => { + confirmSpy.mockRestore(); + TestBed.resetTestingModule(); + }); + + function createPage() { + return TestBed.runInInjectionContext(() => new ManageAnnouncementsPage()) as any; + } + + it('loads the admin list on construction', () => { + createPage(); + expect(service.ensureLoaded).toHaveBeenCalled(); + }); + + describe('publish affordance', () => { + it.each<[AnnouncementState, boolean]>([ + ['draft', true], + ['scheduled', true], + ['published', false], + // Archived is terminal — the server 400s, so do not offer the button. + ['archived', false], + ])('state %s → publishable: %s', (state, expected) => { + const page = createPage(); + expect(page.canPublish(makeAnnouncement({ state }))).toBe(expected); + }); + }); + + describe('destructive actions confirm first', () => { + it('asks before bumping the revision, and says what it does', async () => { + // "Show again" re-surfaces the announcement for everyone who dismissed + // it — the one thing an admin fixing a typo must not trigger by accident. + const page = createPage(); + await page.onRevise(makeAnnouncement()); + + expect(confirmSpy).toHaveBeenCalledOnce(); + expect(confirmSpy.mock.calls[0][0]).toContain('dismissed it will see it once more'); + expect(service.revise).toHaveBeenCalledWith('a1'); + }); + + it('does not revise when the confirm is declined', async () => { + confirmSpy.mockReturnValue(false); + const page = createPage(); + await page.onRevise(makeAnnouncement()); + + expect(service.revise).not.toHaveBeenCalled(); + }); + + it('asks before archiving and mentions acks are kept', async () => { + const page = createPage(); + await page.onArchive(makeAnnouncement()); + + expect(confirmSpy.mock.calls[0][0]).toContain('acknowledgements are kept'); + expect(service.archive).toHaveBeenCalledWith('a1'); + }); + + it('asks before deleting and points at archive instead', async () => { + const page = createPage(); + await page.onDelete(makeAnnouncement()); + + expect(confirmSpy.mock.calls[0][0]).toContain('Archive instead'); + expect(service.remove).toHaveBeenCalledWith('a1'); + }); + + it('publishes without a confirm — it is reversible by archiving', async () => { + const page = createPage(); + await page.onPublish(makeAnnouncement()); + + expect(confirmSpy).not.toHaveBeenCalled(); + expect(service.publish).toHaveBeenCalledWith('a1'); + }); + }); + + it('surfaces the server detail when an action fails', async () => { + service.publish = vi.fn(async () => { + throw { error: { detail: "cannot publish an announcement in state 'archived'" } }; + }); + const page = createPage(); + await page.onPublish(makeAnnouncement()); + + expect(page.actionError()).toContain('cannot publish'); + expect(page.busyId()).toBeNull(); + }); + + describe('row summary', () => { + it('describes an untargeted announcement as Everyone', () => { + items.set([makeAnnouncement()]); + const page = createPage(); + expect(page.rows()[0].audience).toBe('Everyone'); + }); + + it('lists the roles when targeted', () => { + items.set([makeAnnouncement({ target_roles: ['faculty', 'staff'] })]); + const page = createPage(); + expect(page.rows()[0].audience).toBe('faculty, staff'); + }); + + it('calls out showToNewUsers, since it is the surprising setting', () => { + items.set([makeAnnouncement({ show_to_new_users: true })]); + const page = createPage(); + expect(page.rows()[0].audience).toContain('including users who join later'); + }); + + it('says "Live since" for a published announcement and "Publishes" otherwise', () => { + items.set([ + makeAnnouncement({ announcement_id: 'live', state: 'published' }), + makeAnnouncement({ announcement_id: 'draft', state: 'draft' }), + ]); + const page = createPage(); + expect(page.rows()[0].timing).toContain('Live since'); + expect(page.rows()[1].timing).toContain('Publishes'); + }); + + it('mentions the expiry when there is one', () => { + items.set([makeAnnouncement({ expires_at: '2099-01-01T00:00:00Z' })]); + const page = createPage(); + expect(page.rows()[0].timing).toContain('expires'); + }); + + it('renders a dash rather than "Invalid Date" for junk', () => { + items.set([makeAnnouncement({ publish_at: 'not a date' })]); + const page = createPage(); + expect(page.rows()[0].timing).toContain('—'); + }); + + it('tolerates the legacy +00:00Z timestamp spelling', () => { + items.set([makeAnnouncement({ publish_at: '2026-03-04T00:00:00+00:00Z' })]); + const page = createPage(); + expect(page.rows()[0].timing).not.toContain('—'); + }); + }); + + describe('surface chips', () => { + it('maps each surface to its own icon', () => { + const page = createPage(); + expect(page.surfaceIcon('panel')).toBe('heroWindow'); + expect(page.surfaceIcon('banner')).toBe('heroRectangleGroup'); + expect(page.surfaceIcon('modal')).toBe('heroBellAlert'); + }); + + it('gives every state a distinct chip style', () => { + const page = createPage(); + const states: AnnouncementState[] = ['draft', 'scheduled', 'published', 'archived']; + const classes = states.map(s => page.stateChipClass(s)); + expect(new Set(classes).size).toBe(states.length); + }); + }); + + describe('reach', () => { + it('says nothing for a draft — zeroes would read as "nobody engaged"', () => { + items.set([makeAnnouncement({ state: 'draft' })]); + statsById.set(new Map([['a1', makeStats()]])); + const page = createPage(); + expect(page.rows()[0].reach).toBeNull(); + }); + + it('says nothing until the stats have arrived', () => { + items.set([makeAnnouncement({ state: 'published' })]); + const page = createPage(); + expect(page.rows()[0].reach).toBeNull(); + }); + + it('renders the funnel once stats land', () => { + items.set([makeAnnouncement({ state: 'published' })]); + const page = createPage(); + statsById.set(new Map([['a1', makeStats()]])); + + const reach = page.rows()[0].reach!; + expect(reach).toContain('12 seen'); + expect(reach).toContain('8 dismissed'); + expect(reach).toContain('~40 targeted'); + expect(reach).toContain('estimate'); + }); + + it('omits acknowledged unless one was ever asked for', () => { + items.set([ + makeAnnouncement({ state: 'published', requires_ack: false }), + ]); + const page = createPage(); + statsById.set(new Map([['a1', makeStats()]])); + expect(page.rows()[0].reach).not.toContain('acknowledged'); + }); + + it('shows acknowledged for a requiresAck announcement', () => { + items.set([makeAnnouncement({ state: 'published', requires_ack: true })]); + const page = createPage(); + statsById.set(new Map([['a1', makeStats()]])); + expect(page.rows()[0].reach).toContain('3 acknowledged'); + }); + + it('says the audience is not estimated rather than implying zero', () => { + // A role-scoped announcement cannot be counted — `targeted` is null, and + // rendering that as "of ~0" would read as nobody being targeted. + items.set([ + makeAnnouncement({ state: 'published', target_roles: ['faculty'] }), + ]); + const page = createPage(); + statsById.set(new Map([['a1', makeStats({ targeted: null })]])); + + const reach = page.rows()[0].reach!; + expect(reach).toContain('audience not estimated'); + expect(reach).not.toContain('~0'); + }); + + it('still reports reach for an archived announcement', () => { + // Archiving stops it showing but keeps the acks — the record is the + // reason to archive rather than delete. + items.set([makeAnnouncement({ state: 'archived' })]); + const page = createPage(); + statsById.set(new Map([['a1', makeStats()]])); + expect(page.rows()[0].reach).toContain('12 seen'); + }); + + it('requests stats once the list resolves', () => { + items.set([makeAnnouncement({ state: 'published' })]); + createPage(); + // The fetch is an effect, so it needs a flush — the page is constructed + // directly here rather than through a fixture. + TestBed.tick(); + expect(service.loadStats).toHaveBeenCalled(); + }); + }); +}); diff --git a/frontend/ai.client/src/app/admin/manage-announcements/manage-announcements.page.ts b/frontend/ai.client/src/app/admin/manage-announcements/manage-announcements.page.ts new file mode 100644 index 000000000..a4542cde2 --- /dev/null +++ b/frontend/ai.client/src/app/admin/manage-announcements/manage-announcements.page.ts @@ -0,0 +1,417 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + signal, +} from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { + heroPencil, + heroTrash, + heroPlus, + heroMegaphone, + heroPaperAirplane, + heroArchiveBox, + heroArrowPath, + heroWindow, + heroRectangleGroup, + heroBellAlert, +} from '@ng-icons/heroicons/outline'; +import { AnnouncementsAdminService } from './services/announcements-admin.service'; +import { Announcement, AnnouncementState } from './models/announcement.model'; +import { parseIso } from '../../utils/date'; + +/** + * Admin list of feature announcements. + * + * Mirrors `manage-user-menu-links`, plus the lifecycle actions an + * announcement has and a link does not: publish, archive, and "Show again" + * (the revision bump, §D4). + */ +@Component({ + selector: 'app-manage-announcements-page', + imports: [RouterLink, NgIcon], + providers: [ + provideIcons({ + heroPencil, + heroTrash, + heroPlus, + heroMegaphone, + heroPaperAirplane, + heroArchiveBox, + heroArrowPath, + heroWindow, + heroRectangleGroup, + heroBellAlert, + }), + ], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+
+
+

Announcements

+

+ Tell users what changed. Everything published appears in What's New; a + banner or modal additionally puts it in front of them. +

+
+ + + New announcement + +
+ + +
+ Panel by default, banner when it matters, modal when it is a policy change. + Every extra interruption costs attention on the next one. Users only ever + see one banner and one modal at a time, no matter how many are eligible. +
+ + @if (loadError()) { +
+ Failed to load announcements. {{ loadError() }} +
+ } + + @if (actionError()) { +
+ {{ actionError() }} +
+ } + + @if (announcements().length === 0 && !isLoading()) { +
+
+ } @else { +
+ @for (item of rows(); track item.announcement.announcement_id) { +
+
+
+
+ + {{ item.announcement.title }} + + + + {{ stateLabel(item.announcement.state) }} + + + @for (surface of item.announcement.surfaces; track surface) { + + + } + + @if (item.announcement.requires_ack) { + + Requires acknowledgement + + } + + @if (item.announcement.revision > 1) { + + rev {{ item.announcement.revision }} + + } +
+ +

+ {{ summarize(item.announcement.body_markdown) }} +

+ +

+ {{ item.timing }} + @if (item.audience) { + · {{ item.audience }} + } +

+ + @if (item.reach; as reach) { +

+ Reach + {{ reach }} +

+ } +
+ +
+ @if (canPublish(item.announcement)) { + + } + + @if (item.announcement.state === 'published') { + + + } + + + + Edit + + + +
+
+
+ } +
+ } +
+ `, +}) +export class ManageAnnouncementsPage { + private readonly service = inject(AnnouncementsAdminService); + + constructor() { + this.service.ensureLoaded(); + + // Reach is a second endpoint per announcement, so it is fetched once the + // list resolves rather than blocking it. `loadStats` skips ids it has + // already requested, so re-running on every list change is cheap and the + // effect cannot feed itself. + effect(() => { + const announcements = this.announcements(); + if (announcements.length === 0) return; + void this.service.loadStats(announcements); + }); + } + + protected readonly announcements = this.service.announcements; + protected readonly isLoading = computed(() => + this.service.announcementsResource.isLoading(), + ); + protected readonly loadError = computed(() => { + const err = this.service.announcementsResource.error(); + if (!err) return null; + return err instanceof Error ? err.message : String(err); + }); + + protected readonly busyId = signal(null); + protected readonly actionError = signal(null); + + protected readonly rows = computed(() => + this.announcements().map(announcement => ({ + announcement, + timing: this.describeTiming(announcement), + audience: this.describeAudience(announcement), + reach: this.describeReach(announcement), + })), + ); + + protected readonly reachHint = + 'Approximate. Counts are cumulative — anyone who acknowledged also ' + + 'counts as dismissed and as seen. The audience size is an estimate that ' + + 'moves as people join.'; + + protected canPublish(a: Announcement): boolean { + // Archived is terminal; the server refuses to publish out of it, so do not + // offer a button that returns a 400. + return a.state === 'draft' || a.state === 'scheduled'; + } + + protected stateLabel(state: AnnouncementState): string { + return state.charAt(0).toUpperCase() + state.slice(1); + } + + protected stateChipClass(state: AnnouncementState): string { + switch (state) { + case 'published': + return 'bg-state-success-100 text-state-success-800 dark:bg-state-success-900/40 dark:text-state-success-300'; + case 'scheduled': + return 'bg-state-info-100 text-state-info-700 dark:bg-state-info-900/40 dark:text-state-info-300'; + case 'archived': + return 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400'; + default: + return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'; + } + } + + protected surfaceIcon(surface: string): string { + if (surface === 'banner') return 'heroRectangleGroup'; + if (surface === 'modal') return 'heroBellAlert'; + return 'heroWindow'; + } + + protected surfaceHint(surface: string): string { + if (surface === 'banner') + return 'A pill above the chat composer, in the chat view only. At most one at a time.'; + if (surface === 'modal') return 'A dialog on next load. At most one at a time.'; + return "Always on. The What's New entry in the user menu."; + } + + protected summarize(markdown: string | null | undefined): string { + if (!markdown) return '(empty)'; + const stripped = markdown.replace(/[#*_`>\-]/g, '').replace(/\s+/g, ' ').trim(); + return stripped.length > 140 ? stripped.slice(0, 140) + '…' : stripped; + } + + /** + * One line of reach, or null when there is nothing honest to say. + * + * Null for a draft (nothing has been shown, so a row of zeroes would read + * as "nobody engaged" rather than "not sent yet") and while the fetch is + * still in flight. + * + * The counts are a funnel, not a partition — see `AnnouncementStats`. They + * are rendered as such: "12 seen · 8 dismissed" means 8 of those 12, not 20 + * people. + */ + private describeReach(a: Announcement): string | null { + if (!AnnouncementsAdminService.hasReach(a)) return null; + const stats = this.service.statsFor(a.announcement_id); + if (!stats) return null; + + const parts = [`${stats.seen} seen`, `${stats.dismissed} dismissed`]; + // Only meaningful where an acknowledgement was ever asked for. + if (a.requires_ack) parts.push(`${stats.acknowledged} acknowledged`); + + const line = parts.join(' · '); + return stats.targeted != null + ? `${line} — of ~${stats.targeted} targeted (estimate)` + : `${line} (audience not estimated)`; + } + + private describeTiming(a: Announcement): string { + const published = this.formatDate(a.publish_at); + const verb = a.state === 'published' ? 'Live since' : 'Publishes'; + const base = `${verb} ${published}`; + return a.expires_at ? `${base} · expires ${this.formatDate(a.expires_at)}` : base; + } + + private describeAudience(a: Announcement): string | null { + const roles = a.target_roles ?? []; + const audience = roles.includes('*') || roles.length === 0 + ? 'Everyone' + : roles.join(', '); + return a.show_to_new_users + ? `${audience} (including users who join later)` + : audience; + } + + private formatDate(value: string | null | undefined): string { + if (!value) return '—'; + const date = parseIso(value); + if (Number.isNaN(date.getTime())) return '—'; + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + } + + protected async onPublish(a: Announcement): Promise { + await this.run(a, () => this.service.publish(a.announcement_id)); + } + + protected async onArchive(a: Announcement): Promise { + if (!confirm(`Archive "${a.title}"? It stops showing, but acknowledgements are kept.`)) return; + await this.run(a, () => this.service.archive(a.announcement_id)); + } + + protected async onRevise(a: Announcement): Promise { + // Worth a confirm: this re-surfaces the announcement for everyone who had + // already dismissed it, which is exactly what an admin fixing a typo does + // NOT want (that is what Edit is for). + if ( + !confirm( + `Show "${a.title}" again?\n\nThis bumps the revision, so everyone who dismissed it will see it once more. Editing the text does not do this.`, + ) + ) { + return; + } + await this.run(a, () => this.service.revise(a.announcement_id)); + } + + protected async onDelete(a: Announcement): Promise { + if (!confirm(`Delete "${a.title}"? This cannot be undone. Archive instead to keep the record.`)) return; + await this.run(a, () => this.service.remove(a.announcement_id)); + } + + private async run(a: Announcement, action: () => Promise): Promise { + this.busyId.set(a.announcement_id); + this.actionError.set(null); + try { + await action(); + } catch (err: unknown) { + const detail = + (err as { error?: { detail?: string }; message?: string })?.error?.detail ?? + (err as Error)?.message ?? + 'The action failed. Please try again.'; + this.actionError.set(detail); + } finally { + this.busyId.set(null); + } + } +} diff --git a/frontend/ai.client/src/app/admin/manage-announcements/models/announcement.model.ts b/frontend/ai.client/src/app/admin/manage-announcements/models/announcement.model.ts new file mode 100644 index 000000000..09504e8bf --- /dev/null +++ b/frontend/ai.client/src/app/admin/manage-announcements/models/announcement.model.ts @@ -0,0 +1,97 @@ +/** + * Admin-side announcement types — the client mirror of + * `apis/shared/announcements/models.py` (`AnnouncementResponse`). + * + * Wider than the user-facing model in + * `services/announcements/announcement.model.ts`, which deliberately omits + * `state`, `target_roles`, `show_to_new_users` and `created_by`. Keep them + * separate: collapsing them into one type is how admin-only fields end up + * being served to users. + * + * See `docs/specs/feature-announcements.md`. + */ + +export type AnnouncementSurface = 'panel' | 'banner' | 'modal'; +export type AnnouncementSeverity = 'info' | 'success' | 'warning'; +export type AnnouncementState = 'draft' | 'scheduled' | 'published' | 'archived'; + +export interface Announcement { + announcement_id: string; + title: string; + body_markdown: string; + summary?: string | null; + surfaces: AnnouncementSurface[]; + severity: AnnouncementSeverity; + state: AnnouncementState; + publish_at: string; + expires_at?: string | null; + /** + * A **display filter, not an RBAC grant** (spec §D9). This list is written + * only to the announcement; it is never mirrored into a role's `granted*` + * arrays, and `apis/shared/rbac/` knows nothing about it. Visibility of a + * notice is not access control. + */ + target_roles: string[]; + show_to_new_users: boolean; + requires_ack: boolean; + cta_label?: string | null; + cta_url?: string | null; + revision: number; + created_at: string; + updated_at: string; + created_by?: string | null; +} + +export interface AnnouncementListResponse { + announcements: Announcement[]; + total: number; +} + +/** POST body. The server always creates as a draft or scheduled — never live. */ +export interface AnnouncementCreateRequest { + title: string; + body_markdown: string; + summary?: string | null; + surfaces: AnnouncementSurface[]; + severity: AnnouncementSeverity; + state: 'draft' | 'scheduled'; + publish_at?: string | null; + expires_at?: string | null; + target_roles: string[]; + show_to_new_users: boolean; + requires_ack: boolean; + cta_label?: string | null; + cta_url?: string | null; +} + +/** + * PATCH body. `state` and `revision` are absent by design — both are + * transitions, owned by the publish / archive / revise endpoints, not by an + * edit that looks like a body change. + */ +export type AnnouncementUpdateRequest = Partial< + Omit +>; + + +/** + * `GET /admin/announcements/{id}/stats` — reach for the **current** revision. + * + * The three counts are a **funnel, not a partition**: the stored rank only + * ever rises through seen → dismissed → acknowledged (§D2), so someone who + * acknowledged is counted in all three and `seen >= dismissed >= + * acknowledged` always holds. "Only ever saw it" is `seen - dismissed`. + * Rendering them as disjoint buckets would understate every stage. + * + * All of it is approximate and the UI must say so (§11). `targeted` is null + * when the audience is role-scoped rather than everyone — that means **not + * estimated, not zero**. + */ +export interface AnnouncementStats { + announcement_id: string; + revision: number; + seen: number; + dismissed: number; + acknowledged: number; + targeted?: number | null; +} diff --git a/frontend/ai.client/src/app/admin/manage-announcements/services/announcements-admin.service.spec.ts b/frontend/ai.client/src/app/admin/manage-announcements/services/announcements-admin.service.spec.ts new file mode 100644 index 000000000..838cfdb27 --- /dev/null +++ b/frontend/ai.client/src/app/admin/manage-announcements/services/announcements-admin.service.spec.ts @@ -0,0 +1,188 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { + HttpTestingController, + provideHttpClientTesting, +} from '@angular/common/http/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { ConfigService } from '../../../services/config.service'; +import { AnnouncementsService } from '../../../services/announcements/announcements.service'; +import { AnnouncementsAdminService } from './announcements-admin.service'; +import { Announcement } from '../models/announcement.model'; + +const API = 'http://api.test'; + +function makeAnnouncement(overrides: Partial = {}): Announcement { + return { + announcement_id: 'a1', + title: 'Skills are here', + body_markdown: '# Skills', + summary: null, + surfaces: ['panel'], + severity: 'info', + state: 'published', + publish_at: '2026-01-01T00:00:00Z', + expires_at: null, + target_roles: ['*'], + show_to_new_users: false, + requires_ack: false, + cta_label: null, + cta_url: null, + revision: 1, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + created_by: 'admin@example.com', + ...overrides, + }; +} + +describe('AnnouncementsAdminService — reach', () => { + let service: AnnouncementsAdminService; + let http: HttpTestingController; + + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { provide: ConfigService, useValue: { appApiUrl: () => API } }, + { provide: AnnouncementsService, useValue: { reload: vi.fn() } }, + ], + }); + service = TestBed.inject(AnnouncementsAdminService); + http = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + http.verify(); + TestBed.resetTestingModule(); + }); + + function statsUrl(id: string) { + return `${API}/admin/announcements/${id}/stats`; + } + + it('fetches reach for a published announcement', async () => { + const pending = service.loadStats([makeAnnouncement()]); + http.expectOne(statsUrl('a1')).flush({ + announcement_id: 'a1', + revision: 1, + seen: 12, + dismissed: 8, + acknowledged: 3, + targeted: 40, + }); + await pending; + + expect(service.statsFor('a1')?.seen).toBe(12); + }); + + it('does not ask about a draft — nothing has been shown', async () => { + await service.loadStats([makeAnnouncement({ state: 'draft' })]); + http.expectNone(statsUrl('a1')); + expect(service.statsFor('a1')).toBeNull(); + }); + + it('asks once per announcement, however often the list re-renders', async () => { + const announcements = [makeAnnouncement()]; + const first = service.loadStats(announcements); + http.expectOne(statsUrl('a1')).flush({ + announcement_id: 'a1', + revision: 1, + seen: 1, + dismissed: 0, + acknowledged: 0, + targeted: null, + }); + await first; + + await service.loadStats(announcements); + http.expectNone(statsUrl('a1')); + }); + + it('re-asks after a revision bump — the counters restart', async () => { + // "Show again" starts a fresh count, so a cached entry from the previous + // revision would report stale reach for a broadcast that just went out. + const first = service.loadStats([makeAnnouncement({ revision: 1 })]); + http.expectOne(statsUrl('a1')).flush({ + announcement_id: 'a1', + revision: 1, + seen: 9, + dismissed: 9, + acknowledged: 0, + targeted: null, + }); + await first; + + const second = service.loadStats([makeAnnouncement({ revision: 2 })]); + http.expectOne(statsUrl('a1')).flush({ + announcement_id: 'a1', + revision: 2, + seen: 1, + dismissed: 0, + acknowledged: 0, + targeted: null, + }); + await second; + + expect(service.statsFor('a1')?.revision).toBe(2); + expect(service.statsFor('a1')?.seen).toBe(1); + }); + + it('fails soft — a broken stats endpoint leaves the list usable', async () => { + const pending = service.loadStats([makeAnnouncement()]); + http.expectOne(statsUrl('a1')).flush('boom', { + status: 500, + statusText: 'Server Error', + }); + + await expect(pending).resolves.toBeUndefined(); + expect(service.statsFor('a1')).toBeNull(); + }); + + it('retries a failed fetch on the next pass rather than caching the failure', async () => { + const first = service.loadStats([makeAnnouncement()]); + http.expectOne(statsUrl('a1')).flush('boom', { + status: 500, + statusText: 'Server Error', + }); + await first; + + const second = service.loadStats([makeAnnouncement()]); + http.expectOne(statsUrl('a1')).flush({ + announcement_id: 'a1', + revision: 1, + seen: 4, + dismissed: 0, + acknowledged: 0, + targeted: null, + }); + await second; + + expect(service.statsFor('a1')?.seen).toBe(4); + }); + + it('drops cached reach when a mutation lands', async () => { + const first = service.loadStats([makeAnnouncement()]); + http.expectOne(statsUrl('a1')).flush({ + announcement_id: 'a1', + revision: 1, + seen: 5, + dismissed: 0, + acknowledged: 0, + targeted: null, + }); + await first; + expect(service.statsFor('a1')).not.toBeNull(); + + const archived = service.archive('a1'); + http.expectOne(`${API}/admin/announcements/a1/archive`).flush( + makeAnnouncement({ state: 'archived' }), + ); + await archived; + + // Publishing/archiving/revising all change what reach means. + expect(service.statsFor('a1')).toBeNull(); + }); +}); diff --git a/frontend/ai.client/src/app/admin/manage-announcements/services/announcements-admin.service.ts b/frontend/ai.client/src/app/admin/manage-announcements/services/announcements-admin.service.ts new file mode 100644 index 000000000..a731feca4 --- /dev/null +++ b/frontend/ai.client/src/app/admin/manage-announcements/services/announcements-admin.service.ts @@ -0,0 +1,208 @@ +import { Injectable, computed, inject, resource, signal } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { firstValueFrom } from 'rxjs'; +import { ConfigService } from '../../../services/config.service'; +import { AnnouncementsService } from '../../../services/announcements/announcements.service'; +import { + Announcement, + AnnouncementCreateRequest, + AnnouncementListResponse, + AnnouncementStats, + AnnouncementUpdateRequest, +} from '../models/announcement.model'; + +/** + * Admin CRUD for feature announcements (`/admin/announcements`). + * + * Separate from the root-provided `AnnouncementsService`, which owns the + * *user* surface — that one is injected by the always-rendered user dropdown, + * so putting an admin loader on it would fire `GET /admin/announcements/` for + * every user on every app load. Same reasoning as `UserMenuLinksService`'s + * gated admin resource, but split into its own service because the admin + * surface here is much larger than a second resource. + * + * Every mutation reloads the admin list **and** the user-facing feed, so an + * admin who publishes something sees their own What's-New entry appear + * without a refresh. + */ +@Injectable({ providedIn: 'root' }) +export class AnnouncementsAdminService { + private readonly http = inject(HttpClient); + private readonly config = inject(ConfigService); + private readonly userFeed = inject(AnnouncementsService); + + private readonly baseUrl = computed( + () => `${this.config.appApiUrl()}/admin/announcements`, + ); + + // Gated so the loader only fires once an admin page asks for it. + private readonly requested = signal(false); + + readonly announcementsResource = resource({ + params: () => (this.requested() ? {} : undefined), + loader: async () => this.fetchAll(), + }); + + /** Activates the resource. Called by the admin list page. */ + ensureLoaded(): void { + this.requested.set(true); + } + + readonly announcements = computed( + () => this.announcementsResource.value()?.announcements ?? [], + ); + + async fetchAll(): Promise { + return await firstValueFrom( + this.http.get(`${this.baseUrl()}/`), + ); + } + + async get(id: string): Promise { + return await firstValueFrom( + this.http.get(`${this.baseUrl()}/${id}`), + ); + } + + async create(data: AnnouncementCreateRequest): Promise { + const created = await firstValueFrom( + this.http.post(`${this.baseUrl()}/`, data), + ); + this.refresh(); + return created; + } + + async update( + id: string, + updates: AnnouncementUpdateRequest, + ): Promise { + const updated = await firstValueFrom( + this.http.patch(`${this.baseUrl()}/${id}`, updates), + ); + this.refresh(); + return updated; + } + + /** draft | scheduled → published. */ + async publish(id: string): Promise { + const result = await firstValueFrom( + this.http.post(`${this.baseUrl()}/${id}/publish`, {}), + ); + this.refresh(); + return result; + } + + /** Stops it showing. Acknowledgements are kept. */ + async archive(id: string): Promise { + const result = await firstValueFrom( + this.http.post(`${this.baseUrl()}/${id}/archive`, {}), + ); + this.refresh(); + return result; + } + + /** + * "Show again" — increments `revision`, so everyone's suppression lapses at + * once (§D4). This is the destructive-feeling one: it re-surfaces the + * announcement for every targeted user, which is why it is a separate + * action from editing and why the UI confirms first. + */ + async revise(id: string): Promise { + const result = await firstValueFrom( + this.http.post(`${this.baseUrl()}/${id}/revise`, {}), + ); + this.refresh(); + return result; + } + + async remove(id: string): Promise { + await firstValueFrom(this.http.delete(`${this.baseUrl()}/${id}`)); + this.refresh(); + } + + // ── Reach (§9) ──────────────────────────────────────────────────────── + // + // Stats are a separate endpoint per announcement rather than a field on the + // list, so they are fetched into this map and read by id. Only announcements + // that have actually been shown are worth a request: a draft has by + // definition reached nobody, and asking would be N wasted round trips on the + // rows an admin is still writing. + + private readonly statsById = signal>( + new Map(), + ); + /** Ids already requested, so the loader is not re-entered on every render. */ + private readonly requestedStats = new Set(); + + readonly stats = this.statsById.asReadonly(); + + statsFor(id: string): AnnouncementStats | null { + return this.statsById().get(id) ?? null; + } + + /** Whether reach is meaningful — nothing has been shown before publication. */ + static hasReach(announcement: Announcement): boolean { + return ( + announcement.state === 'published' || announcement.state === 'archived' + ); + } + + /** + * Fetch reach for any of these that has been live and is not already + * loaded. Fails soft per id: a stats endpoint erroring must not blank the + * admin list, which is the page's actual job. + */ + async loadStats(announcements: Announcement[]): Promise { + const pending = announcements + .filter(a => AnnouncementsAdminService.hasReach(a)) + .filter(a => !this.requestedStats.has(this.statsKey(a))); + if (pending.length === 0) return; + + for (const a of pending) this.requestedStats.add(this.statsKey(a)); + + const results = await Promise.all( + pending.map(async a => { + try { + return await firstValueFrom( + this.http.get( + `${this.baseUrl()}/${a.announcement_id}/stats`, + ), + ); + } catch { + // Leave it absent; the row renders without a reach line. + this.requestedStats.delete(this.statsKey(a)); + return null; + } + }), + ); + + this.statsById.update(prev => { + const next = new Map(prev); + for (const stats of results) { + if (stats) next.set(stats.announcement_id, stats); + } + return next; + }); + } + + /** + * Keyed by revision, not just id. + * + * "Show again" bumps the revision and the counters restart, so a cached + * entry from the previous revision would show stale reach for a broadcast + * that has only just gone out. + */ + private statsKey(a: Announcement): string { + return `${a.announcement_id}#R${a.revision}`; + } + + private refresh(): void { + this.announcementsResource.reload(); + // The admin is also a user: keep their own What's-New in step. + this.userFeed.reload(); + // Publishing, archiving and revising all change what reach means, so drop + // the cache and let the list re-request it. + this.requestedStats.clear(); + this.statsById.set(new Map()); + } +} diff --git a/frontend/ai.client/src/app/admin/manage-models/components/add-curated-model-dialog.component.ts b/frontend/ai.client/src/app/admin/manage-models/components/add-curated-model-dialog.component.ts index 70870f6ec..5cec01d5d 100644 --- a/frontend/ai.client/src/app/admin/manage-models/components/add-curated-model-dialog.component.ts +++ b/frontend/ai.client/src/app/admin/manage-models/components/add-curated-model-dialog.component.ts @@ -68,7 +68,7 @@ export type AddCuratedModelDialogResult = string[] | undefined; type="button" (click)="onCancel()" aria-label="Close dialog" - class="flex size-8 shrink-0 items-center justify-center rounded-2xl text-gray-400 hover:bg-gray-100 hover:text-gray-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:text-gray-500 dark:hover:bg-gray-700 dark:hover:text-gray-200" + class="flex size-8 shrink-0 items-center justify-center rounded-2xl text-gray-400 hover:bg-gray-100 hover:text-gray-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-500 dark:text-gray-500 dark:hover:bg-gray-700 dark:hover:text-gray-200" >
@if (selectedRoleIds().size === 0) { -

+

Select at least one role so users can see this model.

} @@ -149,7 +149,7 @@ export type AddCuratedModelDialogResult = string[] | undefined; type="button" (click)="confirm()" [disabled]="!canConfirm()" - class="inline-flex items-center gap-2 rounded-2xl bg-blue-600 px-4 py-2 text-sm/6 font-medium text-white hover:bg-blue-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-blue-500 dark:hover:bg-blue-600" + class="inline-flex items-center gap-2 rounded-2xl bg-primary-accessible px-4 py-2 text-sm/6 font-medium text-white hover:brightness-95 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-500 disabled:cursor-not-allowed disabled:opacity-60 dark:hover:brightness-110" > Add to models @@ -158,9 +158,8 @@ export type AddCuratedModelDialogResult = string[] | undefined;
`, styles: ` - @import "tailwindcss"; + @reference "../../../../styles/theme.css"; - @custom-variant dark (&:where(.dark, .dark *)); .dialog-backdrop { animation: backdrop-fade-in 200ms ease-out; diff --git a/frontend/ai.client/src/app/admin/manage-models/components/delete-model-dialog.component.ts b/frontend/ai.client/src/app/admin/manage-models/components/delete-model-dialog.component.ts index 1fef55826..b68676d61 100644 --- a/frontend/ai.client/src/app/admin/manage-models/components/delete-model-dialog.component.ts +++ b/frontend/ai.client/src/app/admin/manage-models/components/delete-model-dialog.component.ts @@ -53,10 +53,10 @@ export type DeleteModelDialogResult = true | undefined;
-
+
@@ -74,7 +74,7 @@ export type DeleteModelDialogResult = true | undefined; type="button" (click)="onCancel()" aria-label="Close dialog" - class="flex size-8 shrink-0 items-center justify-center rounded-2xl text-gray-400 hover:bg-gray-100 hover:text-gray-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:text-gray-500 dark:hover:bg-gray-700 dark:hover:text-gray-200" + class="flex size-8 shrink-0 items-center justify-center rounded-2xl text-gray-400 hover:bg-gray-100 hover:text-gray-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-500 dark:text-gray-500 dark:hover:bg-gray-700 dark:hover:text-gray-200" >
`, styles: ` - @import "tailwindcss"; + @reference "../../../../styles/theme.css"; - @custom-variant dark (&:where(.dark, .dark *)); .dialog-backdrop { animation: backdrop-fade-in 200ms ease-out; diff --git a/frontend/ai.client/src/app/admin/manage-models/manage-models.page.html b/frontend/ai.client/src/app/admin/manage-models/manage-models.page.html index c0eaa5576..af314044b 100644 --- a/frontend/ai.client/src/app/admin/manage-models/manage-models.page.html +++ b/frontend/ai.client/src/app/admin/manage-models/manage-models.page.html @@ -10,7 +10,7 @@

Manage Models

@@ -41,7 +41,7 @@

Manage Models

@for (provider of availableProviders(); track provider) { @@ -54,7 +54,7 @@

Manage Models

@@ -78,9 +78,9 @@

Manage Models

@@ -88,24 +88,20 @@

Manage Models

-
+

Loading models…

} @else if (modelsResource.error()) { -
+

Failed to load models

Please check your connection and try again.

@@ -129,7 +125,7 @@

Manage Models

Manage Models

} @@ -167,7 +163,7 @@

Manage Models

@@ -178,8 +174,8 @@

Manage Models

Manage Models

@if (isAlreadyAdded(model.template.modelId)) { - + @@ -127,7 +127,7 @@

    @for (cap of model.capabilities; track cap) {
  • {{ cap }}
  • @@ -189,7 +189,7 @@

    @if (errorFor(model.key); as msg) { @@ -200,7 +200,7 @@

    @if (isAlreadyAdded(model.template.modelId)) { View in list @@ -209,7 +209,7 @@

    type="button" (click)="previewCuratedModel(model)" [disabled]="addingKey() !== null" - class="inline-flex items-center gap-2 rounded-2xl border border-gray-300 bg-white px-4 py-2 text-sm/6 font-medium text-gray-700 hover:bg-gray-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 disabled:cursor-not-allowed disabled:opacity-60 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700" + class="inline-flex items-center gap-2 rounded-2xl border border-gray-300 bg-white px-4 py-2 text-sm/6 font-medium text-gray-700 hover:bg-gray-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-500 disabled:cursor-not-allowed disabled:opacity-60 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700" > Preview & customize @@ -217,7 +217,7 @@

    type="button" (click)="addCuratedModel(model)" [disabled]="addingKey() !== null" - class="inline-flex items-center gap-2 rounded-2xl bg-blue-600 px-4 py-2 text-sm/6 font-medium text-white hover:bg-blue-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-blue-500 dark:hover:bg-blue-600" + class="inline-flex items-center gap-2 rounded-2xl bg-primary-accessible px-4 py-2 text-sm/6 font-medium text-white hover:brightness-95 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-500 disabled:cursor-not-allowed disabled:opacity-60 dark:hover:brightness-110" >

@@ -512,12 +539,12 @@

step="0.01" min="0" placeholder="0.00 (optional)" - class="block w-full rounded-2xl border border-gray-300 bg-white py-2 pl-7 pr-3 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder:text-gray-500" - [class.border-red-500]="modelForm.controls.cacheReadPricePerMillionTokens.invalid && modelForm.controls.cacheReadPricePerMillionTokens.touched" + class="block w-full rounded-2xl border border-gray-300 bg-white py-2 pl-7 pr-3 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder:text-gray-500" + [class.border-state-danger-500]="modelForm.controls.cacheReadPricePerMillionTokens.invalid && modelForm.controls.cacheReadPricePerMillionTokens.touched" />

@if (modelForm.controls.cacheReadPricePerMillionTokens.invalid && modelForm.controls.cacheReadPricePerMillionTokens.touched) { -

Enter a valid price (0 or greater)

+

Enter a valid price (0 or greater)

} @@ -533,7 +560,7 @@

(click)="toggleInferenceParams()" [attr.aria-expanded]="inferenceParamsExpanded()" aria-controls="inference-params-body" - class="flex w-full items-center justify-between gap-4 rounded-2xl text-left hover:opacity-80 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500" + class="flex w-full items-center justify-between gap-4 rounded-2xl text-left hover:opacity-80 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-500" >
Inference pa Supported @@ -593,7 +620,7 @@

Inference pa type="number" formControlName="min" [step]="meta.kind === 'number' ? 'any' : 1" - class="mt-1 block w-full rounded-2xl border border-gray-300 bg-white px-2 py-1 text-sm/6 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-900 dark:text-white" + class="mt-1 block w-full rounded-2xl border border-gray-300 bg-white px-2 py-1 text-sm/6 text-gray-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-white" />

@@ -602,7 +629,7 @@

Inference pa type="number" formControlName="max" [step]="meta.kind === 'number' ? 'any' : 1" - class="mt-1 block w-full rounded-2xl border border-gray-300 bg-white px-2 py-1 text-sm/6 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-900 dark:text-white" + class="mt-1 block w-full rounded-2xl border border-gray-300 bg-white px-2 py-1 text-sm/6 text-gray-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-white" />

@@ -617,7 +644,7 @@

Inference pa type="number" formControlName="defaultValue" [step]="meta.kind === 'number' ? 'any' : 1" - class="mt-1 block w-full rounded-2xl border border-gray-300 bg-white px-2 py-1 text-sm/6 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-900 dark:text-white" + class="mt-1 block w-full rounded-2xl border border-gray-300 bg-white px-2 py-1 text-sm/6 text-gray-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-white" />

} @else if (meta.kind === 'toggle') { @@ -626,7 +653,7 @@

Inference pa Default to enabled @@ -643,7 +670,7 @@

Inference pa type="checkbox" [checked]="isParamAllowed(i, lvl)" (change)="toggleParamAllowed(i, lvl)" - class="size-4 rounded border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800" + class="size-4 rounded border-gray-300 text-primary-600 focus:ring-2 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800" /> {{ lvl }} @@ -654,7 +681,7 @@

Inference pa Locked (no user override) @if (paramRowErrors(i, 'known').length > 0) { -

@if (loadError()) { -
+
{{ loadError() }}
} @@ -37,7 +37,7 @@ const MAX_PROMPT_TEXT = 8000;
@if (form.controls.name.invalid && form.controls.name.touched) { - + }
@if (form.controls.description.invalid && form.controls.description.touched) { - + }

These instructions are appended to the base system prompt. Users never see this text — only the name and description above. @@ -88,13 +88,13 @@ const MAX_PROMPT_TEXT = 8000; rows="12" [maxlength]="maxPromptLength" placeholder="Write instructions here..." - class="block w-full rounded-sm border border-gray-300 bg-white px-3 py-2 font-mono text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder:text-gray-500" - [class.border-red-500]="form.controls.prompt_text.invalid && form.controls.prompt_text.touched" + class="block w-full rounded-sm border border-gray-300 bg-white px-3 py-2 font-mono text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder:text-gray-500" + [class.border-state-danger-500]="form.controls.prompt_text.invalid && form.controls.prompt_text.touched" aria-describedby="prompt-error prompt-count" >

@if (form.controls.prompt_text.invalid && form.controls.prompt_text.touched) { - + } @else { } @@ -110,7 +110,7 @@ const MAX_PROMPT_TEXT = 8000;
{{ role.displayName }}
@@ -134,7 +135,7 @@ export type ToolRoleDialogResult = string[] | undefined; } -

+

Changes take effect within 5-10 minutes.

} @@ -146,7 +147,7 @@ export type ToolRoleDialogResult = string[] | undefined; type="button" (click)="save()" [disabled]="saving() || loading()" - class="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 sm:ml-3 sm:w-auto dark:bg-indigo-500 dark:shadow-none dark:hover:bg-indigo-400 disabled:opacity-50 disabled:cursor-not-allowed" + class="inline-flex w-full justify-center rounded-md bg-primary-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-primary-500 sm:ml-3 sm:w-auto dark:bg-primary-500 dark:shadow-none dark:hover:bg-primary-400 disabled:opacity-50 disabled:cursor-not-allowed" > {{ saving() ? 'Saving...' : 'Save Changes' }} @@ -162,9 +163,8 @@ export type ToolRoleDialogResult = string[] | undefined;
`, styles: ` - @import "tailwindcss"; + @reference "../../../../styles/theme.css"; - @custom-variant dark (&:where(.dark, .dark *)); /* Backdrop fade-in animation */ .dialog-backdrop { @@ -277,3 +277,4 @@ export class ToolRoleDialogComponent implements OnInit { this.dialogRef.close(undefined); } } + diff --git a/frontend/ai.client/src/app/admin/tools/pages/tool-form.page.ts b/frontend/ai.client/src/app/admin/tools/pages/tool-form.page.ts index 8903d3c92..94730b68f 100644 --- a/frontend/ai.client/src/app/admin/tools/pages/tool-form.page.ts +++ b/frontend/ai.client/src/app/admin/tools/pages/tool-form.page.ts @@ -22,6 +22,7 @@ import { heroExclamationTriangle, } from '@ng-icons/heroicons/outline'; import { AdminToolService } from '../services/admin-tool.service'; +import { SpinnerComponent } from '../../../components/spinner/spinner.component'; import { ConnectorsService } from '../../connectors/services/connectors.service'; import { TOOL_CATEGORIES, @@ -45,7 +46,7 @@ import { @Component({ selector: 'app-tool-form', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [RouterLink, ReactiveFormsModule, NgIcon], + imports: [RouterLink, ReactiveFormsModule, NgIcon, SpinnerComponent], providers: [provideIcons({ heroArrowLeft, heroServer, heroUserGroup, heroLink, heroShieldCheck, heroPlus, heroTrash, heroExclamationTriangle })], template: `
@@ -72,7 +73,7 @@ import { @if (loading()) {
-
+
} @else { @@ -85,18 +86,18 @@ import { @if (!isEditMode()) {
@if (form.get('toolId')?.invalid && form.get('toolId')?.touched) { -

+

Tool ID must be 3-50 characters, lowercase letters, numbers, and underscores only.

} @@ -106,18 +107,18 @@ import {
@if (form.get('displayName')?.invalid && form.get('displayName')?.touched) { -

+

Display name is required (1-100 characters).

} @@ -126,18 +127,18 @@ import {
@if (form.get('description')?.invalid && form.get('description')?.touched) { -

+

Description is required (max 500 characters).

} @@ -152,7 +153,7 @@ import { @for (proto of protocols; track proto.value) { @@ -186,21 +187,21 @@ import { @if (selectedProtocol() === 'mcp_external') {
-

Lambda Function URL or API Gateway endpoint @@ -216,7 +217,7 @@ import { @for (auth of mcpAuthTypes; track auth.value) { @@ -251,7 +252,7 @@ import { type="text" formControlName="mcpAwsRegion" placeholder="us-west-2 (auto-detected from URL if blank)" - class="mt-1 block w-full rounded-2xl border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder:text-gray-500" + class="mt-1 block w-full rounded-2xl border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder:text-gray-500" />

} @@ -268,7 +269,7 @@ import { type="text" formControlName="mcpApiKeyHeader" placeholder="x-api-key" - class="mt-1 block w-full rounded-2xl border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder:text-gray-500" + class="mt-1 block w-full rounded-2xl border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder:text-gray-500" />
@@ -280,7 +281,7 @@ import { type="text" formControlName="mcpSecretArn" placeholder="arn:aws:secretsmanager:..." - class="mt-1 block w-full rounded-2xl border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder:text-gray-500" + class="mt-1 block w-full rounded-2xl border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder:text-gray-500" />
@@ -297,14 +298,14 @@ import { type="button" (click)="discoverMcpTools()" [disabled]="discovering() || !form.get('mcpServerUrl')?.value" - class="inline-flex items-center gap-1 rounded-2xl px-2.5 py-1 text-sm/6 font-medium text-blue-600 hover:bg-blue-50 hover:text-blue-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 disabled:cursor-not-allowed disabled:opacity-50 dark:text-blue-400 dark:hover:bg-blue-900/20" + class="inline-flex items-center gap-1 rounded-2xl px-2.5 py-1 text-sm/6 font-medium text-primary-accessible hover:bg-primary-50 hover:brightness-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-500 disabled:cursor-not-allowed disabled:opacity-50 dark:text-primary-accessible-dark dark:hover:bg-primary-900/20" > {{ discovering() ? 'Discovering…' : 'Discover from server' }}
@if (discoverError()) { -

+

{{ discoverError() }}

} @@ -331,14 +332,14 @@ import { formControlName="name" placeholder="tool_name" [attr.aria-label]="'Tool name ' + ($index + 1)" - class="block w-full rounded-2xl border border-gray-300 bg-white px-3 py-1.5 font-mono text-sm/6 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-900 dark:text-white" + class="block w-full rounded-2xl border border-gray-300 bg-white px-3 py-1.5 font-mono text-sm/6 text-gray-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-white" />
@@ -346,7 +347,7 @@ import { type="button" (click)="removeMcpTool($index)" [attr.aria-label]="'Remove tool ' + ($index + 1)" - class="flex size-8 shrink-0 items-center justify-center rounded-2xl text-gray-400 hover:bg-red-50 hover:text-red-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500 dark:text-gray-500 dark:hover:bg-red-900/20 dark:hover:text-red-400" + class="flex size-8 shrink-0 items-center justify-center rounded-2xl text-gray-400 hover:bg-state-danger-50 hover:text-state-danger-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-state-danger-500 dark:text-gray-500 dark:hover:bg-state-danger-900/20 dark:hover:text-state-danger-400" >
-
@@ -383,7 +384,7 @@ import { @@ -397,11 +398,11 @@ import { @if (form.get('forwardAuthToken')?.value) { -
@@ -483,7 +484,7 @@ import { @if (selectedProtocol() === 'mcp') {
-

@@ -494,14 +495,14 @@ import {

Unique name for the target on the gateway. @@ -510,14 +511,14 @@ import {

The external MCP server endpoint the Gateway will call. @@ -534,7 +535,7 @@ import { id="gwListingMode" formControlName="gwListingMode" [attr.disabled]="form.get('gwCredentialType')?.value === 'oauth' ? '' : null" - class="mt-1 block w-full rounded-2xl border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-60 dark:border-gray-600 dark:bg-gray-800 dark:text-white" + class="mt-1 block w-full rounded-2xl border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:opacity-60 dark:border-gray-600 dark:bg-gray-800 dark:text-white" > @for (mode of gatewayListingModes; track mode.value) { @@ -556,7 +557,7 @@ import {

Auto-detected from the endpoint URL for Lambda / API Gateway / @@ -605,7 +606,7 @@ import { type="text" formControlName="gwAwsRegion" placeholder="defaults to the gateway's region" - class="mt-1 block w-full rounded-2xl border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder:text-gray-500" + class="mt-1 block w-full rounded-2xl border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder:text-gray-500" />

Optional — auto-detected from the endpoint URL; AWS defaults it @@ -617,14 +618,14 @@ import { @if (isLambdaUrlEndpoint()) {

The Lambda behind this Function URL. We grant the gateway @@ -640,18 +641,18 @@ import { @if (form.get('gwCredentialType')?.value === 'oauth' || form.get('gwCredentialType')?.value === 'api_key') {

An existing AgentCore credential provider. Provisioning providers is out of scope here — manage them in - Connectors. + Connectors.

} @@ -668,7 +669,7 @@ import { type="text" formControlName="gwOauthScopes" placeholder="openid profile email" - class="mt-1 block w-full rounded-2xl border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder:text-gray-500" + class="mt-1 block w-full rounded-2xl border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder:text-gray-500" />

Space- or comma-separated. @@ -681,7 +682,7 @@ import { Needs approval @@ -754,7 +755,7 @@ import { type="button" (click)="removeGwTool($index)" [attr.aria-label]="'Remove gateway tool ' + ($index + 1)" - class="flex size-8 shrink-0 items-center justify-center rounded-2xl text-gray-400 hover:bg-red-50 hover:text-red-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500 dark:text-gray-500 dark:hover:bg-red-900/20 dark:hover:text-red-400" + class="flex size-8 shrink-0 items-center justify-center rounded-2xl text-gray-400 hover:bg-state-danger-50 hover:text-state-danger-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-state-danger-500 dark:text-gray-500 dark:hover:bg-state-danger-900/20 dark:hover:text-state-danger-400" >

Note: per-tool approval flags are stored but not yet enforced for Gateway tools (tracked separately). For OAuth targets, users connect the provider via - Connectors. + Connectors.

@@ -775,21 +776,21 @@ import { @if (selectedProtocol() === 'a2a') {
-
@@ -804,7 +805,7 @@ import { type="text" formControlName="a2aAgentId" placeholder="AgentCore Runtime ID (optional)" - class="mt-1 block w-full rounded-2xl border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder:text-gray-500" + class="mt-1 block w-full rounded-2xl border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 placeholder:text-gray-400 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder:text-gray-500" />
@@ -815,7 +816,7 @@ import { @for (stat of statuses; track stat.value) { @@ -913,7 +914,7 @@ import { Public tool @@ -929,7 +930,7 @@ import { Enabled by default @@ -944,17 +945,17 @@ import {
@if (error()) { -
+
{{ error() }}
} @if (form.invalid) { -
-

+

+

Please fix the following before saving:

-
    +
      @if (form.get('toolId')?.invalid && !isEditMode()) {
    • Tool ID is required (3-50 chars, lowercase, numbers, underscores)
    • } @@ -972,7 +973,7 @@ import { diff --git a/frontend/ai.client/src/app/admin/tools/pages/tool-list.page.spec.ts b/frontend/ai.client/src/app/admin/tools/pages/tool-list.page.spec.ts index 385d70cc2..ee15ab9a0 100644 --- a/frontend/ai.client/src/app/admin/tools/pages/tool-list.page.spec.ts +++ b/frontend/ai.client/src/app/admin/tools/pages/tool-list.page.spec.ts @@ -36,14 +36,14 @@ describe('gatewayBadgeFor', () => { const badge = gatewayBadgeFor(status({ status: 'READY', healthy: true })); expect(badge?.label).toBe('Ready'); expect(badge?.failed).toBe(false); - expect(badge?.cls).toContain('green'); + expect(badge?.cls).toContain('state-success'); }); it('maps a still-syncing target to Syncing', () => { const badge = gatewayBadgeFor(status({ status: 'CREATING', healthy: false })); expect(badge?.label).toBe('Syncing'); expect(badge?.failed).toBe(false); - expect(badge?.cls).toContain('blue'); + expect(badge?.cls).toContain('state-info'); }); it('maps a FAILED target to a red Failed badge carrying the reason in the title', () => { @@ -53,7 +53,7 @@ describe('gatewayBadgeFor', () => { ); expect(badge?.label).toBe('Failed'); expect(badge?.failed).toBe(true); - expect(badge?.cls).toContain('red'); + expect(badge?.cls).toContain('state-danger'); expect(badge?.title).toBe(reason); }); diff --git a/frontend/ai.client/src/app/admin/tools/pages/tool-list.page.ts b/frontend/ai.client/src/app/admin/tools/pages/tool-list.page.ts index 1299f4dc6..d1cd425f0 100644 --- a/frontend/ai.client/src/app/admin/tools/pages/tool-list.page.ts +++ b/frontend/ai.client/src/app/admin/tools/pages/tool-list.page.ts @@ -64,7 +64,7 @@ export function gatewayBadgeFor(health: GatewayHealth | undefined): GatewayBadge if (health.healthy) { return { label: 'Ready', - cls: `${GATEWAY_BADGE_BASE} bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300`, + cls: `${GATEWAY_BADGE_BASE} bg-state-success-100 text-state-success-800 dark:bg-state-success-900/30 dark:text-state-success-300`, title: 'Gateway target is ready', failed: false, }; @@ -72,14 +72,14 @@ export function gatewayBadgeFor(health: GatewayHealth | undefined): GatewayBadge if (TRANSIENT_GATEWAY_STATUSES.includes(health.status.toUpperCase())) { return { label: 'Syncing', - cls: `${GATEWAY_BADGE_BASE} bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300`, + cls: `${GATEWAY_BADGE_BASE} bg-state-info-100 text-state-info-800 dark:bg-state-info-900/30 dark:text-state-info-300`, title: 'The gateway is connecting to the target and listing its tools…', failed: false, }; } return { label: health.status.toUpperCase() === 'MISSING' ? 'Missing' : 'Failed', - cls: `${GATEWAY_BADGE_BASE} bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300`, + cls: `${GATEWAY_BADGE_BASE} bg-state-danger-100 text-state-danger-800 dark:bg-state-danger-900/30 dark:text-state-danger-300`, title: health.statusReasons.join(' ') || 'Gateway target is not usable', failed: true, }; @@ -100,11 +100,12 @@ export function isTransientGatewayStatus(status: string): boolean { import { AppRolesService } from '../../roles/services/app-roles.service'; import { ToolRoleDialogComponent, ToolRoleDialogData, ToolRoleDialogResult } from '../components/tool-role-dialog.component'; import { DeleteToolDialogComponent, DeleteToolDialogData, DeleteToolDialogResult } from '../components/delete-tool-dialog.component'; +import { SpinnerComponent } from '../../../components/spinner/spinner.component'; @Component({ selector: 'app-tool-list', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [RouterLink, FormsModule, NgIcon], + imports: [RouterLink, FormsModule, NgIcon, SpinnerComponent], providers: [ provideIcons({ heroPlus, @@ -131,7 +132,7 @@ import { DeleteToolDialogComponent, DeleteToolDialogData, DeleteToolDialogResult
@@ -162,7 +163,7 @@ import { DeleteToolDialogComponent, DeleteToolDialogData, DeleteToolDialogResult id="status" [ngModel]="statusFilter()" (ngModelChange)="statusFilter.set($event)" - class="rounded-2xl border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white" + class="rounded-2xl border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white" > @for (status of statuses; track status.value) { @@ -175,7 +176,7 @@ import { DeleteToolDialogComponent, DeleteToolDialogData, DeleteToolDialogResult id="category" [ngModel]="categoryFilter()" (ngModelChange)="categoryFilter.set($event)" - class="rounded-2xl border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white" + class="rounded-2xl border border-gray-300 bg-white px-3 py-2 text-sm/6 text-gray-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white" > @for (cat of categories; track cat.value) { @@ -202,9 +203,7 @@ import { DeleteToolDialogComponent, DeleteToolDialogData, DeleteToolDialogResult @if (toolsResource.isLoading() && tools().length === 0) {
-
+

Loading tools…

@@ -212,7 +211,7 @@ import { DeleteToolDialogComponent, DeleteToolDialogData, DeleteToolDialogResult @if (toolsResource.error()) { -
+

Failed to load tools. Please try again.

@@ -335,7 +334,7 @@ import { DeleteToolDialogComponent, DeleteToolDialogData, DeleteToolDialogResult [routerLink]="['/admin/tools/edit', tool.toolId]" [attr.aria-label]="'Edit ' + tool.displayName" [title]="'Edit ' + tool.displayName" - class="flex size-8 items-center justify-center rounded-2xl text-gray-400 hover:bg-gray-100 hover:text-gray-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:text-gray-500 dark:hover:bg-gray-700 dark:hover:text-gray-200" + class="flex size-8 items-center justify-center rounded-2xl text-gray-400 hover:bg-gray-100 hover:text-gray-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-500 dark:text-gray-500 dark:hover:bg-gray-700 dark:hover:text-gray-200" > @@ -344,7 +343,7 @@ import { DeleteToolDialogComponent, DeleteToolDialogData, DeleteToolDialogResult (click)="deleteTool(tool)" [attr.aria-label]="'Delete ' + tool.displayName" [title]="'Delete ' + tool.displayName" - class="flex size-8 items-center justify-center rounded-2xl text-gray-400 hover:bg-red-50 hover:text-red-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500 dark:text-gray-500 dark:hover:bg-red-900/20 dark:hover:text-red-400" + class="flex size-8 items-center justify-center rounded-2xl text-gray-400 hover:bg-state-danger-50 hover:text-state-danger-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-state-danger-500 dark:text-gray-500 dark:hover:bg-state-danger-900/20 dark:hover:text-state-danger-400" >
+

{{ state.error() }}

@@ -271,7 +270,7 @@ import { parseIso } from '../../../../utils/date';
@@ -354,9 +353,9 @@ export class UserDetailPage implements OnInit { getStatusClass(status: string): string { switch (status) { case 'active': - return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'; + return 'bg-state-success-100 text-state-success-800 dark:bg-state-success-900 dark:text-state-success-200'; case 'suspended': - return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'; + return 'bg-state-danger-100 text-state-danger-800 dark:bg-state-danger-900 dark:text-state-danger-200'; default: return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'; } @@ -364,11 +363,11 @@ export class UserDetailPage implements OnInit { getUsageBarClass(percentage: number): string { if (percentage >= 90) { - return 'bg-red-500'; + return 'bg-state-danger-500'; } else if (percentage >= 80) { - return 'bg-yellow-500'; + return 'bg-state-warning-500'; } - return 'bg-green-500'; + return 'bg-state-success-500'; } getUsageBarWidth(percentage: number): number { @@ -382,11 +381,11 @@ export class UserDetailPage implements OnInit { getEventIconClass(event: QuotaEventSummary): string { switch (event.eventType) { case 'block': - return 'text-red-500'; + return 'text-state-danger-500'; case 'warning': - return 'text-yellow-500'; + return 'text-state-warning-500'; default: - return 'text-blue-500'; + return 'text-state-info-500'; } } } diff --git a/frontend/ai.client/src/app/admin/users/pages/user-list/user-list.page.ts b/frontend/ai.client/src/app/admin/users/pages/user-list/user-list.page.ts index 504ba1dac..a7bd7aa35 100644 --- a/frontend/ai.client/src/app/admin/users/pages/user-list/user-list.page.ts +++ b/frontend/ai.client/src/app/admin/users/pages/user-list/user-list.page.ts @@ -17,11 +17,12 @@ import { import { UserStateService } from '../../services/user-state.service'; import { UserListItem, UserStatus } from '../../models'; import { parseIso } from '../../../../utils/date'; +import { SpinnerComponent } from '../../../../components/spinner/spinner.component'; @Component({ selector: 'app-user-list', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [FormsModule, NgIcon], + imports: [FormsModule, NgIcon, SpinnerComponent], providers: [ provideIcons({ heroMagnifyingGlass, heroUser, heroChevronRight, heroXMark, heroArrowLeft }), ], @@ -48,7 +49,7 @@ import { parseIso } from '../../../../utils/date'; [(ngModel)]="searchEmail" (keyup.enter)="search()" placeholder="Search by email address..." - class="w-full pl-10 pr-10 py-2 bg-white border border-gray-300 rounded-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-800 dark:border-gray-500 dark:text-white dark:placeholder-gray-400" + class="w-full pl-10 pr-10 py-2 bg-white border border-gray-300 rounded-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-800 dark:border-gray-500 dark:text-white dark:placeholder-gray-400" /> @if (searchEmail) { +
}
@@ -226,7 +226,7 @@

Persona

The agent runs on this model. Only models your role allows are shown.

@@ -264,7 +264,7 @@

Model

selectedModelId() === m.ref ? 'border-primary-500 ring-1 ring-primary-500 dark:border-primary-300 dark:ring-primary-300' : 'border-gray-200 hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-600' - " + " >

{{ m.label }}

@@ -357,13 +357,13 @@

Parameters

-

Capabilities this agent can call.

@for (t of tools(); track t.ref) { - @@ -376,13 +376,17 @@

Tools

@if (skills().length) {
-

Reusable instruction bundles the agent can load on demand.

+
@for (s of skills(); track s.ref) { - @@ -395,25 +399,28 @@

Skills

@if (spaces().length) {
-

A persistent "second brain" the agent reads from — and can write to with editor access.

+
@for (space of spaces(); track space.ref) { -
+
@if (isSpaceSelected(space.ref)) { @for (sel of memorySelections(); track sel.ref) { @if (sel.ref === space.ref) { -
+
Access:
@@ -422,7 +429,7 @@

Memory spaces<

diff --git a/frontend/ai.client/src/app/agents/agent-form/components/agent-preview.component.ts b/frontend/ai.client/src/app/agents/agent-form/components/agent-preview.component.ts index 77c1d32de..1d46e1485 100644 --- a/frontend/ai.client/src/app/agents/agent-form/components/agent-preview.component.ts +++ b/frontend/ai.client/src/app/agents/agent-form/components/agent-preview.component.ts @@ -82,11 +82,11 @@ import { ModelService } from '../../../session/services/model/model.service'; @if (isDirty()) { -
-
@@ -157,11 +163,11 @@

3. Conf type="button" (click)="submitJob()" [disabled]="submitting()" - [class]="'inline-flex items-center gap-1.5 rounded-sm px-4 py-2 text-sm font-medium text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 ' + - (submitting() ? 'cursor-not-allowed bg-blue-400' : 'bg-blue-600 hover:bg-blue-700')" + [class]="'inline-flex items-center gap-1.5 rounded-sm px-4 py-2 text-sm font-medium text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-500 ' + + (submitting() ? 'cursor-not-allowed bg-primary-300' : 'bg-primary-accessible hover:brightness-95')" > @if (submitting()) { -
+ Submitting... } @else { diff --git a/frontend/ai.client/src/app/fine-tuning/pages/create-inference-job/create-inference-job.page.spec.ts b/frontend/ai.client/src/app/fine-tuning/pages/create-inference-job/create-inference-job.page.spec.ts index ba8c7e0d1..b211f5a12 100644 --- a/frontend/ai.client/src/app/fine-tuning/pages/create-inference-job/create-inference-job.page.spec.ts +++ b/frontend/ai.client/src/app/fine-tuning/pages/create-inference-job/create-inference-job.page.spec.ts @@ -17,6 +17,7 @@ const mockTrainedModel: TrainedModelResponse = { training_job_id: 'tj-1', model_id: 'model-1', model_name: 'Test Model', + task_type: 'text-classification', model_s3_path: 's3://bucket/model', instance_type: 'ml.g5.xlarge', completed_at: '2026-02-28T00:00:00Z', @@ -129,6 +130,9 @@ describe('CreateInferenceJobPage', () => { await component.onFileSelected(input); expect(mockHttp.presignInferenceUpload).toHaveBeenCalledWith({ + // The accepted input format follows the task the chosen model was + // fine-tuned for, not anything the user picks. + task_type: 'text-classification', filename: 'input.txt', content_type: 'text/plain', }); diff --git a/frontend/ai.client/src/app/fine-tuning/pages/create-inference-job/create-inference-job.page.ts b/frontend/ai.client/src/app/fine-tuning/pages/create-inference-job/create-inference-job.page.ts index d363708a2..1f614ef88 100644 --- a/frontend/ai.client/src/app/fine-tuning/pages/create-inference-job/create-inference-job.page.ts +++ b/frontend/ai.client/src/app/fine-tuning/pages/create-inference-job/create-inference-job.page.ts @@ -1,4 +1,4 @@ -import { Component, ChangeDetectionStrategy, inject, OnInit, signal } from '@angular/core'; +import { Component, ChangeDetectionStrategy, computed, inject, OnInit, signal } from '@angular/core'; import { Router, RouterLink } from '@angular/router'; import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; import { DatePipe } from '@angular/common'; @@ -14,11 +14,17 @@ import { firstValueFrom } from 'rxjs'; import { FineTuningStateService } from '../../services/fine-tuning-state.service'; import { FineTuningHttpService } from '../../services/fine-tuning-http.service'; import { FineTuningUploadService } from '../../services/fine-tuning-upload.service'; -import { FileUploadState, CreateInferenceJobRequest } from '../../models/fine-tuning.models'; +import { + CreateInferenceJobRequest, + DEFAULT_TASK_TYPE, + FileUploadState, + FineTuningTaskType, +} from '../../models/fine-tuning.models'; +import { SpinnerComponent } from '../../../components/spinner/spinner.component'; @Component({ selector: 'app-create-inference-job', - imports: [RouterLink, ReactiveFormsModule, DatePipe, NgIcon], + imports: [RouterLink, ReactiveFormsModule, DatePipe, NgIcon, SpinnerComponent], providers: [ provideIcons({ heroArrowLeft, @@ -42,6 +48,30 @@ export class CreateInferenceJobPage implements OnInit { /** Upload state tracking. */ readonly uploadState = signal(null); + /** The currently chosen training job id, mirrored from the form control. */ + readonly selectedTrainingJobId = signal(''); + + /** + * The task the chosen model was fine-tuned for. + * + * The artifact can only serve its own task, so the accepted input format + * follows the model rather than anything the user picks — an image + * classifier cannot read a .txt of one line per record. + */ + readonly selectedTaskType = computed(() => { + const jobId = this.selectedTrainingJobId(); + const model = this.state.trainedModels().find((m) => m.training_job_id === jobId); + return model?.task_type ?? DEFAULT_TASK_TYPE; + }); + + /** Whether the chosen model expects a .zip of images. */ + readonly requiresArchive = computed(() => this.selectedTaskType() !== 'text-classification'); + + /** `accept` attribute for the inference input, per the chosen model's task. */ + readonly uploadAccept = computed(() => + this.requiresArchive() ? '.zip' : '.txt,.csv,.jsonl,.json', + ); + /** Whether the form is being submitted. */ readonly submitting = signal(false); @@ -56,6 +86,18 @@ export class CreateInferenceJobPage implements OnInit { ngOnInit(): void { this.state.loadTrainedModels(); + + // Mirror the model choice into a signal so the accepted input format can + // follow it, and drop any file already staged under the previous model's + // contract — a .txt uploaded for a text classifier is not valid input for + // an image one. + this.form.get('trainingJobId')?.valueChanges.subscribe((jobId) => { + const next = jobId ?? ''; + if (next === this.selectedTrainingJobId()) return; + this.selectedTrainingJobId.set(next); + this.uploadState.set(null); + this.submitError.set(null); + }); } /** Handle file selection from the file input. */ @@ -71,6 +113,7 @@ export class CreateInferenceJobPage implements OnInit { // Step 1: Get presigned URL for inference input const presignResponse = await firstValueFrom( this.http.presignInferenceUpload({ + task_type: this.selectedTaskType(), filename: file.name, content_type: file.type || 'application/octet-stream', }), diff --git a/frontend/ai.client/src/app/fine-tuning/pages/create-training-job/create-training-job.page.html b/frontend/ai.client/src/app/fine-tuning/pages/create-training-job/create-training-job.page.html index 1b52a3ebb..dd8647936 100644 --- a/frontend/ai.client/src/app/fine-tuning/pages/create-training-job/create-training-job.page.html +++ b/frontend/ai.client/src/app/fine-tuning/pages/create-training-job/create-training-job.page.html @@ -13,29 +13,78 @@

New Training @if (submitError(); as errorMsg) { -

@if (upload.status === 'uploading') {
} @if (upload.status === 'error' && upload.error) { -

{{ upload.error }}

+

{{ upload.error }}

}
} @@ -419,13 +420,13 @@

Web sour {{ crawl.fetchedCount }} page{{ crawl.fetchedCount === 1 ? '' : 's' }} @if (crawl.status === 'running') { - - + + Crawling… } @else if (crawl.status === 'failed') { - Crawl failed + Crawl failed } @if (isCrawlSyncable(crawl)) { @@ -455,7 +456,7 @@

Web sour (click)="removeWebSource(crawl)" title="Remove web source" [attr.aria-label]="'Remove ' + crawl.rootUrl" - class="flex size-8 shrink-0 items-center justify-center rounded-2xl text-gray-400 hover:bg-red-50 hover:text-red-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500 disabled:cursor-not-allowed disabled:opacity-50 dark:text-gray-500 dark:hover:bg-red-900/20 dark:hover:text-red-400" + class="flex size-8 shrink-0 items-center justify-center rounded-2xl text-gray-400 hover:bg-state-danger-50 hover:text-state-danger-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-state-danger-500 disabled:cursor-not-allowed disabled:opacity-50 dark:text-gray-500 dark:hover:bg-state-danger-900/20 dark:hover:text-state-danger-400" >

-
-
-
-
@if (error()) { - `, styles: ` - @import "tailwindcss"; - @custom-variant dark (&:where(.dark, .dark *)); + @reference "../../../styles/theme.css"; .dialog-backdrop { animation: backdrop-fade-in 200ms ease-out; } @keyframes backdrop-fade-in { from { opacity: 0; } to { opacity: 1; } } .dialog-panel { animation: dialog-fade-in-up 200ms ease-out; } diff --git a/frontend/ai.client/src/app/memory-spaces/components/share-space-dialog.component.ts b/frontend/ai.client/src/app/memory-spaces/components/share-space-dialog.component.ts index f40dd1286..43cbcf31d 100644 --- a/frontend/ai.client/src/app/memory-spaces/components/share-space-dialog.component.ts +++ b/frontend/ai.client/src/app/memory-spaces/components/share-space-dialog.component.ts @@ -60,7 +60,7 @@ interface GrantRow {
-
-