Skip to content

feat: make model api key settings explicit#740

Open
cygaar wants to merge 2 commits into
ColeMurray:mainfrom
cygaar:modeloptions
Open

feat: make model api key settings explicit#740
cygaar wants to merge 2 commits into
ColeMurray:mainfrom
cygaar:modeloptions

Conversation

@cygaar

@cygaar cygaar commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Summary

Makes the API key settings for models explicit (similar to how it's setup in Cursor), to make it easier to configure them.

Changes

  • Adds fixed inputs for:

    • ANTHROPIC_API_KEY
    • OPENAI_API_KEY
    • OPENCODE_API_KEY
  • Supports both global and per-repo scopes using the same encrypted secrets APIs as the existing Secrets settings.

  • Shows whether each key is Set, Inherited, or Not set.

  • Allows repo-specific keys to override global keys.

  • Adds save/remove actions for direct keys.

  • Adds focused tests for global save, repo override save, inherited status, and repo key deletion.

Testing

  • npm test -w @open-inspect/web -- model-api-keys-settings.test.tsx
  • npm run typecheck -w @open-inspect/web
  • npm run lint -w @open-inspect/web

Screenshot

Screenshot 2026-06-13 at 1 44 34 PM

Summary by CodeRabbit

  • New Features

    • Added a Model API Keys settings UI to manage provider keys (OpenAI, Anthropic, OpenCode Zen) with global and repository-scoped modes, clear status labels, per-key remove, validation, and save notifications.
    • Integrated the API keys UI into the Models settings page; loading state now preserves page context while keys load.
  • Tests

    • Added a comprehensive test suite covering save, remove, repo/global behavior, and UI states.

@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds ModelApiKeysSettings, a client React component to view, validate, save, and delete model provider API keys globally or per-repository (SWR-backed), integrates it into ModelsSettings, and provides a Vitest + React Testing Library suite covering save, override, display, and delete behaviors.

Changes

Model API Keys Settings Feature

Layer / File(s) Summary
Contracts and Configuration
packages/web/src/components/settings/model-api-keys-settings.tsx
Defines ModelApiKeyName type, provider metadata, SecretsResponse, byte-size limits (MAX_KEY_BYTES, MAX_TOTAL_BYTES), and helpers getUtf8Size, readJson, createEmptyValues.
Component State and Data Fetching
packages/web/src/components/settings/model-api-keys-settings.tsx
Manages input values, per-key deleting flags, selects global vs repo endpoints, wires SWR fetching with fallback, and computes direct/inherited/changed key sets and repo label/loading messages.
Save and Delete Handlers
packages/web/src/components/settings/model-api-keys-settings.tsx
handleSave() validates UTF-8 byte sizes (per-key and total), constructs PUT payload with only changed secrets, performs fetch, mutates SWR, and shows toasts. handleDelete() sends per-key DELETE, clears local value, revalidates SWR, and shows toasts.
Component UI Rendering
packages/web/src/components/settings/model-api-keys-settings.tsx
Renders repository combobox (including "All Repositories (Global)"), loading/error/ready states, input rows per provider with "Set"/"Inherited"/"Not set" badges, per-key Remove buttons, error messages, and Save button conditioned on change and loading state.
Integration into ModelsSettings
packages/web/src/components/settings/models-settings.tsx
Imports and renders ModelApiKeysSettings; refactors loading UI from early return to inline conditional so the heading and the new component remain visible while preferences load.
Comprehensive Test Suite
packages/web/src/components/settings/model-api-keys-settings.test.tsx
Adds Vitest + RTL tests with JSDOM, jest-dom matchers, hoisted mocks for repos and toast, fetch stubs for PUT/DELETE, and tests for global save (PUT /api/secrets), inherited vs direct display, repo-specific override save (PUT /api/repos/{owner}/{name}/secrets), and per-key deletion (DELETE).

Sequence Diagram — Save/Delete flow:

sequenceDiagram
  participant User
  participant UI as ModelApiKeysSettings
  participant API as /api/secrets or /api/repos/{owner}/{name}/secrets
  participant SWR as SWRCache
  participant Toast

  User->>UI: edits keys / clicks Save or Remove
  UI->>API: PUT changed secrets / DELETE key
  API-->>UI: 200 { status: "ok" }
  UI->>SWR: mutate/revalidate secrets endpoint
  UI->>Toast: show success or error
Loading

🎯 4 (Complex) | ⏱️ ~60 minutes

🐰 A component hops in to guard your keys,
SWR fetches secrets with such ease!
PUT and DELETE, save and remove—
With validation, it's in the groove. ✨🔑

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: adding explicit model API key settings. It accurately reflects the primary purpose of the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
packages/web/src/components/settings/model-api-keys-settings.test.tsx (1)

67-67: 📐 Maintainability & Code Quality | ⚡ Quick win

Extract dedupingInterval into a named ms constant.

Line 67 inlines a duration default (Infinity) directly in config. Please define it once as a named constant with unit in the name and reference it here.

As per coding guidelines, “For durations and timeouts: use … milliseconds for TypeScript, encode the unit in variable names … define each default value exactly once in a named constant.”

Suggested change
+const DEDUPING_INTERVAL_MS = Number.POSITIVE_INFINITY;
+
 function renderWithSWR(fallback: Record<string, unknown> = {}) {
   const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
@@
         provider: () => new Map(),
         fallback,
-        dedupingInterval: Infinity,
+        dedupingInterval: DEDUPING_INTERVAL_MS,
         revalidateOnFocus: false,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/web/src/components/settings/model-api-keys-settings.test.tsx` at
line 67, The test in model-api-keys-settings references a duration inline via
dedupingInterval: Infinity; extract this into a single named millisecond
constant (e.g., DEFAULT_DEDUPING_INTERVAL_MS) declared once near the top of the
test/module and replace the inline value with that constant wherever
dedupingInterval is used (update any objects or calls that set dedupingInterval
in the test, such as the config/props passed to the ModelApiKeysSettings
component).

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/web/src/components/settings/model-api-keys-settings.test.tsx`:
- Around line 131-135: The test is asserting badges that render only after
SWR/UI updates, so replace the synchronous queries with async ones: after
calling selectRepo() in the test for ModelApiKeysSettings, wait for the badges
using await screen.findByText("Inherited") and await screen.findByText("Set")
(or wrap getByText assertions in await waitFor(...)) to avoid races with
isLoading/fetchError; update the assertions referencing
screen.getByText("Inherited") and screen.getByText("Set") to use async finders
or waitFor accordingly.

In `@packages/web/src/components/settings/model-api-keys-settings.tsx`:
- Around line 303-312: The Remove button can be clicked while a save is in
progress; update its disabled condition to also block when the component is
saving. In the JSX for the Button (the one using onClick={() =>
handleDelete(item.key)} and props disabled={!hasDirectKey || deletingKey ===
item.key}), include the saving state (e.g., saving === true) so the disabled
prop is true if saving, hasDirectKey is false, or deletingKey matches item.key;
ensure the same saving flag is used by save logic to prevent concurrent
delete/save races.

---

Nitpick comments:
In `@packages/web/src/components/settings/model-api-keys-settings.test.tsx`:
- Line 67: The test in model-api-keys-settings references a duration inline via
dedupingInterval: Infinity; extract this into a single named millisecond
constant (e.g., DEFAULT_DEDUPING_INTERVAL_MS) declared once near the top of the
test/module and replace the inline value with that constant wherever
dedupingInterval is used (update any objects or calls that set dedupingInterval
in the test, such as the config/props passed to the ModelApiKeysSettings
component).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ef029f9d-6e79-49aa-8c88-7e65d519e20c

📥 Commits

Reviewing files that changed from the base of the PR and between 4ae4dcb and 0ca7898.

📒 Files selected for processing (3)
  • packages/web/src/components/settings/model-api-keys-settings.test.tsx
  • packages/web/src/components/settings/model-api-keys-settings.tsx
  • packages/web/src/components/settings/models-settings.tsx

Comment thread packages/web/src/components/settings/model-api-keys-settings.tsx
@ColeMurray

Copy link
Copy Markdown
Owner

@cygaar can you include a screenshot/recording of the feature?

@cygaar

cygaar commented Jun 13, 2026

Copy link
Copy Markdown
Contributor Author

@ColeMurray added a screenshot

@ColeMurray

Copy link
Copy Markdown
Owner

[Automated Review]

Summary

  • PR: feat: make model api key settings explicit #740feat: make model api key settings explicit · Author: @cygaar · Base: mainmodeloptions
  • Size: 3 files, +590 / −60 (one new component, one new test file, one integration edit)
  • What it does: Adds a dedicated "Model API Keys" UI (for ANTHROPIC_API_KEY, OPENAI_API_KEY, OPENCODE_API_KEY) embedded at the bottom of the Models settings tab. It writes to the existing encrypted-secrets endpoints, supports global + per-repo scope, shows Set/Inherited/Not-set status, and adds save/per-key-delete.

Overall: This is a clean, secure, well-tested component, and the underlying behavior is correct end-to-end (verified against the backend — see below). My concerns are not bugs; they are two design-fit issues: (1) it forks the existing secrets logic rather than sharing it, and (2) it presents ANTHROPIC_API_KEY as a uniform settable key, which is misleading on Modal (the default provider). There are no correctness or security blockers.


Design & Fit Assessment

I traced the whole data path before judging, so this is grounded rather than speculative.

✅ The idea is coherent with the system. The three keys map exactly onto the three model providers in the shared catalog (packages/shared/src/models.ts: anthropic/*, openai/*, and the opencode/* "OpenCode Zen" group at lines 164–171). All three are accepted by the secrets API — ANTHROPIC/OPENAI were deliberately un-reserved in commit bc2482a4 (PR #88, "allow LLM API keys to be set as repo secrets"), and OPENCODE_API_KEY was never reserved (secrets-validation.ts:7-25). A user-set key reaches the OpenCode runtime via process-env inheritance (entrypoint.py:792-856) and is used on all three providers. So the curated, Cursor-style "explicit keys" framing is legitimate — on Daytona and Vercel it is the only credential source for Anthropic (docs/SECRETS.md:116), so surfacing it explicitly has real value.

⚠️ Concern 1 — It contradicts the provider-nuance the system already maintains (the sharpest fit issue). On Modal — the default providerANTHROPIC_API_KEY is structurally required and auto-injected by the platform via a separate Modal Secret (app.py:42-47, required_keys=["ANTHROPIC_API_KEY"]; attached at manager.py:409-417). The docs deliberately tell users to set ANTHROPIC_API_KEY only for Daytona/Vercel because "Modal injects this automatically" (docs/SECRETS.md:39, docs/HOW_IT_WORKS.md:489-490). This new UI erases that nuance:

  • For a Modal user with a working agent, the Anthropic row renders "Not set" — because the badge reflects only the user-secret store, not the effective runtime credential. That's a misleading prompt to enter a key that isn't needed.
  • If they do enter one, it collides with the platform key. Tracing shows the user value should win (Modal layers explicit env= over secrets=[]), so it likely "works" — but no test in the repo pins Secret-vs-env precedence (only env-vs-env is tested, test_sandbox_env_vars.py:40,88). So the happy path here rests on Modal SDK semantics, not on anything this codebase guarantees.
  • Net: the feature is provider-agnostic in presentation but provider-dependent in reality, and the UI conveys none of it. At minimum the Anthropic row could be annotated/disabled on Modal (or the status could distinguish "provided by platform").

⚠️ Concern 2 — It forks the existing secrets implementation instead of sharing it. A generic secrets UI already exists: secrets-settings.tsx (scope + repo combobox) + secrets-editor.tsx (the SWR/validation/PUT/DELETE engine, already parameterized by scope). The new component re-implements the same logic: the GLOBAL_SCOPE = "__global__" sentinel, the apiBase derivation, getUtf8Size, the PUT { secrets } body, the per-key DELETE + mutate, the repo Combobox config, and the size constants MAX_VALUE_SIZE = 16384 / MAX_TOTAL_VALUE_SIZE = 65536. Those two constants are already triple-sourced (server secrets-validation.ts:3-4, client secrets-editor.tsx:14-15, docs/SECRETS.md) — this PR adds a fourth copy. The presentational fork (fixed provider rows vs. free-form key/value) is defensible; the logic fork is not. There's no useSecrets hook today, so the clean fix is to extract the scope→apiBase + save/delete/validate logic into a shared hook (and import the byte limits from one source — ideally lifting them into @open-inspect/shared).

Minor fit note: the same secret is now editable from two tabs (the generic Secrets tab and the new Models section). They're views over one store so it's consistent, but it's a second surface for the same data.


Critical Issues

None. No correctness, security, or data-integrity blockers — the frontend↔backend contract (globalSecrets in the repo response, PUT body shape, per-key DELETE routes, auth) all check out against the live handlers (routes/secrets.ts).


Suggestions

  • [Architecture] (strong) model-api-keys-settings.tsx — Extract the duplicated secrets logic (scope→apiBase, getUtf8Size, size limits, save/delete/mutate) into a shared hook reused by both this component and secrets-editor.tsx, rather than maintaining a parallel copy. At minimum, import MAX_VALUE_SIZE/MAX_TOTAL_VALUE_SIZE from a single source instead of re-declaring them (:13-14). Rationale: avoids the now-fourth copy of the byte limits and prevents the two secrets UIs from drifting.

  • [Design] model-api-keys-settings.tsx — Make the Anthropic row provider-aware. On Modal it's platform-injected, so "Not set" is misleading and an entered value collides with the platform key. Consider annotating/disabling it for Modal sessions, or reflecting "provided by platform" in the status. This preserves the guidance docs/SECRETS.md already encodes.

  • [Testing] model-api-keys-settings.test.tsx — Tests stub fetch entirely, so they validate request construction and status rendering but give no signal on the real backend contract (fine for unit scope — the contract was verified manually here). Consider adding cases for the error toasts (non-OK save/delete) and the "empty field is omitted from the payload" behavior, which is core to the "leave empty to keep current value" semantics and currently untested.

  • [Docs] docs/SECRETS.md / docs/HOW_IT_WORKS.md aren't updated. Since they contain the provider-specific Anthropic guidance this UI interacts with, a short note pointing at the new UI (and the Modal caveat) would keep them in sync.


Nitpicks

  • Nit: model-api-keys-settings.tsx handleSave/handleDelete — Save is disabled on saving but not on deletingKey, so Save can fire mid-delete. Both hit the same endpoint and re-mutate, so impact is low, but disabling Save while a delete is in flight would tidy it up.
  • Nit: The "Repository" <label> isn't associated with the Combobox (no htmlFor/id); minor a11y gap. (The provider inputs correctly use aria-label.)
  • Nit: Remove triggers an immediate destructive DELETE with no confirmation. Acceptable, but worth confirming it matches the existing SecretsEditor UX.

Positive Feedback

  • Security posture is excellent: type="password" inputs, secret values never re-fetched/redisplayed ("Saved values are never displayed again"), only metadata read, and the .toUpperCase() comparisons correctly mirror the server's key normalization.
  • Solid component craft: clean loading/error/ready states, SWR usage consistent with the rest of the app, good aria-labels that the tests assert against, and the "only send changed (non-empty) keys" upsert semantics are correct against the backend's setSecrets behavior.
  • The models-settings.tsx refactor (early-return → inline conditional) is a genuine improvement — the heading and the new section now stay visible while model preferences load.

Questions

  1. model-api-keys-settings.tsx — Was composing/extending the existing scope-parameterized SecretsEditor (or extracting a shared hook) considered before forking? Knowing the intent helps weigh the duplication trade-off.
  2. MODEL_API_KEYS[0] (Anthropic) — On Modal the badge shows "Not set" despite the platform providing the key. Is provider-awareness in scope for this PR, or intentionally deferred?
  3. Is there a reason this lives in the Models tab rather than alongside the generic Secrets tab, given both now edit the same store?

Verdict

Comment. The implementation is correct, secure, and well-tested, and the feature genuinely fits the system's three-provider model. Not requesting changes on correctness grounds. Before merge, though, it's worth a conscious decision on the two design-fit points — the logic duplication and the Modal ANTHROPIC_API_KEY "Not set"/collision UX — since both touch how the wider system already handles secrets.

Methodology: the new component file isn't on the local checkout, so it was reviewed from the PR diff; every backend assumption was verified against the current main (2 commits behind origin/main, neither touching the secrets path).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants