);
}
diff --git a/packages/db/src/lib/__tests__/provider-credit-balance.test.ts b/packages/db/src/lib/__tests__/provider-credit-balance.test.ts
index c0be9977c..e437a7906 100644
--- a/packages/db/src/lib/__tests__/provider-credit-balance.test.ts
+++ b/packages/db/src/lib/__tests__/provider-credit-balance.test.ts
@@ -7,6 +7,7 @@ vi.mock('../../encryption', () => ({
import {
fetchOpenRouterCreditBalance,
+ fetchRoomoteCreditBalance,
getProviderCreditBalances,
parseOpenRouterKeyBalance,
parseOpenRouterKeyDetails,
@@ -66,6 +67,35 @@ describe('parseOpenRouterKeyBalance', () => {
});
});
+describe('fetchRoomoteCreditBalance', () => {
+ it('uses the managed Roomote key against the OpenRouter balance endpoint', async () => {
+ const fetchImpl = vi
+ .fn()
+ .mockResolvedValue(
+ jsonResponse({ data: { limit: 5, limit_remaining: 3 } }),
+ );
+
+ await expect(
+ fetchRoomoteCreditBalance({
+ runtimeEnv: { R_TRIAL_OPENROUTER_API_KEY: 'managed-key' },
+ fetchImpl,
+ }),
+ ).resolves.toMatchObject({
+ providerId: 'roomote',
+ remaining: 3,
+ limit: 5,
+ });
+ expect(fetchImpl).toHaveBeenCalledWith(
+ 'https://openrouter.ai/api/v1/key',
+ expect.objectContaining({
+ headers: expect.objectContaining({
+ authorization: 'Bearer managed-key',
+ }),
+ }),
+ );
+ });
+});
+
describe('fetchOpenRouterCreditBalance', () => {
it('returns null when no API key is configured', async () => {
const fetchImpl = vi.fn();
diff --git a/packages/db/src/lib/provider-credit-balance.ts b/packages/db/src/lib/provider-credit-balance.ts
index c888cd6a1..4c4270f45 100644
--- a/packages/db/src/lib/provider-credit-balance.ts
+++ b/packages/db/src/lib/provider-credit-balance.ts
@@ -1,5 +1,6 @@
import {
OPENROUTER_KEY_ENDPOINT,
+ ROOMOTE_INFERENCE_API_KEY_ENV_VAR_NAME,
type ProviderCreditBalance,
} from '@roomote/types';
@@ -164,9 +165,10 @@ export async function fetchOpenRouterKeyDetails(
async function fetchOpenRouterKeyPayload(
options: BalanceFetchOptions,
+ envVarName = 'OPENROUTER_API_KEY',
): Promise<{ apiKey: string; payload: unknown } | null> {
const fetchImpl = options.fetchImpl ?? fetch;
- const apiKey = await resolveModelProviderEnvValue(['OPENROUTER_API_KEY'], {
+ const apiKey = await resolveModelProviderEnvValue(envVarName, {
...(options.runtimeEnv && { runtimeEnv: options.runtimeEnv }),
...(options.executor && { executor: options.executor }),
});
@@ -202,6 +204,29 @@ export async function fetchOpenRouterCreditBalance(
};
}
+export async function fetchRoomoteCreditBalance(
+ options: BalanceFetchOptions = {},
+): Promise {
+ const response = await fetchOpenRouterKeyPayload(
+ options,
+ ROOMOTE_INFERENCE_API_KEY_ENV_VAR_NAME,
+ );
+ if (!response) {
+ return null;
+ }
+
+ const parsed = parseOpenRouterKeyBalance(response.payload);
+ if (!parsed) {
+ return null;
+ }
+
+ return {
+ providerId: 'roomote',
+ ...parsed,
+ fetchedAt: new Date().toISOString(),
+ };
+}
+
/**
* Fetch credit balances for every supported keyed provider. Providers that are
* not connected, fail to respond, or return nothing displayable are omitted.
@@ -211,6 +236,7 @@ export async function getProviderCreditBalances(
): Promise {
const results = await Promise.allSettled([
fetchOpenRouterCreditBalance(options),
+ fetchRoomoteCreditBalance(options),
]);
return results.flatMap((result) =>
diff --git a/packages/types/src/provider-credit-balance.ts b/packages/types/src/provider-credit-balance.ts
index 76e188912..3275e3a94 100644
--- a/packages/types/src/provider-credit-balance.ts
+++ b/packages/types/src/provider-credit-balance.ts
@@ -1,13 +1,13 @@
/**
* Normalized credit/spend remaining data for API-key inference providers that
- * expose a key-readable balance endpoint (currently OpenRouter), displayed on
- * the Models settings page under the connected provider row.
+ * expose a key-readable balance endpoint, displayed on the Models settings
+ * page under the connected provider row.
*
* Missing balance is a non-error: the UI simply omits the balance line.
*/
/** Setup-catalog provider ids that report credit balance. */
-export type ProviderCreditBalanceProviderId = 'openrouter';
+export type ProviderCreditBalanceProviderId = 'openrouter' | 'roomote';
export interface ProviderCreditBalance {
providerId: ProviderCreditBalanceProviderId;
From 0b1e595016ed0c17ace181781f3e550b35b0a230 Mon Sep 17 00:00:00 2001
From: Bruno Bergher
Date: Thu, 27 Aug 2026 16:30:34 +0100
Subject: [PATCH 11/17] fix: remove unused inference provider imports
---
.../src/components/settings/InferenceProviderSection.tsx | 8 --------
1 file changed, 8 deletions(-)
diff --git a/apps/web/src/components/settings/InferenceProviderSection.tsx b/apps/web/src/components/settings/InferenceProviderSection.tsx
index 37d42a95f..33d48a2aa 100644
--- a/apps/web/src/components/settings/InferenceProviderSection.tsx
+++ b/apps/web/src/components/settings/InferenceProviderSection.tsx
@@ -55,9 +55,7 @@ import { GitHubCopilotConnectDialog } from '@/components/settings/GitHubCopilotC
import { XaiConnectDialog } from '@/components/settings/XaiConnectDialog';
import { ProviderCreditBalanceLine } from '@/components/settings/ProviderCreditBalanceLine';
import { SubscriptionUsageLine } from '@/components/settings/SubscriptionUsageLine';
-import { Secret } from 'modal';
-const MASKED_VALUE = '••••••••••••••••••••••••••••';
const PROVIDER_GRID_ROW_CLASS =
'grid gap-2 md:grid-cols-[minmax(160px,220px)_minmax(0,1fr)]';
@@ -179,12 +177,6 @@ function ConnectedProviderRow({
const runtimeKeyLabel = provider.envVarName
? `${provider.label} API key is managed by ${provider.envVarName}`
: `${provider.label} API key is managed by an environment variable`;
- const inputValue =
- provider.authKind === 'endpoint'
- ? (provider.additionalEnvValues[provider.envVarName ?? ''] ??
- 'Configured endpoint')
- : MASKED_VALUE;
-
return (
From 726034649e831d189faf6e25b33b7c42a1b1fa64 Mon Sep 17 00:00:00 2001
From: Bruno Bergher
Date: Thu, 27 Aug 2026 16:50:19 +0100
Subject: [PATCH 12/17] fix: derive Roomote trial model preset
---
apps/web/src/trpc/commands/setup-new/index.ts | 5 +-
.../types/src/model-provider-config.test.ts | 27 +++++++
packages/types/src/model-provider-config.ts | 76 ++++++++++++-------
3 files changed, 76 insertions(+), 32 deletions(-)
diff --git a/apps/web/src/trpc/commands/setup-new/index.ts b/apps/web/src/trpc/commands/setup-new/index.ts
index 0da1e5dbd..c3f934617 100644
--- a/apps/web/src/trpc/commands/setup-new/index.ts
+++ b/apps/web/src/trpc/commands/setup-new/index.ts
@@ -55,6 +55,7 @@ import {
CHATGPT_SUBSCRIPTION_PROVIDER_ID,
XAI_SUBSCRIPTION_PROVIDER_ID,
ROOMOTE_INFERENCE_PROVIDER_ID,
+ ROOMOTE_TRIAL_MODEL_PRESET_ID,
OPENAI_COMPATIBLE_PROVIDER_ID,
collectSetupModelProviderCredentialValues,
createEmptyDeploymentModelConfig,
@@ -300,8 +301,6 @@ async function savePersistedTaskModelSettings(
* config, or task model settings) and refuses when a real provider is
* already connected, so it can never overwrite configuration.
*/
-const TRIAL_PRESET_ID = 'efficient';
-
export async function chooseSetupTrialInferenceCommand(auth: UserAuthSuccess) {
assertAdmin(auth);
@@ -381,7 +380,7 @@ export async function chooseSetupTrialInferenceCommand(auth: UserAuthSuccess) {
const provider = getSetupModelProvider(ROOMOTE_INFERENCE_PROVIDER_ID);
const runtimeModelConfig = buildRecommendedDeploymentModelConfig(
provider,
- TRIAL_PRESET_ID,
+ ROOMOTE_TRIAL_MODEL_PRESET_ID,
);
const defaultModelId = runtimeModelConfig.roomoteModel;
const trialModels = provider.suggestedTaskModels.map((suggestion) =>
diff --git a/packages/types/src/model-provider-config.test.ts b/packages/types/src/model-provider-config.test.ts
index fd9e247d7..c0670b54c 100644
--- a/packages/types/src/model-provider-config.test.ts
+++ b/packages/types/src/model-provider-config.test.ts
@@ -13,6 +13,7 @@ import {
getReasoningEffortLabel,
getRecommendedModelPresets,
getSetupModelProvider,
+ ROOMOTE_TRIAL_MODEL_PRESET_ID,
getSetupProviderTaskModelPrefix,
normalizeDeploymentModelConfig,
REASONING_EFFORT_OPTIONS,
@@ -319,6 +320,32 @@ describe('SETUP_MODEL_PROVIDER_CATALOG', () => {
).toBe(true);
});
+ it('derives the Roomote Trial preset from OpenRouter Efficient with Roomote model IDs', () => {
+ const openRouterEfficient = getRecommendedModelPresets(
+ getSetupModelProvider('openrouter'),
+ ).find((preset) => preset.id === 'efficient');
+ const roomoteTrial = getRecommendedModelPresets(
+ getSetupModelProvider('roomote'),
+ ).find((preset) => preset.id === ROOMOTE_TRIAL_MODEL_PRESET_ID);
+
+ expect(roomoteTrial).toMatchObject({
+ id: ROOMOTE_TRIAL_MODEL_PRESET_ID,
+ label: 'Roomote Trial',
+ default: true,
+ });
+ expect(roomoteTrial?.roles).toEqual(
+ Object.fromEntries(
+ Object.entries(openRouterEfficient!.roles).map(([role, config]) => [
+ role,
+ {
+ ...config,
+ modelId: config.modelId.replace(/^openrouter\//u, 'roomote/'),
+ },
+ ]),
+ ),
+ );
+ });
+
it('keeps recommended-model slugs and default models under each provider prefix', () => {
for (const provider of SETUP_MODEL_PROVIDER_CATALOG) {
if ('dynamicModels' in provider && provider.dynamicModels) {
diff --git a/packages/types/src/model-provider-config.ts b/packages/types/src/model-provider-config.ts
index b17f6a42b..cbc980953 100644
--- a/packages/types/src/model-provider-config.ts
+++ b/packages/types/src/model-provider-config.ts
@@ -51,6 +51,7 @@ export const XAI_SUBSCRIPTION_PROVIDER_ID = 'xai-subscription' as const;
export const ROOMOTE_INFERENCE_PROVIDER_ID = 'roomote' as const;
export const ROOMOTE_INFERENCE_API_KEY_ENV_VAR_NAME =
'R_TRIAL_OPENROUTER_API_KEY' as const;
+export const ROOMOTE_TRIAL_MODEL_PRESET_ID = 'trial' as const;
/**
* Model-id prefix used when composing or looking up task models for a setup
@@ -414,37 +415,66 @@ const OPENAI_RECOMMENDED_MODEL_PRESETS = [
},
] as const satisfies readonly RecommendedModelPreset[];
+const OPENROUTER_EFFICIENT_MODEL_PRESET = {
+ id: 'efficient',
+ label: 'Efficient',
+ // Reasoning efforts are intentionally unset so the shared per-role
+ // defaults apply, exactly as they do for a hand-configured model.
+ roles: {
+ coding: { modelId: 'openrouter/openai/gpt-5.6-luna' },
+ helper: { modelId: 'openrouter/openai/gpt-5.6-luna' },
+ codeReview: { modelId: 'openrouter/openai/gpt-5.6-luna' },
+ explore: { modelId: 'openrouter/openai/gpt-5.6-luna' },
+ planning: { modelId: 'openrouter/openai/gpt-5.6-luna' },
+ },
+} as const satisfies RecommendedModelPreset;
+
+function rebaseOpenRouterModelIdForRoomote(modelId: string): string {
+ return modelId.replace(/^openrouter\//u, `${ROOMOTE_INFERENCE_PROVIDER_ID}/`);
+}
+
+function rebaseOpenRouterPresetForRoomote(
+ preset: RecommendedModelPreset,
+): RecommendedModelPreset {
+ return {
+ ...preset,
+ roles: Object.fromEntries(
+ Object.entries(preset.roles).map(([role, config]) => [
+ role,
+ {
+ ...config,
+ modelId: rebaseOpenRouterModelIdForRoomote(config.modelId),
+ },
+ ]),
+ ) as RecommendedModelPreset['roles'],
+ };
+}
+
+const ROOMOTE_TRIAL_MODEL_PRESET = {
+ ...rebaseOpenRouterPresetForRoomote(OPENROUTER_EFFICIENT_MODEL_PRESET),
+ id: ROOMOTE_TRIAL_MODEL_PRESET_ID,
+ label: 'Roomote Trial',
+ default: true,
+} as const satisfies RecommendedModelPreset;
+
export const SETUP_MODEL_PROVIDER_CATALOG = [
{
id: ROOMOTE_INFERENCE_PROVIDER_ID,
label: 'Roomote inference',
envVarName: ROOMOTE_INFERENCE_API_KEY_ENV_VAR_NAME,
- defaultRoomoteModel: 'roomote/openai/gpt-5.6-luna',
+ defaultRoomoteModel: ROOMOTE_TRIAL_MODEL_PRESET.roles.coding!.modelId,
authKind: 'api-key',
suggestedTaskModels: mapRecommendedTaskModels(
Object.fromEntries(
Object.entries(OPENROUTER_RECOMMENDED_TASK_MODEL_SLUGS).map(
([modelId, openRouterModelId]) => [
modelId,
- openRouterModelId.replace(/^openrouter\//u, 'roomote/'),
+ rebaseOpenRouterModelIdForRoomote(openRouterModelId),
],
),
) as RecommendedTaskModelSlugMap,
),
- recommendedPresets: [
- {
- id: 'efficient',
- label: 'Efficient',
- default: true,
- roles: {
- coding: { modelId: 'roomote/openai/gpt-5.6-luna' },
- helper: { modelId: 'roomote/openai/gpt-5.6-luna' },
- codeReview: { modelId: 'roomote/openai/gpt-5.6-luna' },
- explore: { modelId: 'roomote/openai/gpt-5.6-luna' },
- planning: { modelId: 'roomote/openai/gpt-5.6-luna' },
- },
- },
- ],
+ recommendedPresets: [ROOMOTE_TRIAL_MODEL_PRESET],
// Only hosting can enable this provider. It is never a user connection.
hidden: true,
},
@@ -510,19 +540,7 @@ export const SETUP_MODEL_PROVIDER_CATALOG = [
},
},
},
- {
- id: 'efficient',
- label: 'Efficient',
- // Reasoning efforts are intentionally unset so the shared per-role
- // defaults apply, exactly as they do for a hand-configured model.
- roles: {
- coding: { modelId: 'openrouter/openai/gpt-5.6-luna' },
- helper: { modelId: 'openrouter/openai/gpt-5.6-luna' },
- codeReview: { modelId: 'openrouter/openai/gpt-5.6-luna' },
- explore: { modelId: 'openrouter/openai/gpt-5.6-luna' },
- planning: { modelId: 'openrouter/openai/gpt-5.6-luna' },
- },
- },
+ OPENROUTER_EFFICIENT_MODEL_PRESET,
],
},
{
From 09397bae63409889ddbc72119c62f81a0b22808b Mon Sep 17 00:00:00 2001
From: Bruno Bergher
Date: Thu, 27 Aug 2026 17:06:19 +0100
Subject: [PATCH 13/17] test: align inference provider settings coverage
---
.../InferenceProviderSection.test.tsx | 22 ++++++++-----------
.../ProviderCreditBalanceLine.test.tsx | 4 ++--
2 files changed, 11 insertions(+), 15 deletions(-)
diff --git a/apps/web/src/components/settings/InferenceProviderSection.test.tsx b/apps/web/src/components/settings/InferenceProviderSection.test.tsx
index 0ccb85843..54a66fabd 100644
--- a/apps/web/src/components/settings/InferenceProviderSection.test.tsx
+++ b/apps/web/src/components/settings/InferenceProviderSection.test.tsx
@@ -551,7 +551,9 @@ describe('InferenceProviderSection', () => {
renderInferenceProviderSection();
// Locale-independent: currency separators and symbols vary by environment.
- expect(screen.getByText(/Credits:.*left/i)).toBeInTheDocument();
+ expect(
+ screen.getByText(/12[.,]50.*of.*50[.,]00.*left/i),
+ ).toBeInTheDocument();
expect(
screen.getByRole('progressbar', { name: 'Credit balance' }),
).toBeInTheDocument();
@@ -680,7 +682,9 @@ describe('InferenceProviderSection', () => {
expect(screen.getByText('xAI (Grok subscription)')).toBeInTheDocument();
expect(screen.getByText('xAI')).toBeInTheDocument();
- expect(screen.getByLabelText('API key for xAI')).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: 'Edit xAI API key' }),
+ ).toBeInTheDocument();
expect(
screen.getByText(/subscription is preferred at runtime/i),
).toBeInTheDocument();
@@ -779,7 +783,7 @@ describe('InferenceProviderSection', () => {
).not.toBeInTheDocument();
});
- it('masks a saved key on a connected row and allows rotating it', async () => {
+ it('opens an edit dialog for a saved key and allows rotating it', async () => {
providerSetupData.current = buildProviderSetup({
anthropicSavedKey: true,
});
@@ -787,10 +791,6 @@ describe('InferenceProviderSection', () => {
renderInferenceProviderSection();
- expect(screen.getByLabelText('API key for Anthropic')).toHaveValue(
- '••••••••••••••••••••••••••••',
- );
-
fireEvent.click(
screen.getByRole('button', { name: 'Edit Anthropic API key' }),
);
@@ -929,7 +929,7 @@ describe('InferenceProviderSection', () => {
renderInferenceProviderSection();
expect(screen.getByText('Roomote inference')).toBeInTheDocument();
- expect(screen.getByText(/Credits:.*left/i)).toBeInTheDocument();
+ expect(screen.getByText(/3[.,]00.*of.*5[.,]00.*left/i)).toBeInTheDocument();
expect(
screen.getByRole('progressbar', { name: 'Credit balance' }),
).toBeInTheDocument();
@@ -946,17 +946,13 @@ describe('InferenceProviderSection', () => {
).not.toBeInTheDocument();
});
- it('locks a runtime env-managed key behind a masked field and lock tooltip', () => {
+ it('locks a runtime env-managed key behind a lock tooltip', () => {
providerSetupData.current = buildProviderSetup({
openrouterRuntimeKey: true,
});
renderInferenceProviderSection();
- expect(screen.getByLabelText('API key for OpenRouter')).toHaveValue(
- '••••••••••••••••••••••••••••',
- );
- expect(screen.getByLabelText('API key for OpenRouter')).toBeDisabled();
expect(
screen.getByLabelText(
'OpenRouter API key is managed by OPENROUTER_API_KEY',
diff --git a/apps/web/src/components/settings/ProviderCreditBalanceLine.test.tsx b/apps/web/src/components/settings/ProviderCreditBalanceLine.test.tsx
index 6a1a0536d..1a10aa385 100644
--- a/apps/web/src/components/settings/ProviderCreditBalanceLine.test.tsx
+++ b/apps/web/src/components/settings/ProviderCreditBalanceLine.test.tsx
@@ -37,7 +37,7 @@ describe('ProviderCreditBalanceLine', () => {
render();
expect(
- screen.getByText(/Credits:.*12\.50.*of.*50\.00.*left/i),
+ screen.getByText(/12[.,]50.*of.*50[.,]00.*left/i),
).toBeInTheDocument();
expect(screen.getByRole('progressbar')).toBeInTheDocument();
});
@@ -49,7 +49,7 @@ describe('ProviderCreditBalanceLine', () => {
/>,
);
- expect(screen.getByText(/Credits:.*3\.00.*left/i)).toBeInTheDocument();
+ expect(screen.getByText(/3[.,]00.*left/i)).toBeInTheDocument();
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
});
});
From 081b1703314cc6cfd30ef3bc80441d801b4bade6 Mon Sep 17 00:00:00 2001
From: Matt Rubens <2600+mrubens@users.noreply.github.com>
Date: Thu, 27 Aug 2026 14:16:13 -0400
Subject: [PATCH 14/17] feat: store the Roomote inference key in Settings
instead of living off env
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The hosting-injected R_TRIAL_OPENROUTER_API_KEY becomes a delivery
mechanism only: the first admin setup-status read imports it once into
encrypted Settings storage (stamping trialInferenceKeyImportedAt so it is
never re-imported), and every runtime read — the inference gateway, credit
balance, provider status, the wizard's trial offer — resolves the stored
key alone. Deleting the Roomote inference provider therefore disables the
trial permanently, even while hosting keeps injecting the variable.
- resolveModelProviderEnvValue / resolveModelRuntimeEnv skip the runtime
env tier for the Roomote inference key name
- buildSetupModelStatus never counts the env value for the roomote
provider; connection = the stored row
- chooseSetupTrialInference requires the stored key and runs the import
first; the wizard gates the trial card on savedApiKeySatisfied
- docs note that deleting the provider disables the trial permanently
---
apps/docs/models.mdx | 4 +-
.../(onboarding)/setup/hooks.client.test.tsx | 6 +-
apps/web/src/app/(onboarding)/setup/hooks.ts | 2 +-
.../src/trpc/commands/setup-new/index.test.ts | 66 +++++++++++++++-
apps/web/src/trpc/commands/setup-new/index.ts | 77 +++++++++++++++++--
.../db/src/lib/model-runtime-config.test.ts | 40 +++++++---
packages/db/src/lib/model-runtime-config.ts | 11 ++-
.../types/src/model-provider-config.test.ts | 33 ++++++--
packages/types/src/model-provider-config.ts | 11 ++-
packages/types/src/setup-new.ts | 9 +++
10 files changed, 228 insertions(+), 31 deletions(-)
diff --git a/apps/docs/models.mdx b/apps/docs/models.mdx
index 4ede1b8cb..10d03e8de 100644
--- a/apps/docs/models.mdx
+++ b/apps/docs/models.mdx
@@ -28,7 +28,9 @@ Some hosting deployments offer **Roomote inference** with a limited number of
managed credits during setup. It is separate from your own provider
connections: in particular, you can add an OpenRouter key in **Settings >
Models** even when Roomote inference is active. If the hosting deployment does
-not offer it, the option is not shown.
+not offer it, the option is not shown. Its key is stored with your other
+provider credentials, so deleting the Roomote inference provider in
+**Settings > Models** disables it permanently.
For example, a deployment might use:
diff --git a/apps/web/src/app/(onboarding)/setup/hooks.client.test.tsx b/apps/web/src/app/(onboarding)/setup/hooks.client.test.tsx
index 92d5815f4..4ecf594b3 100644
--- a/apps/web/src/app/(onboarding)/setup/hooks.client.test.tsx
+++ b/apps/web/src/app/(onboarding)/setup/hooks.client.test.tsx
@@ -186,8 +186,10 @@ function trialModelSetup(overrides: Partial> = {}) {
{
id: 'roomote',
label: 'Roomote inference',
- runtimeApiKeySatisfied: true,
- savedApiKeySatisfied: false,
+ // The imported Settings row, not the hosting-injected env variable,
+ // is what connects the provider.
+ runtimeApiKeySatisfied: false,
+ savedApiKeySatisfied: true,
},
],
...overrides,
diff --git a/apps/web/src/app/(onboarding)/setup/hooks.ts b/apps/web/src/app/(onboarding)/setup/hooks.ts
index 9b7f65ae0..6813ef6b6 100644
--- a/apps/web/src/app/(onboarding)/setup/hooks.ts
+++ b/apps/web/src/app/(onboarding)/setup/hooks.ts
@@ -399,7 +399,7 @@ export function useSetupFlow(
const trialInferenceAvailable = status.modelSetup.providers?.some(
(provider) =>
provider.id === ROOMOTE_INFERENCE_PROVIDER_ID &&
- provider.runtimeApiKeySatisfied,
+ provider.savedApiKeySatisfied,
);
const operatorProviderConfigured = status.modelSetup.providers?.some(
(provider) =>
diff --git a/apps/web/src/trpc/commands/setup-new/index.test.ts b/apps/web/src/trpc/commands/setup-new/index.test.ts
index 349efe148..dd2fe4a37 100644
--- a/apps/web/src/trpc/commands/setup-new/index.test.ts
+++ b/apps/web/src/trpc/commands/setup-new/index.test.ts
@@ -260,6 +260,7 @@ import {
trackSetupCommsStateCommand,
trackSetupWelcomeSeenCommand,
chooseSetupTrialInferenceCommand,
+ importTrialInferenceKeyIfNeeded,
} from './index';
import {
DEFAULT_MODEL_PROVIDER_CREDENTIAL_ENV_VAR_NAMES,
@@ -1395,6 +1396,9 @@ describe('chooseSetupTrialInferenceCommand', () => {
it('seeds the Efficient Roomote defaults and records the managed provider choice', async () => {
vi.stubEnv('R_TRIAL_OPENROUTER_API_KEY', 'sk-trial');
+ mockGetPersistedEnvironmentVariableNames.mockResolvedValue([
+ 'R_TRIAL_OPENROUTER_API_KEY',
+ ]);
const { tx, inserted } = createTxStub({
setupNewState: {},
runtimeModelConfig: null,
@@ -1424,15 +1428,67 @@ describe('chooseSetupTrialInferenceCommand', () => {
});
});
- it('refuses when the trial key is not in the environment', async () => {
+ it('refuses when no trial key was ever delivered or stored', async () => {
+ const { tx } = createTxStub({
+ setupNewState: {},
+ runtimeModelConfig: null,
+ taskModelSettings: null,
+ });
+ mockDbTransaction.mockImplementation(
+ async (callback: (tx: unknown) => Promise) => callback(tx),
+ );
+
await expect(
chooseSetupTrialInferenceCommand(buildMockAuth()),
).rejects.toThrow('Free trial inference is not available');
- expect(mockDbTransaction).not.toHaveBeenCalled();
+ });
+
+ it('refuses after the stored key was deleted, even with the variable still injected', async () => {
+ // Disabling the trial = deleting the Roomote inference provider's stored
+ // key. The import marker keeps the still-injected env variable from
+ // resurrecting it.
+ vi.stubEnv('R_TRIAL_OPENROUTER_API_KEY', 'sk-trial');
+ const { tx } = createTxStub({
+ setupNewState: { trialInferenceKeyImportedAt: '2026-08-27T00:00:00Z' },
+ runtimeModelConfig: null,
+ taskModelSettings: null,
+ });
+ mockDbTransaction.mockImplementation(
+ async (callback: (tx: unknown) => Promise) => callback(tx),
+ );
+
+ await expect(
+ chooseSetupTrialInferenceCommand(buildMockAuth()),
+ ).rejects.toThrow('Free trial inference is not available');
+ expect(mockUpsertDeploymentEnvironmentVariables).not.toHaveBeenCalled();
+ });
+
+ it('imports the delivered key into Settings storage exactly once', async () => {
+ vi.stubEnv('R_TRIAL_OPENROUTER_API_KEY', 'sk-trial');
+ const { tx } = createTxStub({
+ setupNewState: {},
+ runtimeModelConfig: null,
+ taskModelSettings: null,
+ });
+ mockDbTransaction.mockImplementation(
+ async (callback: (tx: unknown) => Promise) => callback(tx),
+ );
+
+ await importTrialInferenceKeyIfNeeded('setup-test-user');
+
+ expect(mockUpsertDeploymentEnvironmentVariables).toHaveBeenCalledWith(
+ tx,
+ expect.objectContaining({
+ values: [{ name: 'R_TRIAL_OPENROUTER_API_KEY', value: 'sk-trial' }],
+ }),
+ );
});
it('refuses when an operator provider is already connected', async () => {
vi.stubEnv('R_TRIAL_OPENROUTER_API_KEY', 'sk-trial');
+ mockGetPersistedEnvironmentVariableNames.mockResolvedValue([
+ 'R_TRIAL_OPENROUTER_API_KEY',
+ ]);
vi.stubEnv('OPENROUTER_API_KEY', 'sk-operator');
const { tx, inserted } = createTxStub({
setupNewState: {},
@@ -1451,6 +1507,9 @@ describe('chooseSetupTrialInferenceCommand', () => {
it('no-ops when model choices already exist', async () => {
vi.stubEnv('R_TRIAL_OPENROUTER_API_KEY', 'sk-trial');
+ mockGetPersistedEnvironmentVariableNames.mockResolvedValue([
+ 'R_TRIAL_OPENROUTER_API_KEY',
+ ]);
const { tx, inserted } = createTxStub({
setupNewState: {},
runtimeModelConfig: null,
@@ -1470,6 +1529,9 @@ describe('chooseSetupTrialInferenceCommand', () => {
it('seeds despite a role-model env override, which keeps winning at runtime', async () => {
vi.stubEnv('R_TRIAL_OPENROUTER_API_KEY', 'sk-trial');
+ mockGetPersistedEnvironmentVariableNames.mockResolvedValue([
+ 'R_TRIAL_OPENROUTER_API_KEY',
+ ]);
vi.stubEnv('R_PLANNING_MODEL', 'anthropic/claude-opus-5');
const { tx, inserted } = createTxStub({
setupNewState: {},
diff --git a/apps/web/src/trpc/commands/setup-new/index.ts b/apps/web/src/trpc/commands/setup-new/index.ts
index c3f934617..88318f0db 100644
--- a/apps/web/src/trpc/commands/setup-new/index.ts
+++ b/apps/web/src/trpc/commands/setup-new/index.ts
@@ -54,6 +54,7 @@ import {
buildSetupSourceControlStatus,
CHATGPT_SUBSCRIPTION_PROVIDER_ID,
XAI_SUBSCRIPTION_PROVIDER_ID,
+ ROOMOTE_INFERENCE_API_KEY_ENV_VAR_NAME,
ROOMOTE_INFERENCE_PROVIDER_ID,
ROOMOTE_TRIAL_MODEL_PRESET_ID,
OPENAI_COMPATIBLE_PROVIDER_ID,
@@ -301,16 +302,67 @@ async function savePersistedTaskModelSettings(
* config, or task model settings) and refuses when a real provider is
* already connected, so it can never overwrite configuration.
*/
+/**
+ * Imports the hosting-injected Roomote inference key from the process
+ * environment into encrypted Settings storage, once. The env variable is
+ * only the delivery mechanism: after this import, every runtime read (the
+ * inference gateway, credit balance, provider status) resolves the stored
+ * key, so deleting the Roomote inference provider disables the trial even
+ * though hosting keeps injecting the variable — the stamped
+ * `trialInferenceKeyImportedAt` marker guarantees it is never re-imported.
+ */
+export async function importTrialInferenceKeyIfNeeded(
+ userId: string | null,
+): Promise {
+ if (
+ !isConfiguredEnvValue(process.env[ROOMOTE_INFERENCE_API_KEY_ENV_VAR_NAME])
+ ) {
+ return;
+ }
+
+ await db.transaction(async (tx) => {
+ await tx
+ .insert(deploymentSettings)
+ .values({ id: 'default' })
+ .onConflictDoUpdate({
+ target: deploymentSettings.id,
+ set: { updatedAt: new Date() },
+ });
+ await tx
+ .select({ id: deploymentSettings.id })
+ .from(deploymentSettings)
+ .where(eq(deploymentSettings.id, 'default'))
+ .for('update');
+
+ const currentState = await getPersistedSetupNewState(tx);
+ if (currentState.trialInferenceKeyImportedAt !== null) {
+ return;
+ }
+
+ await upsertDeploymentEnvironmentVariables(tx, {
+ userId,
+ values: [
+ {
+ name: ROOMOTE_INFERENCE_API_KEY_ENV_VAR_NAME,
+ value: process.env[ROOMOTE_INFERENCE_API_KEY_ENV_VAR_NAME]!.trim(),
+ },
+ ],
+ });
+ await savePersistedSetupNewState(
+ normalizeSetupNewState({
+ ...currentState,
+ trialInferenceKeyImportedAt: new Date().toISOString(),
+ }),
+ tx,
+ );
+ });
+}
+
export async function chooseSetupTrialInferenceCommand(auth: UserAuthSuccess) {
assertAdmin(auth);
const { userId } = auth;
-
- if (!isConfiguredEnvValue(process.env.R_TRIAL_OPENROUTER_API_KEY)) {
- throw new Error(
- 'Free trial inference is not available on this deployment.',
- );
- }
+ await importTrialInferenceKeyIfNeeded(userId);
return db.transaction(async (tx) => {
// Serialize against concurrent configuration writes: every check below
@@ -365,6 +417,18 @@ export async function chooseSetupTrialInferenceCommand(auth: UserAuthSuccess) {
githubCopilotConnected,
xaiSubscriptionConnected,
});
+ const trialKeyStored = status.providers.some(
+ (provider) =>
+ provider.id === ROOMOTE_INFERENCE_PROVIDER_ID &&
+ provider.savedApiKeySatisfied,
+ );
+
+ if (!trialKeyStored) {
+ throw new Error(
+ 'Free trial inference is not available on this deployment.',
+ );
+ }
+
const hasOperatorProvider = status.providers.some(
(provider) =>
provider.id !== ROOMOTE_INFERENCE_PROVIDER_ID &&
@@ -1226,6 +1290,7 @@ export async function getSetupNewStatusCommand(auth: UserAuthSuccess) {
const { userId } = auth;
await purgeSavedDeploymentWorkerImage();
+ await importTrialInferenceKeyIfNeeded(userId);
const [
baseStatus,
diff --git a/packages/db/src/lib/model-runtime-config.test.ts b/packages/db/src/lib/model-runtime-config.test.ts
index c776d5762..d628970b0 100644
--- a/packages/db/src/lib/model-runtime-config.test.ts
+++ b/packages/db/src/lib/model-runtime-config.test.ts
@@ -1172,8 +1172,8 @@ describe('managed Roomote inference key', () => {
});
const env = await resolveSandboxModelRuntimeEnv({
- runtimeEnv: { R_TRIAL_OPENROUTER_API_KEY: 'sk-trial' },
- deploymentEnvVars: {},
+ runtimeEnv: {},
+ deploymentEnvVars: { R_TRIAL_OPENROUTER_API_KEY: 'sk-trial' },
});
expect(env.R_INFERENCE_GATEWAY_KEYS?.split(',')).toContain(
@@ -1201,22 +1201,25 @@ describe('managed Roomote inference key', () => {
expect(env).not.toHaveProperty('R_TRIAL_OPENROUTER_API_KEY');
});
- it('materializes the managed key on the control plane for Roomote models', async () => {
+ it('materializes the stored key on the control plane for Roomote models', async () => {
mockDeploymentSettingsFindFirst.mockResolvedValue({
runtimeModelConfig: { roomoteModel: 'roomote/openai/gpt-5.6-luna' },
taskModelSettings: null,
});
const env = await resolveEffectiveModelRuntimeEnv({
- runtimeEnv: { R_TRIAL_OPENROUTER_API_KEY: 'sk-trial' },
- deploymentEnvVars: {},
+ runtimeEnv: {},
+ deploymentEnvVars: { R_TRIAL_OPENROUTER_API_KEY: 'sk-trial' },
});
expect(env.R_TRIAL_OPENROUTER_API_KEY).toBe('sk-trial');
expect(env).not.toHaveProperty('OPENROUTER_API_KEY');
});
- it('does not materialize an unrelated user-provided OpenRouter key', async () => {
+ it('never materializes the Roomote key from the process environment', async () => {
+ // The hosting-injected variable is a delivery mechanism only: setup
+ // imports it into Settings storage, and deleting that stored key must
+ // disable the provider even while hosting keeps injecting the variable.
mockDeploymentSettingsFindFirst.mockResolvedValue({
runtimeModelConfig: { roomoteModel: 'roomote/openai/gpt-5.6-luna' },
taskModelSettings: null,
@@ -1224,21 +1227,40 @@ describe('managed Roomote inference key', () => {
const env = await resolveEffectiveModelRuntimeEnv({
runtimeEnv: { R_TRIAL_OPENROUTER_API_KEY: 'sk-trial' },
- deploymentEnvVars: { OPENROUTER_API_KEY: 'sk-saved' },
+ deploymentEnvVars: {},
+ });
+
+ expect(env).not.toHaveProperty('R_TRIAL_OPENROUTER_API_KEY');
+ });
+
+ it('does not materialize an unrelated user-provided OpenRouter key', async () => {
+ mockDeploymentSettingsFindFirst.mockResolvedValue({
+ runtimeModelConfig: { roomoteModel: 'roomote/openai/gpt-5.6-luna' },
+ taskModelSettings: null,
+ });
+
+ const env = await resolveEffectiveModelRuntimeEnv({
+ runtimeEnv: {},
+ deploymentEnvVars: {
+ R_TRIAL_OPENROUTER_API_KEY: 'sk-trial',
+ OPENROUTER_API_KEY: 'sk-saved',
+ },
});
expect(env.R_TRIAL_OPENROUTER_API_KEY).toBe('sk-trial');
expect(env).not.toHaveProperty('OPENROUTER_API_KEY');
});
- it('resolveModelProviderEnvValue only resolves the requested provider key', async () => {
+ it('resolveModelProviderEnvValue resolves the Roomote key from storage only', async () => {
+ // The runtime env value is hosting's delivery mechanism, not a live
+ // credential: only the imported Settings row counts.
await expect(
resolveModelProviderEnvValue(['R_TRIAL_OPENROUTER_API_KEY'], {
runtimeEnv: {
R_TRIAL_OPENROUTER_API_KEY: 'sk-runtime',
},
}),
- ).resolves.toBe('sk-runtime');
+ ).resolves.toBeUndefined();
mockEnvironmentVariablesFindMany.mockResolvedValue([
{ name: 'R_TRIAL_OPENROUTER_API_KEY', value: 'sk-saved' },
diff --git a/packages/db/src/lib/model-runtime-config.ts b/packages/db/src/lib/model-runtime-config.ts
index 87717570c..ad3cda65e 100644
--- a/packages/db/src/lib/model-runtime-config.ts
+++ b/packages/db/src/lib/model-runtime-config.ts
@@ -165,7 +165,11 @@ function resolveProviderKeyNames({
/**
* Resolve a single model-provider env value with the same precedence the task
* runtime uses: the runtime process env first, then the persisted (encrypted)
- * deployment environment variables.
+ * deployment environment variables. The Roomote inference key is the one
+ * exception: its env variable is only the hosting platform's delivery
+ * mechanism (setup imports it into Settings storage once), so it resolves
+ * from the persisted store alone — deleting the stored key disables the
+ * provider even while hosting keeps injecting the variable.
*/
export async function resolveModelProviderEnvValue(
envVarNames: string | readonly string[],
@@ -178,6 +182,7 @@ export async function resolveModelProviderEnvValue(
const names = typeof envVarNames === 'string' ? [envVarNames] : envVarNames;
for (const envVarName of names) {
+ if (envVarName === ROOMOTE_INFERENCE_API_KEY_ENV_VAR_NAME) continue;
const runtimeValue = normalizeConfiguredValue(runtimeEnv[envVarName]);
if (runtimeValue) {
@@ -458,7 +463,9 @@ async function resolveModelRuntimeEnv(
}
const value =
- normalizeConfiguredValue(runtimeEnv[envVarName]) ??
+ (envVarName === ROOMOTE_INFERENCE_API_KEY_ENV_VAR_NAME
+ ? undefined
+ : normalizeConfiguredValue(runtimeEnv[envVarName])) ??
normalizeConfiguredValue(persistedEnvVars[envVarName]);
return value ? [[envVarName, value]] : [];
diff --git a/packages/types/src/model-provider-config.test.ts b/packages/types/src/model-provider-config.test.ts
index c0670b54c..e69fea2cc 100644
--- a/packages/types/src/model-provider-config.test.ts
+++ b/packages/types/src/model-provider-config.test.ts
@@ -1558,6 +1558,29 @@ describe('reasoning effort labels', () => {
});
describe('buildSetupModelStatus', () => {
+ it('connects Roomote inference only through the stored key, never the env variable', () => {
+ const envOnly = buildSetupModelStatus({
+ runtimeEnv: { R_TRIAL_OPENROUTER_API_KEY: 'sk-trial' },
+ persistedEnvVarNames: [],
+ });
+ // Hidden and unconnected: the env value alone must not surface the
+ // provider, or deleting the stored key could not disable the trial.
+ expect(
+ envOnly.providers.find((provider) => provider.id === 'roomote'),
+ ).toBeUndefined();
+
+ const stored = buildSetupModelStatus({
+ runtimeEnv: {},
+ persistedEnvVarNames: ['R_TRIAL_OPENROUTER_API_KEY'],
+ });
+ expect(
+ stored.providers.find((provider) => provider.id === 'roomote'),
+ ).toMatchObject({
+ runtimeApiKeySatisfied: false,
+ savedApiKeySatisfied: true,
+ });
+ });
+
it('treats runtime env as the highest-precedence satisfied setup source', () => {
const status = buildSetupModelStatus({
runtimeEnv: {
@@ -1643,23 +1666,21 @@ describe('buildSetupModelStatus', () => {
it('treats Roomote inference as a distinct hosting-managed provider', () => {
const status = buildSetupModelStatus({
- runtimeEnv: {
- R_TRIAL_OPENROUTER_API_KEY: 'sk-trial',
- },
+ runtimeEnv: {},
persistedModelConfig: {
roomoteModel: 'roomote/openai/gpt-5.6-luna',
roomoteSmallModel: null,
roomoteVisionModel: null,
},
- persistedEnvVarNames: [],
+ persistedEnvVarNames: ['R_TRIAL_OPENROUTER_API_KEY'],
});
expect(status.setupSatisfied).toBe(true);
expect(
status.providers.find((provider) => provider.id === 'roomote'),
).toMatchObject({
- runtimeApiKeySatisfied: true,
- savedApiKeySatisfied: false,
+ runtimeApiKeySatisfied: false,
+ savedApiKeySatisfied: true,
});
expect(
status.providers.find((provider) => provider.id === 'openrouter'),
diff --git a/packages/types/src/model-provider-config.ts b/packages/types/src/model-provider-config.ts
index cbc980953..44d1bfdc5 100644
--- a/packages/types/src/model-provider-config.ts
+++ b/packages/types/src/model-provider-config.ts
@@ -2053,8 +2053,15 @@ export function buildSetupModelStatus(input: {
const requiredEnvVarNames =
getSetupModelProviderRequiredEnvVarNames(provider);
const hasRequiredEnvVars = requiredEnvVarNames.length > 0;
- const isRuntimeConfigured = (name: string) =>
- isConfiguredEnvValue(runtimeEnv[name]);
+ // The hosting-injected Roomote inference variable is a delivery
+ // mechanism, not a credential: setup imports it into encrypted Settings
+ // storage once, and only the stored key connects the provider. Counting
+ // the env value here would resurrect a provider whose stored key the
+ // operator deleted to disable the trial.
+ const isRuntimeConfigured =
+ provider.id === ROOMOTE_INFERENCE_PROVIDER_ID
+ ? () => false
+ : (name: string) => isConfiguredEnvValue(runtimeEnv[name]);
const isPersisted = (name: string) => persistedEnvVarNameSet.has(name);
const additionalEnvValues = Object.fromEntries(
[
diff --git a/packages/types/src/setup-new.ts b/packages/types/src/setup-new.ts
index f87856451..49d0363a8 100644
--- a/packages/types/src/setup-new.ts
+++ b/packages/types/src/setup-new.ts
@@ -174,6 +174,14 @@ export type SetupNewState = {
azureDiskImageBuild: SetupNewComputeProvisioningState | null;
lastInteractedByUserId: string | null;
automationRecommendations: AutomationRecommendationBatch | null;
+ /**
+ * When the hosting-injected Roomote inference key was imported from the
+ * process environment into encrypted Settings storage. One-shot: once
+ * stamped, the env value is never imported again, so deleting the Roomote
+ * inference provider (its stored key) disables the trial permanently even
+ * though the hosting platform keeps delivering the variable.
+ */
+ trialInferenceKeyImportedAt: string | null;
};
export function createEmptySetupNewState(): SetupNewState {
@@ -204,6 +212,7 @@ export function createEmptySetupNewState(): SetupNewState {
azureDiskImageBuild: null,
lastInteractedByUserId: null,
automationRecommendations: null,
+ trialInferenceKeyImportedAt: null,
};
}
From e091580bafe3143c03913a64dce3879a321e1986 Mon Sep 17 00:00:00 2001
From: Matt Rubens <2600+mrubens@users.noreply.github.com>
Date: Thu, 27 Aug 2026 14:28:47 -0400
Subject: [PATCH 15/17] feat: let operators delete Roomote inference to disable
the trial
The docs promised deletion as the disable path, but the delete command
hard-rejected the roomote provider and the Settings row had no control.
Roomote inference now deletes like any stored-key provider: its credential
is the imported Settings row, the existing last-provider guard still
applies, and the import marker keeps the hosting-injected variable from
re-creating it. The managed row gains a delete control that names the
permanent consequence.
---
.../InferenceProviderSection.test.tsx | 37 +++++++++++-
.../settings/InferenceProviderSection.tsx | 39 +++++++++---
.../trpc/commands/task-models/index.test.ts | 60 ++++++++++++++++++-
.../src/trpc/commands/task-models/index.ts | 8 +--
4 files changed, 127 insertions(+), 17 deletions(-)
diff --git a/apps/web/src/components/settings/InferenceProviderSection.test.tsx b/apps/web/src/components/settings/InferenceProviderSection.test.tsx
index 54a66fabd..9f9d3dfbf 100644
--- a/apps/web/src/components/settings/InferenceProviderSection.test.tsx
+++ b/apps/web/src/components/settings/InferenceProviderSection.test.tsx
@@ -195,8 +195,10 @@ function buildProviderSetup(
authKind: 'api-key' as const,
suggestedTaskModels: [],
hidden: true,
- runtimeApiKeySatisfied: true,
- savedApiKeySatisfied: false,
+ // The imported Settings row, not the hosting-injected env
+ // variable, is what connects the provider.
+ runtimeApiKeySatisfied: false,
+ savedApiKeySatisfied: true,
additionalEnvValues: {} satisfies Record,
},
]
@@ -946,6 +948,37 @@ describe('InferenceProviderSection', () => {
).not.toBeInTheDocument();
});
+ it('offers deleting Roomote inference once another provider is connected', () => {
+ providerSetupData.current = buildProviderSetup({
+ managedRoomote: true,
+ anthropicSavedKey: true,
+ });
+
+ renderInferenceProviderSection();
+
+ const deleteButton = screen.getByRole('button', {
+ name: 'Delete Roomote inference provider',
+ });
+ expect(deleteButton).toBeEnabled();
+
+ fireEvent.click(deleteButton);
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ });
+
+ it('blocks deleting Roomote inference while it is the only provider', () => {
+ providerSetupData.current = buildProviderSetup({
+ managedRoomote: true,
+ });
+
+ renderInferenceProviderSection();
+
+ expect(
+ screen.getByRole('button', {
+ name: 'Delete Roomote inference provider',
+ }),
+ ).toBeDisabled();
+ });
+
it('locks a runtime env-managed key behind a lock tooltip', () => {
providerSetupData.current = buildProviderSetup({
openrouterRuntimeKey: true,
diff --git a/apps/web/src/components/settings/InferenceProviderSection.tsx b/apps/web/src/components/settings/InferenceProviderSection.tsx
index 33d48a2aa..f4a69e8df 100644
--- a/apps/web/src/components/settings/InferenceProviderSection.tsx
+++ b/apps/web/src/components/settings/InferenceProviderSection.tsx
@@ -155,15 +155,36 @@ function ConnectedProviderRow({
{provider.label}
-
-
- Managed by Roomote with free trial credits. Add your own provider at
- any time to continue after credits end.
-
-
+
+
+
+ Managed by Roomote with free trial credits. Add your own provider
+ at any time to continue after credits end.
+
+
+
+
+
+
+
+
+
+
);
diff --git a/apps/web/src/trpc/commands/task-models/index.test.ts b/apps/web/src/trpc/commands/task-models/index.test.ts
index ff15e0744..4ba177bad 100644
--- a/apps/web/src/trpc/commands/task-models/index.test.ts
+++ b/apps/web/src/trpc/commands/task-models/index.test.ts
@@ -1,4 +1,5 @@
import {
+ DEFAULT_MODEL_PROVIDER_CREDENTIAL_ENV_VAR_NAMES,
normalizeTaskModelId,
TASK_MODEL_ROLE_DESCRIPTORS,
TASK_MODEL_ROLES,
@@ -2088,6 +2089,59 @@ describe('task model provider commands', () => {
expect(mockTxDelete).toHaveBeenCalled();
});
+ it('deletes Roomote inference to disable the trial when another provider remains', async () => {
+ // Hermetic: host provider keys must not change the last-provider count.
+ for (const name of DEFAULT_MODEL_PROVIDER_CREDENTIAL_ENV_VAR_NAMES) {
+ vi.stubEnv(name, '');
+ }
+ vi.stubEnv('R_TRIAL_OPENROUTER_API_KEY', '');
+ try {
+ mockIsChatGptSubscriptionConnected.mockResolvedValue(false);
+ mockIsGitHubCopilotSubscriptionConnected.mockResolvedValue(false);
+ mockIsXaiSubscriptionConnected.mockResolvedValue(false);
+ mockGetPersistedEnvironmentVariableNames.mockResolvedValue([
+ 'R_TRIAL_OPENROUTER_API_KEY',
+ 'ANTHROPIC_API_KEY',
+ ]);
+
+ await deleteTaskModelProviderCommand(buildMockAuth(), {
+ provider: 'roomote',
+ });
+
+ expect(txDeleteWhere).toHaveBeenCalledWith({
+ and: [
+ { isNull: 'env.user_id' },
+ { column: 'env.name', values: ['R_TRIAL_OPENROUTER_API_KEY'] },
+ ],
+ });
+ } finally {
+ vi.unstubAllEnvs();
+ }
+ });
+
+ it('keeps Roomote inference while it is the only connected provider', async () => {
+ for (const name of DEFAULT_MODEL_PROVIDER_CREDENTIAL_ENV_VAR_NAMES) {
+ vi.stubEnv(name, '');
+ }
+ vi.stubEnv('R_TRIAL_OPENROUTER_API_KEY', '');
+ try {
+ mockIsChatGptSubscriptionConnected.mockResolvedValue(false);
+ mockIsGitHubCopilotSubscriptionConnected.mockResolvedValue(false);
+ mockIsXaiSubscriptionConnected.mockResolvedValue(false);
+ mockGetPersistedEnvironmentVariableNames.mockResolvedValue([
+ 'R_TRIAL_OPENROUTER_API_KEY',
+ ]);
+
+ await expect(
+ deleteTaskModelProviderCommand(buildMockAuth(), {
+ provider: 'roomote',
+ }),
+ ).rejects.toThrow('Keep at least one inference provider connected.');
+ } finally {
+ vi.unstubAllEnvs();
+ }
+ });
+
it('removes only the xAI API key when a Grok subscription remains connected', async () => {
// Dual-path xAI shares catalog id `xai`. Deleting the key must not strip
// xai/* models while SuperGrok is still connected.
@@ -2163,10 +2217,12 @@ describe('task model provider commands', () => {
expect(txOnConflictDoUpdate).not.toHaveBeenCalled();
});
- it('rejects deleting the hosting-managed Roomote provider', async () => {
+ it('rejects deleting Roomote inference when no key was ever imported', async () => {
+ // Deletion is the supported way to disable the trial, but it operates on
+ // the imported Settings row; without one there is nothing to remove.
await expect(
deleteTaskModelProviderCommand(buildMockAuth(), { provider: 'roomote' }),
- ).rejects.toThrow('Roomote inference is managed by your hosting provider.');
+ ).rejects.toThrow('does not have saved credentials');
expect(mockTxDelete).not.toHaveBeenCalled();
});
diff --git a/apps/web/src/trpc/commands/task-models/index.ts b/apps/web/src/trpc/commands/task-models/index.ts
index a1ca134d3..cb62ec347 100644
--- a/apps/web/src/trpc/commands/task-models/index.ts
+++ b/apps/web/src/trpc/commands/task-models/index.ts
@@ -920,10 +920,10 @@ export async function deleteTaskModelProviderCommand(
const provider = getSetupModelProvider(input.provider);
- if (provider.id === ROOMOTE_INFERENCE_PROVIDER_ID) {
- throw new Error('Roomote inference is managed by your hosting provider.');
- }
-
+ // Roomote inference deletes like any stored-key provider: its credential
+ // is the imported Settings row, and removing it is the one supported way
+ // to disable the trial. The import marker in setup state keeps the
+ // hosting-injected variable from ever re-creating it.
if (provider.authKind === 'oauth') {
throw new Error(
`${provider.label} is connected with a subscription account and cannot be deleted here.`,
From 337fbe087dbc54a9b0c09f8b3ee8465076ef443e Mon Sep 17 00:00:00 2001
From: Matt Rubens <2600+mrubens@users.noreply.github.com>
Date: Thu, 27 Aug 2026 14:37:17 -0400
Subject: [PATCH 16/17] test: read the Roomote credit balance from the stored
key
The balance test injected the key through the runtime env, which the
stored-key model deliberately ignores. It now seeds a real encrypted
Settings row (round-tripping actual encryption rather than the mock the
column type bypassed on insert) and proves the env-delivered value never
reaches the balance endpoint, plus a case for no-stored-key returning
null.
---
.../__tests__/provider-credit-balance.test.ts | 64 +++++++++++++------
1 file changed, 45 insertions(+), 19 deletions(-)
diff --git a/packages/db/src/lib/__tests__/provider-credit-balance.test.ts b/packages/db/src/lib/__tests__/provider-credit-balance.test.ts
index e437a7906..c9ca3c893 100644
--- a/packages/db/src/lib/__tests__/provider-credit-balance.test.ts
+++ b/packages/db/src/lib/__tests__/provider-credit-balance.test.ts
@@ -1,9 +1,6 @@
import { describe, expect, it, vi } from 'vitest';
-vi.mock('../../encryption', () => ({
- encryptJSON: (value: unknown) => JSON.stringify(value),
- decryptSecrets: async (value: string) => JSON.parse(value) as unknown,
-}));
+import { eq } from 'drizzle-orm';
import {
fetchOpenRouterCreditBalance,
@@ -12,6 +9,8 @@ import {
parseOpenRouterKeyBalance,
parseOpenRouterKeyDetails,
} from '../provider-credit-balance';
+import { db } from '../../db';
+import { environmentVariables } from '../../schema';
function jsonResponse(payload: unknown, status = 200) {
return new Response(JSON.stringify(payload), { status });
@@ -68,31 +67,58 @@ describe('parseOpenRouterKeyBalance', () => {
});
describe('fetchRoomoteCreditBalance', () => {
- it('uses the managed Roomote key against the OpenRouter balance endpoint', async () => {
+ it('uses the stored Roomote key against the OpenRouter balance endpoint', async () => {
+ // The hosting-injected env variable is a delivery mechanism only; the
+ // balance is read with the key setup imported into Settings storage.
const fetchImpl = vi
.fn()
.mockResolvedValue(
jsonResponse({ data: { limit: 5, limit_remaining: 3 } }),
);
+ await db.insert(environmentVariables).values({
+ name: 'R_TRIAL_OPENROUTER_API_KEY',
+ value: 'managed-key',
+ userId: null,
+ });
+
+ try {
+ await expect(
+ fetchRoomoteCreditBalance({
+ // Present at runtime, as on a hosted deployment: it must be the
+ // stored row, not this value, that reaches the balance endpoint.
+ runtimeEnv: { R_TRIAL_OPENROUTER_API_KEY: 'env-delivery-value' },
+ fetchImpl,
+ }),
+ ).resolves.toMatchObject({
+ providerId: 'roomote',
+ remaining: 3,
+ limit: 5,
+ });
+ expect(fetchImpl).toHaveBeenCalledWith(
+ 'https://openrouter.ai/api/v1/key',
+ expect.objectContaining({
+ headers: expect.objectContaining({
+ authorization: 'Bearer managed-key',
+ }),
+ }),
+ );
+ } finally {
+ await db
+ .delete(environmentVariables)
+ .where(eq(environmentVariables.name, 'R_TRIAL_OPENROUTER_API_KEY'));
+ }
+ });
+
+ it('returns null when no Roomote key is stored, whatever the env says', async () => {
+ const fetchImpl = vi.fn();
await expect(
fetchRoomoteCreditBalance({
- runtimeEnv: { R_TRIAL_OPENROUTER_API_KEY: 'managed-key' },
+ runtimeEnv: { R_TRIAL_OPENROUTER_API_KEY: 'env-delivery-value' },
fetchImpl,
}),
- ).resolves.toMatchObject({
- providerId: 'roomote',
- remaining: 3,
- limit: 5,
- });
- expect(fetchImpl).toHaveBeenCalledWith(
- 'https://openrouter.ai/api/v1/key',
- expect.objectContaining({
- headers: expect.objectContaining({
- authorization: 'Bearer managed-key',
- }),
- }),
- );
+ ).resolves.toBeNull();
+ expect(fetchImpl).not.toHaveBeenCalled();
});
});
From 86b3b8fb35d740cc51e1d64cee781f0f2ac5bf6f Mon Sep 17 00:00:00 2001
From: Matt Rubens <2600+mrubens@users.noreply.github.com>
Date: Thu, 27 Aug 2026 15:32:12 -0400
Subject: [PATCH 17/17] fix: harden the Roomote trial inference lifecycle
Review fixes for the free-trial inference feature:
- Count the hidden roomote row toward the last-provider delete guard only
while the trial is actually in use, and mirror that in the Settings UI,
so a merely-imported trial key can no longer unlock deleting the last
real provider
- Include the GitHub Copilot subscription in the delete guard's status
build so trial deletion works when Copilot is the only other provider
- Stop counting a leftover task-model-settings row as a prior inference
choice, so choosing the trial seeds models instead of silently
no-opping after the last provider was deleted
- Strip the settings-only trial key from control-plane helper OpenCode
envs so deleting the provider disables the trial there too
- Re-import a rotated injected trial key while the stored key exists,
and skip the write transaction and row lock on the common
already-imported status-read path
- Cache the roomote gateway key with a 30s TTL to keep DB reads and
decryption off the per-request hot path
- Centralize the settings-only env-var policy behind one predicate and
the roomote/OpenRouter model-id aliasing behind shared helpers
- Filter hosting-managed providers by the catalog hidden flag instead of
id checks, merge the duplicated credit-balance fetchers, skip the
roomote body rewrite's JSON parse on the common path, and fix stale
docs and the test mock's env precedence
---
.../__tests__/inference-gateway.test.ts | 4 +
apps/api/src/handlers/inference/index.ts | 53 +++++---
apps/api/src/handlers/inference/registry.ts | 48 +++++++-
.../setup/StepInferenceProvider.tsx | 10 +-
.../settings/InferenceProviderSection.tsx | 22 +++-
.../settings/ModelSettingsSection.tsx | 5 +-
.../settings/pages/TaskModelSettingsPage.tsx | 3 +
.../src/trpc/commands/setup-new/index.test.ts | 63 +++++++++-
apps/web/src/trpc/commands/setup-new/index.ts | 115 ++++++++++++------
.../trpc/commands/task-models/index.test.ts | 13 +-
.../src/trpc/commands/task-models/index.ts | 86 +++++++++++--
.../src/server/opencode-runtime.ts | 15 +++
packages/db/src/lib/model-runtime-config.ts | 12 +-
.../db/src/lib/provider-credit-balance.ts | 44 ++++---
packages/env/src/index.ts | 16 ++-
packages/types/src/model-provider-config.ts | 51 ++++++--
16 files changed, 438 insertions(+), 122 deletions(-)
diff --git a/apps/api/src/handlers/inference/__tests__/inference-gateway.test.ts b/apps/api/src/handlers/inference/__tests__/inference-gateway.test.ts
index 48200c7f6..c976adfbe 100644
--- a/apps/api/src/handlers/inference/__tests__/inference-gateway.test.ts
+++ b/apps/api/src/handlers/inference/__tests__/inference-gateway.test.ts
@@ -38,6 +38,7 @@ vi.mock('@roomote/sdk/server', () => ({
}));
import { inference } from '../index';
+import { resetRoomoteInferenceKeyCache } from '../registry';
function createApp(authContext: Variables['authContext']) {
const app = new Hono<{ Variables: Variables }>();
@@ -116,6 +117,9 @@ describe('inference gateway', () => {
vi.clearAllMocks();
vi.unstubAllGlobals();
vi.unstubAllEnvs();
+ // The Roomote trial key resolution is cached with a TTL; drop it so each
+ // test observes its own mockResolveModelProviderEnvValue behavior.
+ resetRoomoteInferenceKeyCache();
mockFindTaskRun.mockResolvedValue({ id: 42 });
mockGetGitHubCopilotAccessToken.mockResolvedValue(null);
mockGetFreshXaiAccessToken.mockResolvedValue(null);
diff --git a/apps/api/src/handlers/inference/index.ts b/apps/api/src/handlers/inference/index.ts
index 067c90541..ec89181f2 100644
--- a/apps/api/src/handlers/inference/index.ts
+++ b/apps/api/src/handlers/inference/index.ts
@@ -1,6 +1,10 @@
import { Hono } from 'hono';
-import { formatSingleLineLog } from '@roomote/types';
+import {
+ formatSingleLineLog,
+ rebaseRoomoteModelIdToUpstream,
+ ROOMOTE_INFERENCE_PROVIDER_ID,
+} from '@roomote/types';
import { db, eq, taskRuns } from '@roomote/db/server';
import { recordLlmUsage } from '@roomote/sdk/server';
@@ -154,6 +158,14 @@ function buildInferenceResponseHeaders(upstreamHeaders: Headers): Headers {
}
function rewriteRoomoteRequestModel(bodyText: string): string {
+ // The sandbox OpenCode config already sends upstream (prefix-stripped)
+ // model ids, so the dominant path never needs the rewrite; the substring
+ // check skips the full-body JSON parse and re-serialization for it. The
+ // rewrite exists for clients that address models by their catalog id.
+ if (!bodyText.includes(`"${ROOMOTE_INFERENCE_PROVIDER_ID}/`)) {
+ return bodyText;
+ }
+
try {
const body: unknown = JSON.parse(bodyText);
@@ -163,13 +175,15 @@ function rewriteRoomoteRequestModel(bodyText: string): string {
const request = body as Record;
const model = request.model;
- if (typeof model !== 'string' || !model.startsWith('roomote/')) {
+ const upstreamModel =
+ typeof model === 'string' ? rebaseRoomoteModelIdToUpstream(model) : null;
+ if (upstreamModel === null) {
return bodyText;
}
return JSON.stringify({
...request,
- model: model.slice('roomote/'.length),
+ model: upstreamModel,
});
} catch {
return bodyText;
@@ -388,31 +402,29 @@ inference.on(['POST', 'GET'], '/:provider/*', async (c) => {
c.req.header('x-initiator') === 'agent' ? 'agent' : 'user';
}
- // GitHub Copilot's OAuth path normally labels vision traffic. Gateway mode
- // holds that token server-side, so inspect the request body here and restore
- // the same header OpenCode would have set.
let requestBody: BodyInit | null = c.req.raw.body;
let useDuplexHalf = Boolean(c.req.raw.body);
- if (
- (providerId === 'github-copilot' || providerId === 'roomote') &&
- method === 'POST'
- ) {
+ // GitHub Copilot's OAuth path normally labels vision traffic. Gateway mode
+ // holds that token server-side, so inspect the request body here and restore
+ // the same header OpenCode would have set.
+ if (providerId === 'github-copilot' && method === 'POST') {
const bodyText = await c.req.text();
- requestBody =
- providerId === 'roomote'
- ? rewriteRoomoteRequestModel(bodyText)
- : bodyText;
+ requestBody = bodyText;
useDuplexHalf = false;
- if (
- providerId === 'github-copilot' &&
- copilotRequestBodyHasVisionContent(bodyText)
- ) {
+ if (copilotRequestBodyHasVisionContent(bodyText)) {
injectedHeaders['Copilot-Vision-Request'] = 'true';
}
}
+ // Roomote model ids are an aliased namespace over OpenRouter; rewrite a
+ // catalog-id model reference onto the upstream slug OpenRouter expects.
+ if (providerId === ROOMOTE_INFERENCE_PROVIDER_ID && method === 'POST') {
+ requestBody = rewriteRoomoteRequestModel(await c.req.text());
+ useDuplexHalf = false;
+ }
+
try {
const upstreamResponse = await fetchWithLongLivedStreamDispatcher(
upstreamUrl,
@@ -450,7 +462,10 @@ inference.on(['POST', 'GET'], '/:provider/*', async (c) => {
}),
);
- if (providerId === 'roomote' && upstreamResponse.status === 402) {
+ if (
+ providerId === ROOMOTE_INFERENCE_PROVIDER_ID &&
+ upstreamResponse.status === 402
+ ) {
return c.json(
{
error:
diff --git a/apps/api/src/handlers/inference/registry.ts b/apps/api/src/handlers/inference/registry.ts
index f8964a45d..d29f914cb 100644
--- a/apps/api/src/handlers/inference/registry.ts
+++ b/apps/api/src/handlers/inference/registry.ts
@@ -3,6 +3,7 @@ import {
getInferenceGatewayProvider,
INFERENCE_GATEWAY_RESOURCE_PATTERN,
INFERENCE_GATEWAY_REGION_PATTERN,
+ ROOMOTE_INFERENCE_PROVIDER_ID,
type InferenceGatewayProvider,
} from '@roomote/types';
import {
@@ -18,6 +19,49 @@ export function getInferenceProvider(
return getInferenceGatewayProvider(providerId);
}
+/**
+ * The Roomote trial key resolves from the encrypted Settings store only
+ * (never the process env), so without a cache every request on a trial
+ * deployment — all of its LLM traffic — would pay a DB read plus decryption
+ * on this hot path. Cached with a short TTL like
+ * `isBrainProviderConfigured` in @roomote/db; the TTL bounds how long a
+ * deleted or rotated stored key keeps serving.
+ */
+const ROOMOTE_KEY_CACHE_TTL_MS = 30_000;
+
+let roomoteKeyCache: {
+ value: string | undefined;
+ expiresAtMs: number;
+} | null = null;
+
+/** Drop the cached trial key, so the next request re-reads Settings. */
+export function resetRoomoteInferenceKeyCache(): void {
+ roomoteKeyCache = null;
+}
+
+async function resolveProviderApiKey(
+ provider: InferenceGatewayProvider,
+): Promise {
+ if (provider.id !== ROOMOTE_INFERENCE_PROVIDER_ID) {
+ return resolveModelProviderEnvValue(provider.envVarNames);
+ }
+
+ const cached = roomoteKeyCache;
+
+ if (cached && cached.expiresAtMs > Date.now()) {
+ return cached.value;
+ }
+
+ const value = await resolveModelProviderEnvValue(provider.envVarNames);
+
+ roomoteKeyCache = {
+ value,
+ expiresAtMs: Date.now() + ROOMOTE_KEY_CACHE_TTL_MS,
+ };
+
+ return value;
+}
+
/** The upstream URL and auth headers the gateway forwards for one request. */
export interface ResolvedGatewayUpstream {
upstreamUrl: string;
@@ -61,7 +105,7 @@ export async function resolveGatewayUpstream(
requiresSourceCoupledRegion &&
provider.envVarNames.some((envVarName) => process.env[envVarName]?.trim());
const [apiKey, upstreamBaseUrl] = await Promise.all([
- resolveModelProviderEnvValue(provider.envVarNames),
+ resolveProviderApiKey(provider),
resolveProviderUpstreamBaseUrl(provider, {
regionSource: requiresSourceCoupledRegion
? hasRuntimeApiKey
@@ -76,7 +120,7 @@ export async function resolveGatewayUpstream(
ok: false,
status: 404,
error:
- provider.id === 'roomote'
+ provider.id === ROOMOTE_INFERENCE_PROVIDER_ID
? 'Roomote inference is unavailable. Connect an inference provider to continue.'
: `No ${provider.name} API key is configured for this deployment`,
};
diff --git a/apps/web/src/app/(onboarding)/setup/StepInferenceProvider.tsx b/apps/web/src/app/(onboarding)/setup/StepInferenceProvider.tsx
index f3c193552..e6098db4f 100644
--- a/apps/web/src/app/(onboarding)/setup/StepInferenceProvider.tsx
+++ b/apps/web/src/app/(onboarding)/setup/StepInferenceProvider.tsx
@@ -11,7 +11,6 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import {
CHATGPT_SUBSCRIPTION_PROVIDER_ID,
- ROOMOTE_INFERENCE_PROVIDER_ID,
XAI_SUBSCRIPTION_PROVIDER_ID,
OPENAI_COMPATIBLE_PROVIDER_ID,
getDefaultAdditionalEnvValues,
@@ -220,15 +219,12 @@ export function StepInferenceProvider({
const primaryCredentialLabel =
selectedProviderStatus?.envVarLabel ?? 'API key';
const additionalEnvFields = selectedProviderStatus?.additionalEnvFields ?? [];
- // The server status list already excludes hidden providers that are not
- // connected, so the picker only offers providers that can be selected.
+ // Hidden (hosting-managed) providers are never user-selectable, even when
+ // connected — the catalog `hidden` flag is the single source of that rule.
const sortedModelProviders = useMemo(
() =>
modelSetup.providers
- .filter(
- (provider) =>
- !provider.hidden && provider.id !== ROOMOTE_INFERENCE_PROVIDER_ID,
- )
+ .filter((provider) => !provider.hidden)
.sort((left, right) => left.label.localeCompare(right.label)),
[modelSetup.providers],
);
diff --git a/apps/web/src/components/settings/InferenceProviderSection.tsx b/apps/web/src/components/settings/InferenceProviderSection.tsx
index f4a69e8df..f2f3a01ac 100644
--- a/apps/web/src/components/settings/InferenceProviderSection.tsx
+++ b/apps/web/src/components/settings/InferenceProviderSection.tsx
@@ -61,6 +61,13 @@ const PROVIDER_GRID_ROW_CLASS =
type InferenceProviderSectionProps = {
providerSetup: SetupModelStatus | null;
+ /**
+ * Whether the Roomote trial is actually in use (chosen or seeded), as
+ * opposed to merely having its key imported. Mirrors the server-side
+ * delete guard: a merely-imported trial row must not count as a provider
+ * that keeps other providers deletable.
+ */
+ trialInferenceActive?: boolean;
providerSetupPending: boolean;
connectedProviders: SetupModelProviderStatus[];
availableProviders: SetupModelProviderStatus[];
@@ -862,6 +869,7 @@ function DeleteProviderDialog({
export function InferenceProviderSection({
providerSetup,
+ trialInferenceActive = false,
providerSetupPending,
connectedProviders,
availableProviders,
@@ -1267,8 +1275,16 @@ export function InferenceProviderSection({
const canAddProvider = sortedAddableProviders.length > 0;
// Count key rows and subscription rows independently so dual-path xAI
// (API key + SuperGrok) can delete the key while the subscription remains.
- const connectedProviderCount =
- sortedApiKeyConnectedProviders.length +
+ // Mirrors the server-side delete guard: a row is deletable only while at
+ // least one other connected provider remains, and a merely-imported (never
+ // chosen) Roomote trial row does not count as one.
+ const countOtherConnectedProviders = (excludedProviderId: string) =>
+ sortedApiKeyConnectedProviders.filter(
+ (candidate) =>
+ candidate.id !== excludedProviderId &&
+ (candidate.id !== ROOMOTE_INFERENCE_PROVIDER_ID ||
+ trialInferenceActive),
+ ).length +
(chatgptHasRecord ? 1 : 0) +
(githubCopilotHasRecord ? 1 : 0) +
(xaiHasRecord ? 1 : 0);
@@ -1400,7 +1416,7 @@ export function InferenceProviderSection({
: undefined
}
isSaving={savingProviderId === provider.id}
- canDelete={connectedProviderCount > 1}
+ canDelete={countOtherConnectedProviders(provider.id) >= 1}
onEdit={() =>
setProviderDialog({ mode: 'edit', providerId: provider.id })
}
diff --git a/apps/web/src/components/settings/ModelSettingsSection.tsx b/apps/web/src/components/settings/ModelSettingsSection.tsx
index a9dc75d43..88f1a827d 100644
--- a/apps/web/src/components/settings/ModelSettingsSection.tsx
+++ b/apps/web/src/components/settings/ModelSettingsSection.tsx
@@ -57,7 +57,6 @@ import { formatMetadataSummary } from './model-metadata';
import {
CHATGPT_SUBSCRIPTION_PROVIDER_ID,
DEFAULT_MODEL_ROLE_REASONING_EFFORTS,
- ROOMOTE_INFERENCE_PROVIDER_ID,
TASK_MODEL_ROLE_DESCRIPTORS,
TASK_MODEL_ROLES,
XAI_SUBSCRIPTION_PROVIDER_ID,
@@ -752,10 +751,12 @@ export function ModelSettingsSection({
EMPTY_SUGGESTION_STATE,
);
const settingsData = settingsQuery.data;
+ // Hidden (hosting-managed) providers like the Roomote trial expose no
+ // add-model surface; their models are managed by the trial seeding.
const sortedConnectedProviders = useMemo(
() =>
connectedProviders
- .filter((provider) => provider.id !== ROOMOTE_INFERENCE_PROVIDER_ID)
+ .filter((provider) => !provider.hidden)
.sort((left, right) => left.label.localeCompare(right.label)),
[connectedProviders],
);
diff --git a/apps/web/src/components/settings/pages/TaskModelSettingsPage.tsx b/apps/web/src/components/settings/pages/TaskModelSettingsPage.tsx
index 2a41bac32..908ae4e8b 100644
--- a/apps/web/src/components/settings/pages/TaskModelSettingsPage.tsx
+++ b/apps/web/src/components/settings/pages/TaskModelSettingsPage.tsx
@@ -40,6 +40,9 @@ export function TaskModelSettingsPage() {
{recommendationsCallout}
{
})),
};
+ // The import's lock-free pre-check reads setup state through the plain
+ // `db` handle (whose `select` is mockTxSelect) before any transaction
+ // opens, so serve it the same row the transaction stub returns.
+ mockTxSelect.mockImplementation(() => tx.select());
+
return { tx, inserted };
}
@@ -1506,12 +1511,16 @@ describe('chooseSetupTrialInferenceCommand', () => {
});
it('no-ops when model choices already exist', async () => {
+ // A repeat click: the trial was already chosen (`modelProvider` recorded)
+ // and its models seeded. A bare task-model-settings row alone is NOT a
+ // choice — deleting the last provider leaves one behind, and treating it
+ // as a choice would silently skip seeding.
vi.stubEnv('R_TRIAL_OPENROUTER_API_KEY', 'sk-trial');
mockGetPersistedEnvironmentVariableNames.mockResolvedValue([
'R_TRIAL_OPENROUTER_API_KEY',
]);
const { tx, inserted } = createTxStub({
- setupNewState: {},
+ setupNewState: { modelProvider: 'roomote' },
runtimeModelConfig: null,
taskModelSettings: {
allowedModelIds: ['roomote/openai/gpt-5.6-terra'],
@@ -1527,6 +1536,58 @@ describe('chooseSetupTrialInferenceCommand', () => {
expect(inserted).toEqual([]);
});
+ it('seeds over a leftover task-model-settings row after the last provider was deleted', async () => {
+ // Deleting the last provider nulls `modelProvider` and role config but
+ // leaves a task-model-settings row behind; the trial choice must still
+ // seed rather than silently no-op with a success payload.
+ vi.stubEnv('R_TRIAL_OPENROUTER_API_KEY', 'sk-trial');
+ mockGetPersistedEnvironmentVariableNames.mockResolvedValue([
+ 'R_TRIAL_OPENROUTER_API_KEY',
+ ]);
+ const { tx, inserted } = createTxStub({
+ setupNewState: { trialInferenceKeyImportedAt: '2026-08-27T00:00:00Z' },
+ runtimeModelConfig: null,
+ taskModelSettings: {
+ models: [],
+ allowedModelIds: [],
+ defaultModelId: '',
+ },
+ });
+ mockResolveDeploymentEnvVar.mockResolvedValue('sk-trial');
+ mockDbTransaction.mockImplementation(
+ async (callback: (tx: unknown) => Promise) => callback(tx),
+ );
+
+ const result = await chooseSetupTrialInferenceCommand(buildMockAuth());
+
+ expect(result.setupNewState.modelProvider).toBe('roomote');
+ expect(
+ inserted.find((values) => 'taskModelSettings' in values),
+ ).toBeDefined();
+ });
+
+ it('re-imports a rotated injected key while the stored key still exists', async () => {
+ vi.stubEnv('R_TRIAL_OPENROUTER_API_KEY', 'sk-rotated');
+ const { tx } = createTxStub({
+ setupNewState: { trialInferenceKeyImportedAt: '2026-08-27T00:00:00Z' },
+ runtimeModelConfig: null,
+ taskModelSettings: null,
+ });
+ mockResolveDeploymentEnvVar.mockResolvedValue('sk-old');
+ mockDbTransaction.mockImplementation(
+ async (callback: (tx: unknown) => Promise) => callback(tx),
+ );
+
+ await importTrialInferenceKeyIfNeeded('setup-test-user');
+
+ expect(mockUpsertDeploymentEnvironmentVariables).toHaveBeenCalledWith(
+ tx,
+ expect.objectContaining({
+ values: [{ name: 'R_TRIAL_OPENROUTER_API_KEY', value: 'sk-rotated' }],
+ }),
+ );
+ });
+
it('seeds despite a role-model env override, which keeps winning at runtime', async () => {
vi.stubEnv('R_TRIAL_OPENROUTER_API_KEY', 'sk-trial');
mockGetPersistedEnvironmentVariableNames.mockResolvedValue([
diff --git a/apps/web/src/trpc/commands/setup-new/index.ts b/apps/web/src/trpc/commands/setup-new/index.ts
index 88318f0db..1945a5c1d 100644
--- a/apps/web/src/trpc/commands/setup-new/index.ts
+++ b/apps/web/src/trpc/commands/setup-new/index.ts
@@ -290,74 +290,113 @@ async function savePersistedTaskModelSettings(
// Persisted runtime compute config helpers are shared with the compute
// settings commands and imported from '../compute'.
-/**
- * Free-trial inference. A hosting provisioner can inject a capped,
- * Roomote-minted OpenRouter key as `R_TRIAL_OPENROUTER_API_KEY`. The setup
- * wizard's inference step then offers managed Roomote inference alongside
- * connecting a provider. It uses Roomote model ids so an operator's future
- * OpenRouter connection remains entirely separate.
- *
- * This is an explicit operator choice, never an automatic seed: the command
- * no-ops once any inference choice exists (a selected provider, saved model
- * config, or task model settings) and refuses when a real provider is
- * already connected, so it can never overwrite configuration.
- */
/**
* Imports the hosting-injected Roomote inference key from the process
- * environment into encrypted Settings storage, once. The env variable is
- * only the delivery mechanism: after this import, every runtime read (the
- * inference gateway, credit balance, provider status) resolves the stored
- * key, so deleting the Roomote inference provider disables the trial even
- * though hosting keeps injecting the variable — the stamped
- * `trialInferenceKeyImportedAt` marker guarantees it is never re-imported.
+ * environment into encrypted Settings storage. The env variable is only the
+ * delivery mechanism: every runtime read (the inference gateway, credit
+ * balance, provider status) resolves the stored key, so deleting the Roomote
+ * inference provider disables the trial even though hosting keeps injecting
+ * the variable — once the `trialInferenceKeyImportedAt` marker is stamped, a
+ * missing stored key is an operator's explicit disable and is never
+ * re-created. A *rotated* injected value on a deployment whose stored key
+ * still exists is re-imported, so hosting can replace a revoked trial key.
*/
export async function importTrialInferenceKeyIfNeeded(
userId: string | null,
): Promise {
+ const injectedValue =
+ process.env[ROOMOTE_INFERENCE_API_KEY_ENV_VAR_NAME]?.trim();
+
if (
!isConfiguredEnvValue(process.env[ROOMOTE_INFERENCE_API_KEY_ENV_VAR_NAME])
) {
return;
}
+ // Lock-free pre-check: this runs on every setup-status read, so the common
+ // "already imported" case must not open a write transaction or take the
+ // deploymentSettings row lock.
+ if (await trialInferenceImportUpToDate(db, injectedValue!)) {
+ return;
+ }
+
await db.transaction(async (tx) => {
await tx
.insert(deploymentSettings)
.values({ id: 'default' })
- .onConflictDoUpdate({
- target: deploymentSettings.id,
- set: { updatedAt: new Date() },
- });
+ .onConflictDoNothing();
await tx
.select({ id: deploymentSettings.id })
.from(deploymentSettings)
.where(eq(deploymentSettings.id, 'default'))
.for('update');
- const currentState = await getPersistedSetupNewState(tx);
- if (currentState.trialInferenceKeyImportedAt !== null) {
+ // Re-check under the lock: a concurrent status read may have imported.
+ if (await trialInferenceImportUpToDate(tx, injectedValue!)) {
return;
}
+ const currentState = await getPersistedSetupNewState(tx);
+
await upsertDeploymentEnvironmentVariables(tx, {
userId,
values: [
{
name: ROOMOTE_INFERENCE_API_KEY_ENV_VAR_NAME,
- value: process.env[ROOMOTE_INFERENCE_API_KEY_ENV_VAR_NAME]!.trim(),
+ value: injectedValue!,
},
],
});
- await savePersistedSetupNewState(
- normalizeSetupNewState({
- ...currentState,
- trialInferenceKeyImportedAt: new Date().toISOString(),
- }),
- tx,
- );
+
+ if (currentState.trialInferenceKeyImportedAt === null) {
+ await savePersistedSetupNewState(
+ normalizeSetupNewState({
+ ...currentState,
+ trialInferenceKeyImportedAt: new Date().toISOString(),
+ }),
+ tx,
+ );
+ }
});
}
+/**
+ * Whether the injected trial key needs no import work: it was already
+ * imported and either the operator has since deleted the stored key
+ * (disabling the trial — never re-create it) or the stored value already
+ * matches the injected one.
+ */
+async function trialInferenceImportUpToDate(
+ executor: DatabaseOrTransaction,
+ injectedValue: string,
+): Promise {
+ const currentState = await getPersistedSetupNewState(executor);
+
+ if (currentState.trialInferenceKeyImportedAt === null) {
+ return false;
+ }
+
+ const storedValue = await resolveDeploymentEnvVar(
+ ROOMOTE_INFERENCE_API_KEY_ENV_VAR_NAME,
+ executor,
+ {},
+ );
+
+ return !storedValue || storedValue === injectedValue;
+}
+
+/**
+ * Free-trial inference. A hosting provisioner can inject a capped,
+ * Roomote-minted OpenRouter key as `R_TRIAL_OPENROUTER_API_KEY`. The setup
+ * wizard's inference step then offers managed Roomote inference alongside
+ * connecting a provider. It uses Roomote model ids so an operator's future
+ * OpenRouter connection remains entirely separate.
+ *
+ * This is an explicit operator choice, never an automatic seed: the command
+ * no-ops once any inference choice exists (a selected provider or saved
+ * model config) and refuses when a real provider is already connected, so it
+ * can never overwrite configuration.
+ */
export async function chooseSetupTrialInferenceCommand(auth: UserAuthSuccess) {
assertAdmin(auth);
@@ -381,7 +420,6 @@ export async function chooseSetupTrialInferenceCommand(auth: UserAuthSuccess) {
const [
currentState,
persistedModelConfig,
- persistedTaskModelSettings,
persistedEnvVarNames,
chatgptConnected,
githubCopilotConnected,
@@ -389,7 +427,6 @@ export async function chooseSetupTrialInferenceCommand(auth: UserAuthSuccess) {
] = await Promise.all([
getPersistedSetupNewState(tx),
getPersistedRuntimeModelConfig(tx),
- getPersistedRawTaskModelSettings(tx),
getPersistedEnvironmentVariableNames(tx),
isChatGptSubscriptionConnected(),
isGitHubCopilotSubscriptionConnected(),
@@ -397,10 +434,16 @@ export async function chooseSetupTrialInferenceCommand(auth: UserAuthSuccess) {
]);
// Any prior inference choice wins: a repeat click (or a stale wizard tab)
- // must not reset models an operator has since adjusted.
+ // must not reset models an operator has since adjusted. A bare
+ // task-model-settings row is deliberately NOT counted as a choice:
+ // deleting the last provider leaves one behind (normalization re-inflates
+ // it to the default catalog), and treating that leftover as a choice
+ // would make this command silently no-op — the wizard would advance with
+ // no usable models seeded at all. Settings an operator actually uses are
+ // always accompanied by a selected provider, a connected provider (which
+ // the check below refuses on), or a saved role model.
const hasModelChoices =
currentState.modelProvider !== null ||
- persistedTaskModelSettings !== null ||
Object.values(persistedModelConfig).some((value) => value !== null);
if (hasModelChoices) {
diff --git a/apps/web/src/trpc/commands/task-models/index.test.ts b/apps/web/src/trpc/commands/task-models/index.test.ts
index 4ba177bad..100fa30af 100644
--- a/apps/web/src/trpc/commands/task-models/index.test.ts
+++ b/apps/web/src/trpc/commands/task-models/index.test.ts
@@ -75,13 +75,18 @@ vi.mock('@roomote/db/server', () => ({
isXaiSubscriptionConnected: mockIsXaiSubscriptionConnected,
isNull: vi.fn((column) => ({ isNull: column })),
// Mirrors the real runtime-first-then-persisted precedence through the
- // persisted-values mock this file already controls.
+ // persisted-values mock this file already controls, including the
+ // settings-only exception: the Roomote trial key never resolves from the
+ // runtime env (see SETTINGS_ONLY_MODEL_PROVIDER_ENV_VAR_NAMES).
resolveModelProviderEnvValue: vi.fn(
async (envVarNames: string | readonly string[]) => {
const names =
typeof envVarNames === 'string' ? [envVarNames] : envVarNames;
for (const name of names) {
+ if (name === 'R_TRIAL_OPENROUTER_API_KEY') {
+ continue;
+ }
const value = process.env[name]?.trim();
if (value) {
return value;
@@ -478,7 +483,11 @@ describe('lookupTaskModelCommand', () => {
});
it('uses the managed Roomote key to look up Roomote model metadata through OpenRouter', async () => {
- process.env.R_TRIAL_OPENROUTER_API_KEY = 'managed-roomote-key';
+ // The trial key is settings-only: production resolves it from the
+ // persisted store, never the runtime env, so the test stores it there.
+ mockGetPersistedEnvironmentVariableValues.mockResolvedValue({
+ R_TRIAL_OPENROUTER_API_KEY: 'managed-roomote-key',
+ });
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({
diff --git a/apps/web/src/trpc/commands/task-models/index.ts b/apps/web/src/trpc/commands/task-models/index.ts
index cb62ec347..b7f644efe 100644
--- a/apps/web/src/trpc/commands/task-models/index.ts
+++ b/apps/web/src/trpc/commands/task-models/index.ts
@@ -14,6 +14,7 @@ import {
} from '@roomote/db/server';
import {
OPENAI_COMPATIBLE_PROVIDER_ID,
+ rebaseRoomoteModelIdToUpstream,
ROOMOTE_INFERENCE_API_KEY_ENV_VAR_NAME,
ROOMOTE_INFERENCE_PROVIDER_ID,
XAI_SUBSCRIPTION_PROVIDER_ID,
@@ -372,7 +373,7 @@ export async function getPersistedRawTaskModelSettings(
*/
export async function getTaskModelProviderSetupCommand(
auth: UserAuthSuccess,
-): Promise<{ providerSetup: SetupModelStatus }> {
+): Promise<{ providerSetup: SetupModelStatus; trialInferenceActive: boolean }> {
assertAdmin(auth);
const [
@@ -382,6 +383,7 @@ export async function getTaskModelProviderSetupCommand(
chatgptConnected,
githubCopilotConnected,
xaiSubscriptionConnected,
+ persistedTaskModelSettings,
] = await Promise.all([
getDeploymentRuntimeModelConfig(),
getPersistedEnvironmentVariableNames(),
@@ -389,6 +391,7 @@ export async function getTaskModelProviderSetupCommand(
isChatGptSubscriptionConnected(),
isGitHubCopilotSubscriptionConnected(),
isXaiSubscriptionConnected(),
+ getDeploymentTaskModelSettings(),
]);
// Include non-secret OpenAI-compatible env values (base URLs + connection
@@ -431,7 +434,14 @@ export async function getTaskModelProviderSetupCommand(
xaiSubscriptionConnected,
});
- return { providerSetup };
+ return {
+ providerSetup,
+ trialInferenceActive: isTrialInferenceActive({
+ modelProvider: setupNewState.modelProvider,
+ taskModelSettings: persistedTaskModelSettings,
+ runtimeModelConfig: persistedRuntimeModelConfig,
+ }),
+ };
}
/**
@@ -833,13 +843,56 @@ export async function saveTaskModelProviderCommand(
};
}
-function getConnectedModelProviderCount(providerSetup: SetupModelStatus) {
- // A connected ChatGPT subscription is already represented by the `chatgpt`
- // catalog entry (`savedApiKeySatisfied` mirrors `chatgptConnected`), so the
- // filter below counts it; do not add it separately.
+/**
+ * Whether the Roomote trial is actually in use — the operator chose it, so
+ * roomote models are selected or seeded. The imported trial key alone does
+ * not count: hosting delivers it to every eligible deployment before any
+ * operator decision, so a merely-imported key must not make the hidden
+ * roomote provider satisfy connectivity guards.
+ */
+function isTrialInferenceActive({
+ modelProvider,
+ taskModelSettings,
+ runtimeModelConfig,
+}: {
+ modelProvider: SetupModelProviderId | null;
+ taskModelSettings: ReturnType;
+ runtimeModelConfig: DeploymentModelConfig;
+}): boolean {
+ return (
+ modelProvider === ROOMOTE_INFERENCE_PROVIDER_ID ||
+ (taskModelSettings.models ?? []).some((model) =>
+ isModelIdForProvider(model.id, ROOMOTE_INFERENCE_PROVIDER_ID),
+ ) ||
+ TASK_MODEL_ROLES.some((role) => {
+ const modelId =
+ runtimeModelConfig[TASK_MODEL_ROLE_DESCRIPTORS[role].modelConfigKey];
+
+ return (
+ modelId !== null &&
+ isModelIdForProvider(modelId, ROOMOTE_INFERENCE_PROVIDER_ID)
+ );
+ })
+ );
+}
+
+/**
+ * Connected providers that would remain after deleting `deletingProviderId`.
+ * A connected ChatGPT subscription is already represented by the `chatgpt`
+ * catalog entry (`savedApiKeySatisfied` mirrors `chatgptConnected`), so the
+ * filter below counts it; do not add it separately. The hidden roomote row
+ * counts only while the trial is actually in use — see
+ * `isTrialInferenceActive`.
+ */
+function countRemainingConnectedModelProviders(
+ providerSetup: SetupModelStatus,
+ options: { deletingProviderId: SetupModelProviderId; trialActive: boolean },
+) {
return providerSetup.providers.filter(
(provider) =>
- provider.runtimeApiKeySatisfied || provider.savedApiKeySatisfied,
+ provider.id !== options.deletingProviderId &&
+ (provider.runtimeApiKeySatisfied || provider.savedApiKeySatisfied) &&
+ (provider.id !== ROOMOTE_INFERENCE_PROVIDER_ID || options.trialActive),
).length;
}
@@ -936,6 +989,7 @@ export async function deleteTaskModelProviderCommand(
persistedEnvVarNames,
setupNewState,
chatgptConnected,
+ githubCopilotConnected,
xaiSubscriptionConnected,
persistedTaskModelSettings,
] = await Promise.all([
@@ -943,6 +997,7 @@ export async function deleteTaskModelProviderCommand(
getPersistedEnvironmentVariableNames(tx),
getDeploymentSetupNewState(tx),
isChatGptSubscriptionConnected(),
+ isGitHubCopilotSubscriptionConnected(),
isXaiSubscriptionConnected(),
getDeploymentTaskModelSettings(),
]);
@@ -953,6 +1008,7 @@ export async function deleteTaskModelProviderCommand(
persistedEnvVarNames,
selectedProvider: setupNewState.modelProvider,
chatgptConnected,
+ githubCopilotConnected,
xaiSubscriptionConnected,
});
const providerStatus = providerSetup.providers.find(
@@ -973,9 +1029,18 @@ export async function deleteTaskModelProviderCommand(
// Key-only xAI delete is allowed even when the catalog shows a single
// connected `xai` entry, because the subscription remains as a provider.
+ const trialActive = isTrialInferenceActive({
+ modelProvider: setupNewState.modelProvider,
+ taskModelSettings: persistedTaskModelSettings,
+ runtimeModelConfig: persistedRuntimeModelConfig,
+ });
+
if (
!xaiKeyOnlyDelete &&
- getConnectedModelProviderCount(providerSetup) <= 1
+ countRemainingConnectedModelProviders(providerSetup, {
+ deletingProviderId: provider.id,
+ trialActive,
+ }) < 1
) {
throw new Error('Keep at least one inference provider connected.');
}
@@ -1518,9 +1583,10 @@ export async function lookupTaskModelCommand(
// Roomote inference routes through OpenRouter but has a separate model-id
// namespace so it can coexist with an operator's OpenRouter connection.
- const roomoteInferenceModel = modelId.startsWith('roomote/');
+ const roomoteUpstreamSlug = rebaseRoomoteModelIdToUpstream(modelId);
+ const roomoteInferenceModel = roomoteUpstreamSlug !== null;
const openRouterLookupModelId = roomoteInferenceModel
- ? `openrouter/${modelId.slice('roomote/'.length)}`
+ ? `openrouter/${roomoteUpstreamSlug}`
: modelId;
// Only the OpenRouter model API supports single-model lookup; every other
diff --git a/packages/cloud-agents/src/server/opencode-runtime.ts b/packages/cloud-agents/src/server/opencode-runtime.ts
index 3e329f8b4..c6ef8a410 100644
--- a/packages/cloud-agents/src/server/opencode-runtime.ts
+++ b/packages/cloud-agents/src/server/opencode-runtime.ts
@@ -15,6 +15,7 @@ import {
mergeOpenCodeChatGptFastModeOptions,
mergeOpenRouterVariantAliasModels,
normalizeOptionalReasoningEffort,
+ SETTINGS_ONLY_MODEL_PROVIDER_ENV_VAR_NAMES,
stripOpenCodeModelReasoningOptions,
toBedrockMantleRuntimeModelId,
type OpenRouterVariantModelAlias,
@@ -464,6 +465,20 @@ export function buildOpenCodeCliEnv(
}
}
+ // Settings-only credentials (the Roomote trial key) must never be inherited
+ // from the process environment: the injected variable is only hosting's
+ // delivery mechanism, and honoring it here would keep the trial working in
+ // helper model processes after an operator deleted the stored key to
+ // disable it. The legitimate value, when the trial is active, arrives via
+ // `extraEnv` from the persisted-store resolver. Stripped before the
+ // model-backed config builder below so the value cannot reach provider
+ // config content either.
+ for (const envVarName of SETTINGS_ONLY_MODEL_PROVIDER_ENV_VAR_NAMES) {
+ if (extraEnv?.[envVarName] === undefined) {
+ delete env[envVarName];
+ }
+ }
+
for (const modelEnvVarName of [
'R_MODEL',
'R_SMALL_MODEL',
diff --git a/packages/db/src/lib/model-runtime-config.ts b/packages/db/src/lib/model-runtime-config.ts
index ad3cda65e..b9eca68ae 100644
--- a/packages/db/src/lib/model-runtime-config.ts
+++ b/packages/db/src/lib/model-runtime-config.ts
@@ -14,6 +14,7 @@ import {
INFERENCE_GATEWAY_XAI_ENV_VAR_NAME,
isConfiguredEnvValue,
isInferenceGatewayCoveredEnvVar,
+ isSettingsOnlyProviderEnvVar,
normalizeDeploymentModelConfig,
normalizeOptionalReasoningEffort,
parseModelProviderEnvKeys,
@@ -165,9 +166,10 @@ function resolveProviderKeyNames({
/**
* Resolve a single model-provider env value with the same precedence the task
* runtime uses: the runtime process env first, then the persisted (encrypted)
- * deployment environment variables. The Roomote inference key is the one
- * exception: its env variable is only the hosting platform's delivery
- * mechanism (setup imports it into Settings storage once), so it resolves
+ * deployment environment variables. Settings-only vars (see
+ * `SETTINGS_ONLY_MODEL_PROVIDER_ENV_VAR_NAMES`) are the exception: their env
+ * variables are only the hosting platform's delivery mechanism (setup
+ * imports them into Settings storage), so they resolve
* from the persisted store alone — deleting the stored key disables the
* provider even while hosting keeps injecting the variable.
*/
@@ -182,7 +184,7 @@ export async function resolveModelProviderEnvValue(
const names = typeof envVarNames === 'string' ? [envVarNames] : envVarNames;
for (const envVarName of names) {
- if (envVarName === ROOMOTE_INFERENCE_API_KEY_ENV_VAR_NAME) continue;
+ if (isSettingsOnlyProviderEnvVar(envVarName)) continue;
const runtimeValue = normalizeConfiguredValue(runtimeEnv[envVarName]);
if (runtimeValue) {
@@ -463,7 +465,7 @@ async function resolveModelRuntimeEnv(
}
const value =
- (envVarName === ROOMOTE_INFERENCE_API_KEY_ENV_VAR_NAME
+ (isSettingsOnlyProviderEnvVar(envVarName)
? undefined
: normalizeConfiguredValue(runtimeEnv[envVarName])) ??
normalizeConfiguredValue(persistedEnvVars[envVarName]);
diff --git a/packages/db/src/lib/provider-credit-balance.ts b/packages/db/src/lib/provider-credit-balance.ts
index 4c4270f45..19802dc12 100644
--- a/packages/db/src/lib/provider-credit-balance.ts
+++ b/packages/db/src/lib/provider-credit-balance.ts
@@ -184,10 +184,17 @@ async function fetchOpenRouterKeyPayload(
return { apiKey, payload };
}
-export async function fetchOpenRouterCreditBalance(
- options: BalanceFetchOptions = {},
+/**
+ * Both balances come from OpenRouter's key endpoint — the Roomote trial is a
+ * Roomote-minted OpenRouter key — so they share one fetcher and differ only
+ * in which env var holds the credential and which provider row they report.
+ */
+async function fetchOpenRouterBackedCreditBalance(
+ providerId: ProviderCreditBalance['providerId'],
+ envVarName: string,
+ options: BalanceFetchOptions,
): Promise {
- const response = await fetchOpenRouterKeyPayload(options);
+ const response = await fetchOpenRouterKeyPayload(options, envVarName);
if (!response) {
return null;
}
@@ -198,33 +205,30 @@ export async function fetchOpenRouterCreditBalance(
}
return {
- providerId: 'openrouter',
+ providerId,
...parsed,
fetchedAt: new Date().toISOString(),
};
}
-export async function fetchRoomoteCreditBalance(
+export async function fetchOpenRouterCreditBalance(
options: BalanceFetchOptions = {},
): Promise {
- const response = await fetchOpenRouterKeyPayload(
+ return fetchOpenRouterBackedCreditBalance(
+ 'openrouter',
+ 'OPENROUTER_API_KEY',
options,
- ROOMOTE_INFERENCE_API_KEY_ENV_VAR_NAME,
);
- if (!response) {
- return null;
- }
-
- const parsed = parseOpenRouterKeyBalance(response.payload);
- if (!parsed) {
- return null;
- }
+}
- return {
- providerId: 'roomote',
- ...parsed,
- fetchedAt: new Date().toISOString(),
- };
+export async function fetchRoomoteCreditBalance(
+ options: BalanceFetchOptions = {},
+): Promise {
+ return fetchOpenRouterBackedCreditBalance(
+ 'roomote',
+ ROOMOTE_INFERENCE_API_KEY_ENV_VAR_NAME,
+ options,
+ );
}
/**
diff --git a/packages/env/src/index.ts b/packages/env/src/index.ts
index 046def459..476fc42be 100644
--- a/packages/env/src/index.ts
+++ b/packages/env/src/index.ts
@@ -365,12 +365,16 @@ const serverSchema = {
// Brain at a different embedding path.
R_BRAIN_OPENAI_API_KEY: z.string().min(1).optional(),
// Free-trial OpenRouter credential a hosting provisioner injects for new
- // cloud deployments: a Roomote-minted key with a hard spend limit, used as
- // the OpenRouter credential only while no operator key is configured
- // (runtime or saved), so connecting a real key always wins. Runtime-env
- // only: never persisted, never editable in Settings, and served through
- // the inference gateway like any other provider key, so it never reaches
- // a sandbox.
+ // cloud deployments: a Roomote-minted key with a hard spend limit. The env
+ // variable is only hosting's delivery mechanism — setup imports its value
+ // into encrypted Settings storage, and every runtime read (inference
+ // gateway, credit balance, provider status) resolves the stored key, never
+ // this variable. Activating the trial is an explicit operator choice in
+ // the setup wizard; deleting the Roomote provider in Settings removes the
+ // stored key and disables the trial even while hosting keeps injecting
+ // this variable. Rotating the injected value re-imports it only while the
+ // stored key still exists. Served through the inference gateway like any
+ // other provider key, so it never reaches a sandbox.
R_TRIAL_OPENROUTER_API_KEY: z.string().min(1).optional(),
// Optional self-run inference upstreams for the Brain gateway. When set,
// the gateway routes that path's requests there instead of the configured
diff --git a/packages/types/src/model-provider-config.ts b/packages/types/src/model-provider-config.ts
index 44d1bfdc5..ceec615c4 100644
--- a/packages/types/src/model-provider-config.ts
+++ b/packages/types/src/model-provider-config.ts
@@ -53,6 +53,24 @@ export const ROOMOTE_INFERENCE_API_KEY_ENV_VAR_NAME =
'R_TRIAL_OPENROUTER_API_KEY' as const;
export const ROOMOTE_TRIAL_MODEL_PRESET_ID = 'trial' as const;
+/**
+ * Provider env vars that are hosting delivery mechanisms, not credentials:
+ * setup imports the injected value into encrypted Settings storage, and only
+ * the stored value ever satisfies or authenticates the provider. Every
+ * runtime read must go through this predicate — reading the process value
+ * directly would resurrect a provider whose stored key the operator deleted
+ * to disable it.
+ */
+export const SETTINGS_ONLY_MODEL_PROVIDER_ENV_VAR_NAMES = [
+ ROOMOTE_INFERENCE_API_KEY_ENV_VAR_NAME,
+] as const;
+
+export function isSettingsOnlyProviderEnvVar(name: string): boolean {
+ return SETTINGS_ONLY_MODEL_PROVIDER_ENV_VAR_NAMES.includes(
+ name as (typeof SETTINGS_ONLY_MODEL_PROVIDER_ENV_VAR_NAMES)[number],
+ );
+}
+
/**
* Model-id prefix used when composing or looking up task models for a setup
* catalog provider. Subscription connect surfaces are not prefixes: ChatGPT
@@ -429,10 +447,28 @@ const OPENROUTER_EFFICIENT_MODEL_PRESET = {
},
} as const satisfies RecommendedModelPreset;
+/**
+ * Roomote inference is an aliased namespace over OpenRouter: `roomote/`
+ * serves OpenRouter's `` through the trial key while staying separate
+ * from an operator's own OpenRouter connection. These two helpers are the
+ * only home of that translation — the gateway's request rewrite, model
+ * lookup, and catalog seeding all import them instead of re-deriving the
+ * prefix.
+ */
function rebaseOpenRouterModelIdForRoomote(modelId: string): string {
return modelId.replace(/^openrouter\//u, `${ROOMOTE_INFERENCE_PROVIDER_ID}/`);
}
+/**
+ * The upstream (OpenRouter-side) slug for a `roomote/`-prefixed model id, or
+ * null when the id is not in the Roomote namespace.
+ */
+export function rebaseRoomoteModelIdToUpstream(modelId: string): string | null {
+ const prefix = `${ROOMOTE_INFERENCE_PROVIDER_ID}/`;
+
+ return modelId.startsWith(prefix) ? modelId.slice(prefix.length) : null;
+}
+
function rebaseOpenRouterPresetForRoomote(
preset: RecommendedModelPreset,
): RecommendedModelPreset {
@@ -2053,15 +2089,12 @@ export function buildSetupModelStatus(input: {
const requiredEnvVarNames =
getSetupModelProviderRequiredEnvVarNames(provider);
const hasRequiredEnvVars = requiredEnvVarNames.length > 0;
- // The hosting-injected Roomote inference variable is a delivery
- // mechanism, not a credential: setup imports it into encrypted Settings
- // storage once, and only the stored key connects the provider. Counting
- // the env value here would resurrect a provider whose stored key the
- // operator deleted to disable the trial.
- const isRuntimeConfigured =
- provider.id === ROOMOTE_INFERENCE_PROVIDER_ID
- ? () => false
- : (name: string) => isConfiguredEnvValue(runtimeEnv[name]);
+ // Settings-only vars (the Roomote trial key) are delivery mechanisms,
+ // not credentials: only the stored key connects the provider. See
+ // `SETTINGS_ONLY_MODEL_PROVIDER_ENV_VAR_NAMES`.
+ const isRuntimeConfigured = (name: string) =>
+ !isSettingsOnlyProviderEnvVar(name) &&
+ isConfiguredEnvValue(runtimeEnv[name]);
const isPersisted = (name: string) => persistedEnvVarNameSet.has(name);
const additionalEnvValues = Object.fromEntries(
[