Skip to content

Make provider model lists dynamic based on auth context#1556

Merged
juliusmarminge merged 15 commits intomainfrom
t3code/dynamic-claude-models
Mar 30, 2026
Merged

Make provider model lists dynamic based on auth context#1556
juliusmarminge merged 15 commits intomainfrom
t3code/dynamic-claude-models

Conversation

@juliusmarminge
Copy link
Copy Markdown
Member

@juliusmarminge juliusmarminge commented Mar 29, 2026

Summary

  • Detect Claude subscription type from claude auth status output, with an SDK-based fallback when the CLI output is missing the plan field.
  • Cache subscription probes for 5 minutes to avoid repeated startup checks.
  • Adjust built-in Claude model defaults so premium tiers get 1M context as the default while keeping 200k available.
  • Preserve existing auth/status behavior when the subscription type cannot be resolved.

Testing

  • Not run (per request).
  • Expected validation: bun fmt
  • Expected validation: bun lint
  • Expected validation: bun typecheck
  • Expected validation: bun run test

Note

High Risk
Introduces a breaking contract change (ServerProvider.auth replacing authStatus) and alters provider health-check behavior to dynamically filter/adjust models based on detected account/subscription, which can affect model availability and UI display if detection is wrong.

Overview
Providers now surface richer auth context and use it to shape available models. The ServerProvider contract replaces authStatus with a structured auth object (status, optional type/label), and the web settings UI now displays “Authenticated · ” when available.

Codex: adds an app-server JSON-RPC probe (probeCodexAccount) cached for 5 minutes, derives CodexAccountSnapshot, and hides gpt-5.3-codex-spark unless the account is ChatGPT Pro (Spark now disabled for API keys/unknown plans and requests for Spark fall back to gpt-5.3-codex).

Claude: detects subscription type from claude auth status JSON with an SDK-based fallback (cached 5 minutes), annotates auth.type/auth.label, and for premium tiers (Max/Enterprise/Team) flips the default context window to 1M while keeping 200k available. Tests/fixtures are updated accordingly, including model picker behavior depending on server-reported model lists.

Written by Cursor Bugbot for commit 4c5408d. This will update automatically on new commits. Configure here.

Note

Make Codex and Claude provider model lists dynamic based on account auth context

  • Replaces the flat authStatus string on ServerProvider with a structured auth: { status, type, label } object across the contracts, server, and web layers.
  • Codex spark model (gpt-5.3-codex-spark) is now filtered from the model list unless the account has a 'pro' ChatGPT plan; API key and unknown plan accounts get spark disabled.
  • Claude built-in models now default to 200k context window; premium subscriptions (detected via CLI or SDK probe) flip the default to 1M.
  • Auth probes for both Codex and Claude are cached for 5 minutes per binary path to reduce repeated subprocess or SDK invocations.
  • Risk: ServerProvider.authStatus is removed and replaced by ServerProvider.auth—any consumers reading the old field will receive undefined.

Macroscope summarized 4c5408d.

- Probe Claude account type when auth status lacks it
- Prefer 1M context for premium tiers and keep 200k default otherwise
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 29, 2026

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: ab7b9724-bddd-4953-b011-e23d2cf9bfbe

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch t3code/dynamic-claude-models

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

@github-actions github-actions bot added size:L 100-499 changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. labels Mar 29, 2026
Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Normalization strips underscores but set contains underscore entry
    • Changed the set entry from "max_plan" to "maxplan" to match the post-normalization form produced by the underscore-stripping regex.

Create PR

Or push these changes by commenting:

@cursor push fb69915178
Preview (fb69915178)
diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts
--- a/apps/server/src/provider/Layers/ClaudeProvider.ts
+++ b/apps/server/src/provider/Layers/ClaudeProvider.ts
@@ -269,7 +269,7 @@
 /** Subscription types where the 1M context window is included in the plan. */
 const PREMIUM_SUBSCRIPTION_TYPES = new Set([
   "max",
-  "max_plan",
+  "maxplan",
   "max5",
   "max20",
   "enterprise",

cursoragent and others added 3 commits March 29, 2026 19:53
The normalization regex strips underscores (/[\s_-]+/g), so 'max_plan'
normalizes to 'maxplan' which never matched the set entry 'max_plan'.
Changed the set entry to 'maxplan' to match the post-normalization form.
- Keep context window options intact when adjusting subscription models
- Mark the 1m window as default without rebuilding option objects
- Replace flat `authStatus` with structured `auth` across server and web
- Surface Claude subscription type in provider summaries
- Update provider status tests and shared contracts
Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Timeout cannot abort the spawned Claude child process
    • Moved AbortController creation outside the Effect.tryPromise async function and added Effect.ensuring to guarantee abort() is called when the Effect is interrupted by timeoutOption, preventing leaked child processes.

Create PR

Or push these changes by commenting:

@cursor push e409cf506b
Preview (e409cf506b)
diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts
--- a/apps/server/src/provider/Layers/ClaudeProvider.ts
+++ b/apps/server/src/provider/Layers/ClaudeProvider.ts
@@ -324,27 +324,28 @@
  * This is used as a fallback when `claude auth status` does not include
  * subscription type information.
  */
-const probeClaudeCapabilities = (binaryPath: string) =>
-  Effect.tryPromise(async () => {
-    const abort = new AbortController();
-    try {
-      const q = claudeQuery({
-        prompt: ".",
-        options: {
-          pathToClaudeCodeExecutable: binaryPath,
-          abortController: abort,
-          maxTurns: 0,
-          settingSources: [],
-          allowedTools: [],
-          stderr: () => {},
-        },
-      });
-      const init = await q.initializationResult();
-      return { subscriptionType: init.account?.subscriptionType };
-    } finally {
-      if (!abort.signal.aborted) abort.abort();
-    }
+const probeClaudeCapabilities = (binaryPath: string) => {
+  const abort = new AbortController();
+  return Effect.tryPromise(async () => {
+    const q = claudeQuery({
+      prompt: ".",
+      options: {
+        pathToClaudeCodeExecutable: binaryPath,
+        abortController: abort,
+        maxTurns: 0,
+        settingSources: [],
+        allowedTools: [],
+        stderr: () => {},
+      },
+    });
+    const init = await q.initializationResult();
+    return { subscriptionType: init.account?.subscriptionType };
   }).pipe(
+    Effect.ensuring(
+      Effect.sync(() => {
+        if (!abort.signal.aborted) abort.abort();
+      }),
+    ),
     Effect.timeoutOption(CAPABILITIES_PROBE_TIMEOUT_MS),
     Effect.result,
     Effect.map((result) => {
@@ -352,6 +353,7 @@
       return Option.isSome(result.success) ? result.success.value : undefined;
     }),
   );
+};
 
 const runClaudeCommand = (args: ReadonlyArray<string>) =>
   Effect.gen(function* () {

@juliusmarminge
Copy link
Copy Markdown
Member Author

@cursor push e409cf5

…tController outside Promise

Move the AbortController creation outside the Effect.tryPromise async
function and use Effect.ensuring to guarantee abort() is called when
the Effect completes or is interrupted (e.g. by timeoutOption). This
prevents leaked child processes when initializationResult() hangs
indefinitely.

Applied via @cursor push command
@juliusmarminge
Copy link
Copy Markdown
Member Author

@cursor push fb69915

cursor bot and others added 4 commits March 29, 2026 23:27
The normalization regex strips underscores (/[\s_-]+/g), so 'max_plan'
normalizes to 'maxplan' which never matched the set entry 'max_plan'.
Changed the set entry to 'maxplan' to match the post-normalization form.

Applied via @cursor push command
Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Whitespace-only subscription type causes schema validation failure
    • Updated asNonEmptyString to trim the string before checking length, so whitespace-only strings are correctly rejected as Option.none() and never reach the TrimmedNonEmptyString schema.
  • ✅ Fixed: Exported function only used within same file
    • Removed the export keyword from adjustModelsForSubscription since it is only called within the same file.

Create PR

Or push these changes by commenting:

@cursor push 0fe51dd194
Preview (0fe51dd194)
diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts
--- a/apps/server/src/provider/Layers/ClaudeProvider.ts
+++ b/apps/server/src/provider/Layers/ClaudeProvider.ts
@@ -218,8 +218,11 @@
 const SUBSCRIPTION_CONTAINER_KEYS = ["account", "subscription", "user", "billing"] as const;
 
 /** Lift an unknown value into `Option<string>` if it is a non-empty string. */
-const asNonEmptyString = (v: unknown): Option.Option<string> =>
-  typeof v === "string" && v.length > 0 ? Option.some(v) : Option.none();
+const asNonEmptyString = (v: unknown): Option.Option<string> => {
+  if (typeof v !== "string") return Option.none();
+  const trimmed = v.trim();
+  return trimmed.length > 0 ? Option.some(trimmed) : Option.none();
+};
 
 /** Lift an unknown value into `Option<Record>` if it is a plain object. */
 const asRecord = (v: unknown): Option.Option<Record<string, unknown>> =>
@@ -283,7 +286,7 @@
  * - Other tiers (Pro, free, unknown): 200k context stays the default;
  *   1M remains available as a manual option so users can still enable it.
  */
-export function adjustModelsForSubscription(
+function adjustModelsForSubscription(
   baseModels: ReadonlyArray<ServerProviderModel>,
   subscriptionType: string | undefined,
 ): ReadonlyArray<ServerProviderModel> {

),
},
};
});
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.

Exported function only used within same file

Low Severity

adjustModelsForSubscription is exported but only consumed internally on line 486 of the same file. No test file or other module imports it. The unnecessary export widens the module's public API surface without reason.

Fix in Cursor Fix in Web

- Probe Codex account data to gate Spark models and surface auth type/label
- Add Claude subscription labels for provider status and settings UI
- Update provider picker and contracts to carry auth labels
@github-actions github-actions bot added size:XL 500-999 changed lines (additions + deletions). and removed size:L 100-499 changed lines (additions + deletions). labels Mar 29, 2026
Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

There are 4 total unresolved issues (including 2 from previous reviews).

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Codex probe child process leaks on timeout
    • Added AbortSignal support to probeCodexAccount and wrapped probeCodexCapabilities with Effect.ensuring to abort the signal (and thus kill the child process) on timeout or interruption, mirroring the existing probeClaudeCapabilities pattern.
  • ✅ Fixed: Object.assign sets explicit isDefault: undefined property
    • Replaced Object.assign with explicit object construction that only includes the isDefault key when set to true, omitting it entirely for non-default options.

Create PR

Or push these changes by commenting:

@cursor push c5f4489623
Preview (c5f4489623)
diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts
--- a/apps/server/src/provider/Layers/ClaudeProvider.ts
+++ b/apps/server/src/provider/Layers/ClaudeProvider.ts
@@ -333,7 +333,9 @@
       capabilities: {
         ...caps,
         contextWindowOptions: caps.contextWindowOptions.map((opt) =>
-          Object.assign({}, opt, { isDefault: opt.value === "1m" ? true : undefined }),
+          opt.value === "1m"
+            ? { value: opt.value, label: opt.label, isDefault: true as const }
+            : { value: opt.value, label: opt.label },
         ),
       },
     };

diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts
--- a/apps/server/src/provider/Layers/CodexProvider.ts
+++ b/apps/server/src/provider/Layers/CodexProvider.ts
@@ -313,8 +313,14 @@
 const probeCodexCapabilities = (input: {
   readonly binaryPath: string;
   readonly homePath?: string;
-}) =>
-  Effect.tryPromise(() => probeCodexAccount(input)).pipe(
+}) => {
+  const abort = new AbortController();
+  return Effect.tryPromise(() => probeCodexAccount({ ...input, signal: abort.signal })).pipe(
+    Effect.ensuring(
+      Effect.sync(() => {
+        if (!abort.signal.aborted) abort.abort();
+      }),
+    ),
     Effect.timeoutOption(CAPABILITIES_PROBE_TIMEOUT_MS),
     Effect.result,
     Effect.map((result) => {
@@ -322,6 +328,7 @@
       return Option.isSome(result.success) ? result.success.value : undefined;
     }),
   );
+};
 
 const runCodexCommand = (args: ReadonlyArray<string>) =>
   Effect.gen(function* () {

diff --git a/apps/server/src/provider/codexAppServer.ts b/apps/server/src/provider/codexAppServer.ts
--- a/apps/server/src/provider/codexAppServer.ts
+++ b/apps/server/src/provider/codexAppServer.ts
@@ -43,6 +43,7 @@
 export async function probeCodexAccount(input: {
   readonly binaryPath: string;
   readonly homePath?: string;
+  readonly signal?: AbortSignal;
 }): Promise<CodexAccountSnapshot> {
   return await new Promise((resolve, reject) => {
     const child = spawn(input.binaryPath, ["app-server"], {
@@ -82,6 +83,12 @@
         ),
       );
 
+    if (input.signal?.aborted) {
+      fail(new Error("Codex account probe aborted."));
+      return;
+    }
+    input.signal?.addEventListener("abort", () => fail(new Error("Codex account probe aborted.")));
+
     const writeMessage = (message: unknown) => {
       if (!child.stdin.writable) {
         fail(new Error("Cannot write to codex app-server stdin."));

…ault: undefined

- Add AbortSignal support to probeCodexAccount and use Effect.ensuring in
  probeCodexCapabilities to kill the child process when the timeout fires,
  mirroring the existing pattern in probeClaudeCapabilities.

- Replace Object.assign with explicit object construction in
  adjustModelsForSubscription to avoid setting an explicit
  isDefault: undefined property on non-default context window options.
@juliusmarminge juliusmarminge changed the title Detect Claude subscription tiers and tune model defaults Make provider model lists dynamic based on auth context Mar 30, 2026
- Disable Spark for API key and unknown Codex accounts
- Add Codex and Claude auth labels for API key and subscription states
- Update provider tests for dynamic model gating
@juliusmarminge
Copy link
Copy Markdown
Member Author

@cursor push c5f4489

…ault: undefined

- Add AbortSignal support to probeCodexAccount and use Effect.ensuring in
  probeCodexCapabilities to kill the child process when the timeout fires,
  mirroring the existing pattern in probeClaudeCapabilities.

- Replace Object.assign with explicit object construction in
  adjustModelsForSubscription to avoid setting an explicit
  isDefault: undefined property on non-default context window options.

Applied via @cursor push command
Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 3 total unresolved issues (including 2 from previous reviews).

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Session default sparkEnabled contradicts new account logic
    • Changed the default sparkEnabled from true to false in the session context initializer at codexAppServerManager.ts:483, so that when account/read fails the fallback correctly denies spark model access, matching the new whitelist-only-for-pro policy in readCodexAccountSnapshot.

Create PR

Or push these changes by commenting:

@cursor push f032a4203d
Preview (f032a4203d)
diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts
--- a/apps/server/src/codexAppServerManager.ts
+++ b/apps/server/src/codexAppServerManager.ts
@@ -480,7 +480,7 @@
         account: {
           type: "unknown",
           planType: null,
-          sparkEnabled: true,
+          sparkEnabled: false,
         },
         child,
         output,

},
} as const;
}

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.

Session default sparkEnabled contradicts new account logic

Medium Severity

The default account in the session context initializes sparkEnabled: true for the "unknown" type, but readCodexAccountSnapshot now returns sparkEnabled: false for unknown accounts. When account/read fails during session startup, the fallback default keeps spark enabled, contradicting the new whitelist-only-for-pro policy. This lets users without a confirmed pro plan use the spark model if the account probe request fails.

Additional Locations (1)
Fix in Cursor Fix in Web

* - Other tiers (Pro, free, unknown): 200k context stays the default;
* 1M remains available as a manual option so users can still enable it.
*/
export function adjustModelsForSubscription(
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.

🟢 Low Layers/ClaudeProvider.ts:375

When contextWindowOptions lacks a "1m" entry, adjustModelsForSubscription maps every option to an object without isDefault, leaving the model with no default context window. The original model may have had isDefault: true on another option, but this is stripped away. Consider returning the model unchanged when "1m" isn't present, or preserve the existing default otherwise.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/server/src/provider/Layers/ClaudeProvider.ts around line 375:

When `contextWindowOptions` lacks a `"1m"` entry, `adjustModelsForSubscription` maps every option to an object without `isDefault`, leaving the model with no default context window. The original model may have had `isDefault: true` on another option, but this is stripped away. Consider returning the model unchanged when `"1m"` isn't present, or preserve the existing default otherwise.

Evidence trail:
apps/server/src/provider/Layers/ClaudeProvider.ts lines 385-397 (REVIEWED_COMMIT): The map callback creates objects without preserving `isDefault` for non-"1m" options.
packages/shared/src/model.ts lines 62-63: `getDefaultContextWindow` relies on finding an option with `isDefault` property.

@juliusmarminge juliusmarminge merged commit bf9c828 into main Mar 30, 2026
11 checks passed
@juliusmarminge juliusmarminge deleted the t3code/dynamic-claude-models branch March 30, 2026 00:51
Chrono-byte pushed a commit to Chrono-byte/t3code that referenced this pull request Mar 31, 2026
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cursor[bot] <206951365+cursor[bot]@users.noreply.github.com>
xddinside pushed a commit to xddinside/t3code that referenced this pull request Apr 4, 2026
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cursor[bot] <206951365+cursor[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL 500-999 changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants