Skip to content

feat(providers): support custom providers in local anonymous chat via file store - #103

Merged
Zochory merged 7 commits into
mainfrom
implementation/local-custom-provider-store
Aug 5, 2026
Merged

feat(providers): support custom providers in local anonymous chat via file store#103
Zochory merged 7 commits into
mainfrom
implementation/local-custom-provider-store

Conversation

@Zochory

@Zochory Zochory commented Aug 5, 2026

Copy link
Copy Markdown
Member

Summary

Enables custom provider instances (custom+<slug> and openai-chat-completions+<slug>) for local anonymous / non-DB chat instead of returning "Custom provider instances require a database-backed account (deployed chat)."

  • Custom provider instances now pick a storage backend at runtime: Postgres pi_user_providers when DB-backed (userId + FLEET_PI_CHAT_DATABASE_URL), otherwise a gitignored per-user file store at <projectRoot>/.fleet/providers.json (plaintext, atomic temp-file+rename).
  • Store writes are serialized by a per-process mutation lock; id allocation + first write happen atomically so concurrent creates get distinct ids.
  • A malformed/future-version store degrades to [] + a diagnostic instead of bricking chat session creation.
  • Loopback http://localhost base URLs are allowed for OCC-family instances on local dev surfaces only (!isDeployedChatRuntimeSurface()); https stays enforced everywhere else.
  • Registration/route logic shares normalized allocation and URL validation (normalizeCustomProviderInstance, assertCustomProviderBaseUrl, allocateProviderId in @workspace/pi-protocol).
  • Settings GET /api/chat/providers and Pi runtime registration now use symmetric keyed-list APIs; tests mock the local store for hygiene.

Test plan

  • pnpm --filter web typecheck — clean
  • pnpm --filter pi-protocol lint / test — clean / 19 passed
  • pnpm --filter web lint — 0 errors
  • Web unit suite — 766 passed (2 pre-existing resource-expectations.spec.ts failures from unrelated .pi/settings.json drift)
  • Browser E2E verified CRUD round-trip to .fleet/providers.json for anonymous local chat

Made with Cursor

… file store

Allow custom provider instances (custom+/openai-chat-completions+) to be
created and registered on local non-DB accounts by persisting them in a
gitignored per-user file store at .fleet/providers.json instead of
requiring pi_user_providers. The store uses atomic temp-file+rename writes
with a per-process mutation lock, degrades to a diagnostic when unreadable,
and scopes files per account. Loopback http base URLs are now allowed for
OCC-family instances on local dev surfaces only; https stays enforced
elsewhere. Registration/route logic shares normalized allocation and URL
validation, and local-store reads are mocked in unit tests for hygiene.

Co-authored-by: Cursor <cursoragent@cursor.com>
Copilot AI lite review requested due to automatic review settings August 5, 2026 10:20
@vercel

vercel Bot commented Aug 5, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
fleet-pi-web Ready Ready Preview Aug 5, 2026 12:45pm

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@Zochory, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 26 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 046814a1-c619-4649-a039-182e6f091534

📥 Commits

Reviewing files that changed from the base of the PR and between 40ad206 and 9acce02.

📒 Files selected for processing (7)
  • apps/web/src/lib/db/__tests__/local-provider-instances.test.ts
  • apps/web/src/lib/db/local-provider-instances.ts
  • apps/web/src/lib/fs-atomic.ts
  • apps/web/src/lib/pi/runtime/__tests__/custom-provider-local-registration.test.ts
  • apps/web/src/lib/pi/runtime/custom-provider-registry.ts
  • apps/web/src/routes/api/chat/__tests__/providers-custom-route.test.ts
  • packages/pi-protocol/src/provider-catalog.ts
📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Custom providers can now be saved and managed using either local file storage or the existing database-backed path, improving support for local development and offline setups.
    • Provider listings and saved models now handle larger model ID lists more reliably.
  • Bug Fixes

    • Improved validation for custom provider URLs, including better handling of local and loopback addresses.
    • Added safer file updates to reduce the risk of partial writes when saving local configuration.

Walkthrough

Custom providers now support local account-scoped storage and Postgres storage. Shared provider normalization, ID allocation, URL validation, atomic writes, route handlers, runtime registration, diagnostics, schemas, tests, and local development guidance were updated.

Changes

Custom provider storage and runtime

Layer / File(s) Summary
Provider contracts and atomic persistence
packages/pi-protocol/src/provider-catalog.ts, apps/web/src/lib/db/..., apps/web/src/lib/fs-atomic.ts, apps/web/src/lib/env-manager.ts
Adds shared provider API metadata, normalization, ID allocation, database configuration checks, and atomic file writes.
Local provider store
apps/web/src/lib/db/local-provider-instances.ts, apps/web/src/lib/db/__tests__/local-provider-instances.test.ts
Adds an account-scoped JSON store with validation, API-key handling, CRUD operations, collision-safe IDs, serialized mutations, and atomic persistence.
Provider routes and URL policy
apps/web/src/routes/api/chat/providers.ts, apps/web/src/routes/api/chat/__tests__/providers-custom-route.test.ts, apps/web/src/lib/pi/runtime/openai-chat-completions-url.ts, apps/web/openapi.json, AGENTS.md
Routes select local or Postgres storage, apply API-specific URL rules, expose testable handlers, enforce modelIds limits, and document local provider behavior.
Runtime provider registration
apps/web/src/lib/pi/runtime/custom-provider-registry.ts, apps/web/src/lib/pi/runtime/__tests__/*, apps/web/src/lib/pi/server-shared.test.ts
Runtime registration loads providers and keys from the active store, handles invalid or unreadable entries, emits diagnostics, and removes stale local providers.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant providersServerHandlers
  participant ProviderStore
  participant custom-provider-registry
  participant ModelRuntime
  Client->>providersServerHandlers: Save custom provider
  providersServerHandlers->>ProviderStore: Create or update instance
  custom-provider-registry->>ProviderStore: List instances and load API keys
  custom-provider-registry->>ModelRuntime: Register validated models
  ModelRuntime-->>Client: Provide registered custom model
Loading

Possibly related PRs

Suggested reviewers: copilot

Poem

A rabbit stores providers neat,
In .fleet files, safe and sweet.
IDs hop past collisions wide,
Atomic writes keep data tied.
Local models join the runtime lane.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 73.33% 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 describes support for custom providers in local anonymous chat.
Description check ✅ Passed The description clearly explains the feature and testing, but omits several template sections such as Related Issue, Type of Change, and Checklist.
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.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch implementation/local-custom-provider-store

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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds first-class support for custom provider instances in local anonymous / non-DB chat by introducing a gitignored on-disk provider store, while keeping deployed/DB-backed behavior using pi_user_providers. This aligns the Settings providers surface and Pi runtime registration so locally-created custom providers can be persisted and re-registered across sessions.

Changes:

  • Introduces shared custom-provider utilities in @workspace/pi-protocol (API family list/guards, consistent id allocation, instance normalization).
  • Updates /api/chat/providers and the Pi runtime custom-provider registry to select between Postgres storage and a local .fleet/ file store.
  • Adds atomic file-writing utilities and comprehensive unit tests covering local-store CRUD, route behavior, and registration behavior.

Reviewed changes

Copilot reviewed 19 out of 19 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
packages/pi-protocol/src/provider-catalog.ts Adds custom-provider API family helpers, consistent id allocation, and instance normalization utilities.
apps/web/src/routes/api/chat/providers.ts Routes provider CRUD to Postgres vs local file store and centralizes base URL policy.
apps/web/src/routes/api/chat/tests/providers-custom-route.test.ts Tests route handler behavior for local anonymous custom providers and storage selection.
apps/web/src/lib/pi/server-shared.test.ts Mocks the local provider store for server-shared tests.
apps/web/src/lib/pi/runtime/openai-chat-completions-url.ts Consolidates custom-provider base URL validation and tightens local-http gating to deployed surfaces.
apps/web/src/lib/pi/runtime/custom-provider-registry.ts Registers custom providers from either Postgres or the local file store and surfaces store-read diagnostics.
apps/web/src/lib/pi/runtime/tests/session-factory.test.ts Mocks local provider store in session-factory tests.
apps/web/src/lib/pi/runtime/tests/openai-chat-completions-registration.test.ts Ensures DB-backed OCC registration tests don’t accidentally use the local store.
apps/web/src/lib/pi/runtime/tests/custom-provider-local-registration.test.ts Adds coverage for registering local-store providers (including unreadable-store degradation).
apps/web/src/lib/fs-atomic.ts New helper for atomic same-directory temp-file + rename writes.
apps/web/src/lib/env-manager.ts Switches .env.local persistence to the shared atomic write helper.
apps/web/src/lib/db/user-settings.ts Uses shared chat DB configuration predicate.
apps/web/src/lib/db/user-providers.ts Uses shared chat DB configuration predicate.
apps/web/src/lib/db/occ-instances.ts Reuses shared id allocation + instance normalization; widens input shape for upserts.
apps/web/src/lib/db/local-provider-instances.ts Implements the local .fleet/ plaintext provider instance store with serialized writes.
apps/web/src/lib/db/chat-db-config.ts Adds a shared isChatDatabaseConfigured() helper.
apps/web/src/lib/db/tests/local-provider-instances.test.ts Adds test coverage for local-store scoping, atomic writes, and concurrent creates.
AGENTS.md Updates contributor documentation to reflect the new local custom-provider storage behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread apps/web/src/routes/api/chat/providers.ts
Comment thread AGENTS.md Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 19 out of 19 changed files in this pull request and generated no new comments.

Suppressed comments (3)

apps/web/src/routes/api/chat/providers.ts:80

  • When useLocalProviderStore(userId) is true, an unreadable/malformed local provider store will throw from listLocalProviderInstancesWithApiKey and currently bubbles up as a 500 for GET /api/chat/providers (and also after POST/DELETE when returning updatedProviders). That breaks the Providers UI even though session creation intentionally degrades to a warning + empty list. Consider catching local-store read errors here and falling back to [] so Settings still loads and the runtime diagnostics remain the primary surfacing mechanism.
  // One query + one decrypt pass for DB-backed accounts; local anonymous/dev
  // accounts read the gitignored file store. DB errors propagate (route returns
  // 500) instead of a misleading "not configured" row.
  const instances = useLocalProviderStore(userId)
    ? await listLocalProviderInstancesForRoute(userId)
    : await listOccInstancesWithApiKey(userId)

AGENTS.md:148

  • This bullet says the local file store is at <projectRoot>/.fleet/providers.json, but the implementation writes per-scope files (e.g. providers.anonymous.json and providers.user-<hash>.json). This mismatch can mislead contributors when debugging local provider persistence.
- **Custom providers (native Pi mechanism).** Users can register any OpenAI/Anthropic/Google-compatible endpoint, each `{ displayName, apiKey, baseUrl, api, modelIds }` with `api` one of `openai-completions`, `openai-responses`, `anthropic-messages`, `google-genai`. General custom providers get a slug id `custom+<slug>`; legacy named OCC instances keep `openai-chat-completions+<slug>` — both register through the same path. The reserved `openai-chat-completions` id stays the default/Neon AI Gateway slot. Storage reuses `pi_user_providers` (`provider_id` = slug, encrypted `encrypted_key` for the apiKey, encrypted `encrypted_payload` JSON `{displayName, baseUrl, api, modelIds}` — legacy payloads with `modelId` normalize on read); no Neon migration. `apps/web/src/lib/db/occ-instances.ts` is the persistence layer; `custom-provider-registry.ts` (`registerCustomProviders`) registers each instance as its own native Pi provider (`<instanceId>/<modelId>` in the model picker; `google-genai` maps to Pi's `google-generative-ai`) with a per-host `max_tokens` cap (25k + gateway compat on `*.neon.tech` OCC endpoints, else 32k). Custom instances pick a storage backend at runtime: Postgres `pi_user_providers` when the account is DB-backed (`userId` + `FLEET_PI_CHAT_DATABASE_URL`), otherwise a gitignored per-scope local file store at `<projectRoot>/.fleet/providers.anonymous.json` (anonymous chat) or `<projectRoot>/.fleet/providers.user-<hash>.json` (local signed-in accounts; plaintext, atomic temp-file+rename) that enables anonymous/local non-DB chat — `POST /api/chat/providers` with `createOccInstance: true` or an existing `…+<slug>` id. OCC-family instances may use loopback `http://localhost` base URLs in local dev; everything else stays https-only. The reserved default OCC slot remains the env-backed `OPENAI_CHAT_COMPLETIONS_{API_KEY,BASE_URL,MODEL}`; `apps/web/src/lib/db/local-provider-instances.ts` is the local store (and `useLocalProviderStore` picks the backend). Sandbox credential sync covers only the reserved default OCC slot for now (custom instances are not yet pushed to Daytona).

apps/web/src/lib/fs-atomic.ts:14

  • tempPath is derived from process.pid + Date.now(). Two writes to the same filePath within the same millisecond in the same process can collide on the same temp filename, causing intermittent write failures or clobbering. Using a random suffix (e.g. randomUUID()) makes the atomic write helper robust under concurrent callers.
export async function writeFileAtomic(filePath: string, content: string) {
  await mkdir(dirname(filePath), { recursive: true })
  const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`
  try {

Copilot AI review requested due to automatic review settings August 5, 2026 10:30

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 19 out of 19 changed files in this pull request and generated no new comments.

Suppressed comments (3)

apps/web/src/lib/db/local-provider-instances.ts:255

  • createLocalProviderInstance also writes modelId from normalized.modelIds[0] without guarding against an empty model list. A bad caller would persist an invalid row and brick future reads. Adding a local invariant check here keeps the store format self-consistent.
      modelId: normalized.modelIds[0],

apps/web/src/lib/db/local-provider-instances.ts:206

  • modelId is derived from normalized.modelIds[0] without checking that at least one model id exists. If an empty modelIds/missing modelId ever reaches this function, it will write modelId: undefined to disk and make the store unreadable on the next load (fails isLocalProviderInstance). Consider enforcing the invariant here to avoid corrupting the local store.

This issue also appears on line 255 of the same file.

      modelId: normalized.modelIds[0],

apps/web/src/routes/api/chat/providers.ts:235

  • When useLocalStore is true, failures reading/writing the local provider store (e.g. malformed JSON or future version) will currently bubble up and 500 the POST update/create path. Since GET explicitly degrades unreadable stores to [], it’s better to also catch local-store failures here and return a 400 with a clear message so the Settings UI doesn’t hard-fail on save/remove attempts.
  if (!isExistingInstance) {
    instanceId = useLocalStore
      ? await createLocalProviderInstance(
          userId,
          instanceBody,

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 19 out of 19 changed files in this pull request and generated no new comments.

Suppressed comments (4)

apps/web/src/routes/api/chat/providers.ts:487

  • DELETE has the same failure mode as POST for local instances: if the local store JSON is malformed, getLocalProviderInstance / removeLocalProviderInstance will throw and the route returns a 500. That makes it impossible to remove broken instances via Settings, which is the primary recovery path for a bad store.
          if (useLocalProviderStore(userId)) {
            const existing = await getLocalProviderInstance(
              userId,
              body.providerId
            )

apps/web/src/lib/fs-atomic.ts:17

  • writeFileAtomic can generate the same temp filename for concurrent writes to the same target within a single process (same pid + same Date.now() millisecond). That race can cause one writer to rename away the temp file and the other to fail with ENOENT, making env/provider updates flaky under parallel requests.
  await mkdir(dirname(filePath), { recursive: true })
  const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`
  try {
    await writeFile(tempPath, content, "utf8")

apps/web/src/routes/api/chat/providers.ts:242

  • When using the local provider store, createLocalProviderInstance will throw if .fleet/providers.*.json is malformed/future-version. That currently bubbles up as a 500 for POST, even though unreadable stores are treated as recoverable elsewhere, and it prevents users from repairing the store via Settings.

This issue also appears on line 483 of the same file.

  if (!isExistingInstance) {
    instanceId = useLocalStore
      ? await createLocalProviderInstance(
          userId,
          instanceBody,

apps/web/src/lib/db/local-provider-instances.ts:37

  • The PR description mentions a single <projectRoot>/.fleet/providers.json, but the implementation uses scoped filenames (.fleet/providers.anonymous.json and .fleet/providers.user-<hash>.json). Please update the PR description (or any other external docs) to match the actual on-disk paths to avoid confusion for local users.
/**
 * Resolves the gitignored local provider store path for a project root. One
 * file per account: anonymous local chat shares `providers.anonymous.json`,
 * signed-in local accounts get `providers.user-<hash>.json`, so an anonymous
 * user and a local Better Auth account on the same machine never read each
 * other's instances (or plaintext API keys). `.fleet/` is already gitignored
 * (see root `.gitignore`).

Co-authored-by: Cursor <cursoragent@cursor.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 20 out of 20 changed files in this pull request and generated 1 comment.

Comment thread apps/web/src/lib/fs-atomic.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Warning

CodeRabbit couldn't request changes on this pull request because it doesn't have sufficient GitHub permissions.

Please grant CodeRabbit Pull requests: Read and write permission and re-run the review.

👉 Steps to fix this

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/web/src/lib/db/occ-instances.ts (1)

295-303: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Close the allocate-then-upsert window on the Postgres create path.

allocateInstanceId reads pi_user_providers in one transaction, then the caller allocates a second time and runs upsertOccInstance as an ON CONFLICT DO UPDATE. Concurrent new-provider creates from the same user can both pick the same id and cause one write to overwrite the other. For new providers, use a create-only insert, retry on conflict for the next generated id, and fail after exhausting generated options.

🤖 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 `@apps/web/src/lib/db/occ-instances.ts` around lines 295 - 303, Update the
new-provider create flow around allocateInstanceId and upsertOccInstance to
eliminate separate allocation and write steps. Insert the provider with
create-only semantics, retrying with the next generated ID when a uniqueness
conflict occurs so concurrent creates cannot overwrite one another. Preserve
update behavior for existing providers, and return an explicit failure after all
generated ID options are exhausted.
🧹 Nitpick comments (5)
apps/web/src/lib/db/__tests__/local-provider-instances.test.ts (1)

138-155: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a case for an instance with no model ids.

This test covers the legacy modelId to modelIds normalization. It does not cover the input where both modelIds and modelId are absent. That input produces modelId: undefined and makes every later read of the store throw. See the comment on apps/web/src/lib/db/local-provider-instances.ts Lines 189-217.

Add a test that calls upsertLocalProviderInstance with no models and asserts the store rejects it, then asserts a following listLocalProviderInstances still succeeds.

🤖 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 `@apps/web/src/lib/db/__tests__/local-provider-instances.test.ts` around lines
138 - 155, Extend the local provider instance tests near the legacy
normalization case with an instance whose input omits both modelIds and modelId.
Assert upsertLocalProviderInstance rejects the invalid instance, then verify
listLocalProviderInstances still completes successfully afterward without
throwing.
apps/web/src/lib/db/local-provider-instances.ts (1)

284-307: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Document that the allocated id is stale once the lock is released.

allocateLocalInstanceId returns the id after withStoreLock resolves. The caller then writes in a separate lock acquisition. Two concurrent creates that use this helper can therefore receive the same id, and the later upsertLocalProviderInstance overwrites the earlier instance in place at Line 211.

createLocalProviderInstance exists to close that window, and its doc says so. This helper's doc does not state the caveat. Add the caveat, and direct new callers to createLocalProviderInstance.

♻️ Proposed doc change
  * Allocates an available custom provider id for a display name from the local
  * dev store, appending `-2`, `-3`, … or a timestamp suffix on collisions.
  *
+ * The id is only guaranteed unique at the moment of allocation. The store lock
+ * is released before the caller writes, so concurrent creates can receive the
+ * same id. Use {`@link` createLocalProviderInstance} to allocate and write under
+ * one lock.
+ *
  * `@param` userId - The account, or `undefined` for anonymous local chat
🤖 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 `@apps/web/src/lib/db/local-provider-instances.ts` around lines 284 - 307,
Update the JSDoc for allocateLocalInstanceId to state that its returned id may
become stale after withStoreLock releases because callers write under a separate
lock, allowing concurrent creates to collide. Direct new callers that need
allocation and persistence to be atomic to createLocalProviderInstance, while
leaving the implementation unchanged.
packages/pi-protocol/src/provider-catalog.ts (1)

6-16: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Enforce the exhaustiveness the doc comment promises.

The comment states that adding a family updates every consumer in one place. The annotation ReadonlyArray<PiCustomProviderApi> does not enforce that. A subset also satisfies the type, so adding a member to PiCustomProviderApi compiles without adding it here. The runtime guard isPiCustomProviderApi then silently rejects the new family, and local-provider-instances.ts drops stored rows that use it as malformed.

Derive the array from a keyed record so a missing member fails type checking.

♻️ Proposed change to make the list exhaustive
-export const PI_CUSTOM_PROVIDER_APIS: ReadonlyArray<PiCustomProviderApi> = [
-  "openai-completions",
-  "openai-responses",
-  "anthropic-messages",
-  "google-genai",
-]
+const PI_CUSTOM_PROVIDER_API_SET: Record<PiCustomProviderApi, true> = {
+  "openai-completions": true,
+  "openai-responses": true,
+  "anthropic-messages": true,
+  "google-genai": true,
+}
+
+export const PI_CUSTOM_PROVIDER_APIS: ReadonlyArray<PiCustomProviderApi> =
+  Object.keys(PI_CUSTOM_PROVIDER_API_SET) as Array<PiCustomProviderApi>
🤖 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/pi-protocol/src/provider-catalog.ts` around lines 6 - 16, Update
PI_CUSTOM_PROVIDER_APIS to use a keyed record or equivalent exhaustive mapped
type over PiCustomProviderApi, then derive the exported array from that record.
Ensure every union member requires an entry so adding a new API family fails
type checking until it is registered here, while preserving the existing array
consumers and runtime validation behavior.
apps/web/src/routes/api/chat/__tests__/providers-custom-route.test.ts (2)

249-276: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for the local update branch.

The suite covers local create, Postgres create, and local delete. It does not cover an update against an existing custom+<slug> id with the local store selected. That path is the only caller of upsertLocalProviderInstance in the route, and it must not call createLocalProviderInstance. Add a test that posts an existing ${CUSTOM_PROVIDER_ID_PREFIX}my-endpoint id and asserts upsertLocalProviderInstanceMock receives the id while createLocalProviderInstanceMock is not called.

🤖 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 `@apps/web/src/routes/api/chat/__tests__/providers-custom-route.test.ts` around
lines 249 - 276, The custom provider route tests need coverage for the local
update path. Add a test alongside the existing local create/Postgres cases that
selects the local store, posts an existing
`${CUSTOM_PROVIDER_ID_PREFIX}my-endpoint` provider id, asserts
`upsertLocalProviderInstanceMock` receives that id, and verifies
`createLocalProviderInstanceMock` is not called.

205-247: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Pin the deployed-surface state for the loopback tests.

These two tests depend on isDeployedChatRuntimeSurface() returning false. That function reads process.env.VERCEL and getChatAuthSurface(). Neither is stubbed here. If the test process runs with VERCEL=1 or with a Neon Function auth surface configured, the loopback acceptance test fails. Mock @/lib/pi/runtime/deployed-chat-runtime to make the policy branch explicit.

♻️ Proposed mock for the deployed-surface module
 vi.mock("`@/lib/deployment/environment`", () => ({
   isVercelDeployment: () => false,
 }))
+
+vi.mock("`@/lib/pi/runtime/deployed-chat-runtime`", () => ({
+  isDeployedChatRuntimeSurface: () => 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 `@apps/web/src/routes/api/chat/__tests__/providers-custom-route.test.ts` around
lines 205 - 247, Mock the "`@/lib/pi/runtime/deployed-chat-runtime`" module in the
providers-custom-route tests so isDeployedChatRuntimeSurface() consistently
returns false. Apply this explicit local-development runtime state to both
loopback URL tests while preserving their existing assertions.
🤖 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 `@apps/web/src/lib/db/local-provider-instances.ts`:
- Around line 189-217: In upsertLocalProviderInstance, stop writing a row when
normalizeCustomProviderInstance returns an empty modelIds list: read
normalized.modelIds[0] into a local, validate that it exists before building the
LocalProviderInstance, and only assign the checked value to modelId. Apply the
same guard in createLocalProviderInstance so both write paths reject invalid
input consistently, and add the test in local-provider-instances.test.ts to
cover an upsert with neither modelIds nor modelId and confirm the store remains
readable afterward.
- Around line 332-347: Update isLocalProviderInstance so an omitted modelIds
field is accepted and normalized to an empty list, matching the optional
modelIds contract in LocalProviderInstance; continue validating every provided
modelIds value as an array of strings.
- Around line 58-72: Update useLocalProviderStore to disable the local file
store on both Vercel and the Neon Function chat surface, using
getChatAuthSurface alongside isVercelDeployment. Ensure Neon Function users
never fall back to the ephemeral .fleet store, regardless of chat database
configuration.

In
`@apps/web/src/lib/pi/runtime/__tests__/custom-provider-local-registration.test.ts`:
- Around line 48-52: Update the afterEach cleanup to restore
NEON_AI_GATEWAY_TOKEN and NEON_AI_GATEWAY_BASE_URL accurately: assign each
original value when defined, but delete the corresponding process.env property
when its original value is undefined, preventing test state from leaking.

In `@apps/web/src/lib/pi/runtime/custom-provider-registry.ts`:
- Around line 180-182: Update registerCustomProviders around
listLocalProviderInstances and loadLocalProviderInstanceApiKey to catch API-key
store read failures, return an empty registration set with the existing
storeError diagnostic, and allow session creation to continue; add a regression
test in
apps/web/src/lib/pi/runtime/__tests__/custom-provider-local-registration.test.ts
covering successful discovery followed by a rejected
loadLocalProviderInstanceApiKey and asserting the diagnostic result.

---

Outside diff comments:
In `@apps/web/src/lib/db/occ-instances.ts`:
- Around line 295-303: Update the new-provider create flow around
allocateInstanceId and upsertOccInstance to eliminate separate allocation and
write steps. Insert the provider with create-only semantics, retrying with the
next generated ID when a uniqueness conflict occurs so concurrent creates cannot
overwrite one another. Preserve update behavior for existing providers, and
return an explicit failure after all generated ID options are exhausted.

---

Nitpick comments:
In `@apps/web/src/lib/db/__tests__/local-provider-instances.test.ts`:
- Around line 138-155: Extend the local provider instance tests near the legacy
normalization case with an instance whose input omits both modelIds and modelId.
Assert upsertLocalProviderInstance rejects the invalid instance, then verify
listLocalProviderInstances still completes successfully afterward without
throwing.

In `@apps/web/src/lib/db/local-provider-instances.ts`:
- Around line 284-307: Update the JSDoc for allocateLocalInstanceId to state
that its returned id may become stale after withStoreLock releases because
callers write under a separate lock, allowing concurrent creates to collide.
Direct new callers that need allocation and persistence to be atomic to
createLocalProviderInstance, while leaving the implementation unchanged.

In `@apps/web/src/routes/api/chat/__tests__/providers-custom-route.test.ts`:
- Around line 249-276: The custom provider route tests need coverage for the
local update path. Add a test alongside the existing local create/Postgres cases
that selects the local store, posts an existing
`${CUSTOM_PROVIDER_ID_PREFIX}my-endpoint` provider id, asserts
`upsertLocalProviderInstanceMock` receives that id, and verifies
`createLocalProviderInstanceMock` is not called.
- Around line 205-247: Mock the "`@/lib/pi/runtime/deployed-chat-runtime`" module
in the providers-custom-route tests so isDeployedChatRuntimeSurface()
consistently returns false. Apply this explicit local-development runtime state
to both loopback URL tests while preserving their existing assertions.

In `@packages/pi-protocol/src/provider-catalog.ts`:
- Around line 6-16: Update PI_CUSTOM_PROVIDER_APIS to use a keyed record or
equivalent exhaustive mapped type over PiCustomProviderApi, then derive the
exported array from that record. Ensure every union member requires an entry so
adding a new API family fails type checking until it is registered here, while
preserving the existing array consumers and runtime validation behavior.
🪄 Autofix

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: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 5f89f999-f18e-44bd-8da5-1d3a603163c8

📥 Commits

Reviewing files that changed from the base of the PR and between bbfbd6a and 40ad206.

📒 Files selected for processing (20)
  • .pi/settings.json
  • AGENTS.md
  • apps/web/openapi.json
  • apps/web/src/lib/db/__tests__/local-provider-instances.test.ts
  • apps/web/src/lib/db/chat-db-config.ts
  • apps/web/src/lib/db/local-provider-instances.ts
  • apps/web/src/lib/db/occ-instances.ts
  • apps/web/src/lib/db/user-providers.ts
  • apps/web/src/lib/db/user-settings.ts
  • apps/web/src/lib/env-manager.ts
  • apps/web/src/lib/fs-atomic.ts
  • apps/web/src/lib/pi/runtime/__tests__/custom-provider-local-registration.test.ts
  • apps/web/src/lib/pi/runtime/__tests__/openai-chat-completions-registration.test.ts
  • apps/web/src/lib/pi/runtime/__tests__/session-factory.test.ts
  • apps/web/src/lib/pi/runtime/custom-provider-registry.ts
  • apps/web/src/lib/pi/runtime/openai-chat-completions-url.ts
  • apps/web/src/lib/pi/server-shared.test.ts
  • apps/web/src/routes/api/chat/__tests__/providers-custom-route.test.ts
  • apps/web/src/routes/api/chat/providers.ts
  • packages/pi-protocol/src/provider-catalog.ts
💤 Files with no reviewable changes (1)
  • .pi/settings.json

Comment thread apps/web/src/lib/db/local-provider-instances.ts
Comment thread apps/web/src/lib/db/local-provider-instances.ts
Comment thread apps/web/src/lib/db/local-provider-instances.ts
Comment thread apps/web/src/lib/pi/runtime/custom-provider-registry.ts Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 20 out of 20 changed files in this pull request and generated no new comments.

Suppressed comments (3)

apps/web/src/lib/db/local-provider-instances.ts:258

  • When creating a new local provider instance, modelId is set from normalized.modelIds[0] without guarding against an empty model list. If an invalid call site passes no models, the store can be written in a shape that will fail validation on the next read.
      baseUrl: input.baseUrl,
      modelId: normalized.modelIds[0],
      api: normalized.api,
      modelIds: normalized.modelIds,
      apiKey,

apps/web/src/lib/db/local-provider-instances.ts:206

  • modelId is written as normalized.modelIds[0] without verifying the normalized model list is non-empty. If a caller ever upserts an instance with no modelIds/modelId, JSON serialization will drop modelId, making the store unreadable on the next load and effectively disabling local custom providers.

This issue also appears on line 254 of the same file.

      baseUrl: instance.baseUrl,
      modelId: normalized.modelIds[0],
      api: normalized.api,
      modelIds: normalized.modelIds,
      apiKey,

apps/web/src/lib/pi/runtime/custom-provider-registry.ts:162

  • instance.api is trusted from persisted data. If the DB/local store ever contains an unexpected string (corruption, manual edit, older bug), it will be passed through as the provider API and can produce invalid Pi registrations. Consider validating the value against the supported API families and defaulting (or skipping) when it is not recognized.
  for (const instance of instances) {
    const api: PiCustomProviderApi = instance.api ?? "openai-completions"
    // Legacy single-model rows carry `modelId` only; normalized rows carry

Copilot AI review requested due to automatic review settings August 5, 2026 12:22

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 20 out of 20 changed files in this pull request and generated no new comments.

Suppressed comments (3)

apps/web/src/routes/api/chat/providers.ts:487

  • DELETE for local custom provider instances will 500 if the local provider store is malformed/unreadable (e.g. getLocalProviderInstance throws). That makes it hard to remove broken instances via the Settings UI. Consider catching local-store errors here and returning a 400 with an actionable message, while still letting DB errors propagate on the Postgres path.
          if (useLocalProviderStore(userId)) {
            const existing = await getLocalProviderInstance(
              userId,
              body.providerId
            )

apps/web/src/routes/api/chat/providers.ts:236

  • Local custom provider mutations can still 500 if the local provider store is malformed/unreadable. createLocalProviderInstance / upsertLocalProviderInstance will throw, and this route-level handler catches it as a generic 500, which prevents users from recovering via the Settings UI (contradicts the intent in listLocalProviderInstancesForRoute). Consider translating local-store read/write errors into a 400 with an actionable message (or otherwise handling them distinctly from DB errors).

This issue also appears on line 483 of the same file.

    instanceId = useLocalStore
      ? await createLocalProviderInstance(
          userId,
          instanceBody,
          isNamedOccTarget ? toOccInstanceId : toCustomProviderId,

apps/web/src/lib/db/local-provider-instances.ts:35

  • PR description mentions a single <projectRoot>/.fleet/providers.json, but the implementation (and this docstring) uses per-scope files: .fleet/providers.anonymous.json and .fleet/providers.user-<hash>.json. Consider updating the PR description to match the actual on-disk paths to avoid confusion when validating the feature.
/**
 * Resolves the gitignored local provider store path for a project root. One
 * file per account: anonymous local chat shares `providers.anonymous.json`,
 * signed-in local accounts get `providers.user-<hash>.json`, so an anonymous
 * user and a local Better Auth account on the same machine never read each

- Pin the deployed surface in loopback provider route tests and cover the
  local update branch (upsert, no new id allocation)
- Document that allocateLocalInstanceId's id can go stale after its lock
  releases; point new callers at createLocalProviderInstance
- Derive PI_CUSTOM_PROVIDER_APIS from a keyed record so adding an API
  family fails type checking until registered here
- Use a random temp suffix in writeFileAtomic so concurrent writes in the
  same millisecond cannot collide on the same temp path

Co-authored-by: Cursor <cursoragent@cursor.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 20 out of 20 changed files in this pull request and generated no new comments.

Suppressed comments (2)

packages/pi-protocol/src/provider-catalog.ts:273

  • New shared helpers in this file (e.g. allocateProviderId, normalizeCustomProviderInstance, isPiCustomProviderApi / isOccFamilyApi) aren’t covered by the existing pi-protocol unit tests (provider-catalog.test.ts currently only covers slug/id helpers). Adding focused tests here would reduce the risk of silent regressions in both the Postgres and local-file provider stores.
export function allocateProviderId(
  baseSlug: string,
  existingIds: ReadonlySet<string>,
  toId: (slug: string) => string
): string {

apps/web/src/routes/api/chat/providers.ts:102

  • The comment says the unreadable local store degrades to "no instances" with a diagnostic, but this code path only logs a console warning and returns an empty list (no user-visible diagnostic from this route). This mismatch can mislead future maintainers when debugging missing instances in Settings.
/**
 * A malformed/unreadable local store must not 500 the Settings providers list
 * (the UI is how users fix or remove broken instances); degrade to "no
 * instances" with a diagnostic, matching the registration path. DB errors on
 * the Postgres branch still propagate.
 */

@Zochory
Zochory merged commit efcfbfd into main Aug 5, 2026
29 checks passed
@Zochory
Zochory deleted the implementation/local-custom-provider-store branch August 5, 2026 12:49
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