From 5862f501cc5707682268225370236543740f0694 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:11:14 -0400 Subject: [PATCH 01/17] feat: free-trial inference via a provisioner-injected OpenRouter fallback key A hosting provisioner can inject a capped, Roomote-minted OpenRouter key as R_TRIAL_OPENROUTER_API_KEY. The key acts as the OpenRouter credential only while no operator key is configured (runtime or saved), so connecting a real key always wins. A fresh deployment holding the trial key and no inference choices of its own is seeded with OpenRouter's new Efficient preset (gpt-5.6-luna for every role) as ordinary editable config, so the setup wizard skips the inference step, first tasks run on an inexpensive default, and every model/provider control keeps working. - Efficient preset on the OpenRouter catalog entry - resolveModelProviderEnvValue gains a runtime-env-only trial fallback tier - gateway coverage and control-plane key resolution honor the fallback, so the key stays on the control plane and never reaches a sandbox (also blocked from worker env outright) - buildSetupModelStatus satisfies the provider through the fallback and flags trialKeySatisfied so Settings keeps the connect/edit affordances - first-boot seeding in the setup status command, guarded to never overwrite operator choices - docs: environment-variables entry --- apps/docs/environment-variables.mdx | 1 + .../settings/InferenceProviderSection.tsx | 5 +- .../src/trpc/commands/setup-new/index.test.ts | 103 +++++++++++++++++ apps/web/src/trpc/commands/setup-new/index.ts | 107 ++++++++++++++++++ .../trpc/commands/task-models/index.test.ts | 28 +++++ .../src/trpc/commands/task-models/index.ts | 11 +- .../compute-providers/src/worker-env/base.ts | 4 + .../db/src/lib/model-runtime-config.test.ts | 88 ++++++++++++++ packages/db/src/lib/model-runtime-config.ts | 33 +++++- packages/env/src/index.ts | 9 ++ .../types/src/model-provider-config.test.ts | 78 +++++++++++++ packages/types/src/model-provider-config.ts | 81 ++++++++++++- 12 files changed, 538 insertions(+), 10 deletions(-) diff --git a/apps/docs/environment-variables.mdx b/apps/docs/environment-variables.mdx index 3d546b22e..50e606b43 100644 --- a/apps/docs/environment-variables.mdx +++ b/apps/docs/environment-variables.mdx @@ -187,6 +187,7 @@ as per-task auth tokens or workspace paths. | `R_PLANNING_MODEL_REASONING_EFFORT` | Optional | Reasoning level for the advisor model. | | `R_MODEL_ENV_KEYS` | Optional | Comma- or space-separated list of extra provider key env vars to forward to task workers. | | `OPENROUTER_API_KEY` | Provider key | OpenRouter API key. Can also be saved from **Settings > Models**. | +| `R_TRIAL_OPENROUTER_API_KEY` | Optional | Fallback OpenRouter key a hosting platform can inject for free-trial credits. Used only while no `OPENROUTER_API_KEY` is configured (environment or saved), so connecting your own key always wins. | | `REQUESTY_API_KEY` | Provider key | Requesty API key. Can also be saved from **Settings > Models**. | | `AI_GATEWAY_API_KEY` | Provider key | Vercel AI Gateway API key. | | `BASETEN_API_KEY` | Provider key | Baseten API key. | diff --git a/apps/web/src/components/settings/InferenceProviderSection.tsx b/apps/web/src/components/settings/InferenceProviderSection.tsx index 5c1f4a22c..98e602b7b 100644 --- a/apps/web/src/components/settings/InferenceProviderSection.tsx +++ b/apps/web/src/components/settings/InferenceProviderSection.tsx @@ -149,7 +149,10 @@ function ConnectedProviderRow({ onEdit: () => void; onDelete: () => void; }) { - const hasRuntimeKey = provider.runtimeApiKeySatisfied; + // A free-trial fallback key connects the provider but must not lock the + // row: saving an operator key over it is exactly how a trial ends. + const hasRuntimeKey = + provider.runtimeApiKeySatisfied && !provider.trialKeySatisfied; const primaryCredentialLabel = provider.envVarLabel ?? 'API key'; const runtimeKeyTooltip = provider.envVarName ? `Set by ${provider.envVarName}, not changeable in the UI.` 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 13a028a72..19e548548 100644 --- a/apps/web/src/trpc/commands/setup-new/index.test.ts +++ b/apps/web/src/trpc/commands/setup-new/index.test.ts @@ -259,6 +259,8 @@ import { trackSetupBootstrapWelcomeSeenCommand, trackSetupCommsStateCommand, trackSetupWelcomeSeenCommand, + ensureTrialModelConfigSeeded, + resetTrialModelConfigSeedCheckForTests, } from './index'; import { WORKER_RUNTIME_SCHEMA_VERSION, @@ -1339,3 +1341,104 @@ describe('setup recommendation commands', () => { }); }); }); + +describe('ensureTrialModelConfigSeeded', () => { + function createTxStub(row: Record) { + const inserted: Array> = []; + const tx = { + select: vi.fn(() => createSelectChain([row])), + insert: vi.fn(() => ({ + values: vi.fn((values: Record) => { + inserted.push(values); + return { onConflictDoUpdate: vi.fn(async () => undefined) }; + }), + })), + }; + + return { tx, inserted }; + } + + beforeEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + resetTrialModelConfigSeedCheckForTests(); + mockGetPersistedEnvironmentVariableNames.mockResolvedValue([]); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + resetTrialModelConfigSeedCheckForTests(); + }); + + it('seeds the Efficient OpenRouter defaults on a fresh trial deployment', async () => { + vi.stubEnv('R_TRIAL_OPENROUTER_API_KEY', 'sk-trial'); + const { tx, inserted } = createTxStub({ + setupNewState: {}, + runtimeModelConfig: null, + taskModelSettings: null, + }); + mockDbTransaction.mockImplementation( + async (callback: (tx: unknown) => Promise) => callback(tx), + ); + + await ensureTrialModelConfigSeeded(); + + const runtimeModelConfigInsert = inserted.find( + (values) => 'runtimeModelConfig' in values, + ); + const taskModelSettingsInsert = inserted.find( + (values) => 'taskModelSettings' in values, + ); + + expect(runtimeModelConfigInsert?.runtimeModelConfig).toMatchObject({ + roomoteModel: 'openrouter/openai/gpt-5.6-luna', + roomoteSmallModel: 'openrouter/openai/gpt-5.6-luna', + roomotePlanningModel: 'openrouter/openai/gpt-5.6-luna', + }); + expect(taskModelSettingsInsert?.taskModelSettings).toMatchObject({ + defaultModelId: 'openrouter/openai/gpt-5.6-luna', + }); + }); + + it('does nothing without the trial key in the environment', async () => { + await ensureTrialModelConfigSeeded(); + + expect(mockDbTransaction).not.toHaveBeenCalled(); + }); + + it('never overwrites an operator-configured provider', async () => { + vi.stubEnv('R_TRIAL_OPENROUTER_API_KEY', 'sk-trial'); + vi.stubEnv('OPENROUTER_API_KEY', 'sk-operator'); + const { tx, inserted } = createTxStub({ + setupNewState: {}, + runtimeModelConfig: null, + taskModelSettings: null, + }); + mockDbTransaction.mockImplementation( + async (callback: (tx: unknown) => Promise) => callback(tx), + ); + + await ensureTrialModelConfigSeeded(); + + expect(inserted).toEqual([]); + }); + + it('never overwrites existing model choices', async () => { + vi.stubEnv('R_TRIAL_OPENROUTER_API_KEY', 'sk-trial'); + const { tx, inserted } = createTxStub({ + setupNewState: {}, + runtimeModelConfig: null, + taskModelSettings: { + allowedModelIds: ['openrouter/openai/gpt-5.6-terra'], + defaultModelId: 'openrouter/openai/gpt-5.6-terra', + }, + }); + mockDbTransaction.mockImplementation( + async (callback: (tx: unknown) => Promise) => callback(tx), + ); + + await ensureTrialModelConfigSeeded(); + + expect(inserted).toEqual([]); + }); +}); diff --git a/apps/web/src/trpc/commands/setup-new/index.ts b/apps/web/src/trpc/commands/setup-new/index.ts index 7f89857ee..74235a2fb 100644 --- a/apps/web/src/trpc/commands/setup-new/index.ts +++ b/apps/web/src/trpc/commands/setup-new/index.ts @@ -79,6 +79,7 @@ import { isConfiguredEnvValue, isRequiredComputeField, normalizeTaskModelSettings, + DEFAULT_TASK_MODEL_SETTINGS, NON_SECRET_AUTH_ENV_VAR_NAMES, NON_SECRET_COMPUTE_ENV_VAR_NAMES, NON_SECRET_SOURCE_CONTROL_ENV_VAR_NAMES, @@ -287,6 +288,111 @@ async function savePersistedTaskModelSettings( // Persisted runtime compute config helpers are shared with the compute // settings commands and imported from '../compute'. +/** + * Free-trial inference seeding. A hosting provisioner can inject a capped, + * Roomote-minted OpenRouter key as `R_TRIAL_OPENROUTER_API_KEY`; a fresh + * deployment holding that key and no inference choices of its own is seeded + * with OpenRouter's "Efficient" preset as ordinary editable config. The + * setup wizard then skips the inference step, first tasks run on an + * inexpensive default, and every model and provider control keeps working + * because nothing is pinned through env. + * + * Seeding runs at most once per process and only while the deployment has + * made no inference choices at all: no provider selected in setup, no saved + * or operator-env provider credential, no saved model config or task model + * settings, and no `R_MODEL` role pin. Any of those appearing later must + * never be overwritten back to the trial defaults. + */ +const TRIAL_SEED_PRESET_ID = 'efficient'; +let trialModelConfigSeedChecked = false; + +export function resetTrialModelConfigSeedCheckForTests(): void { + trialModelConfigSeedChecked = false; +} + +export async function ensureTrialModelConfigSeeded(): Promise { + if (trialModelConfigSeedChecked) { + return; + } + + if (!isConfiguredEnvValue(process.env.R_TRIAL_OPENROUTER_API_KEY)) { + trialModelConfigSeedChecked = true; + return; + } + + try { + await db.transaction(async (tx) => { + const [ + currentState, + persistedModelConfig, + persistedTaskModelSettings, + persistedEnvVarNames, + chatgptConnected, + githubCopilotConnected, + xaiSubscriptionConnected, + ] = await Promise.all([ + getPersistedSetupNewState(tx), + getPersistedRuntimeModelConfig(tx), + getPersistedRawTaskModelSettings(tx), + getPersistedEnvironmentVariableNames(tx), + isChatGptSubscriptionConnected(), + isGitHubCopilotSubscriptionConnected(), + isXaiSubscriptionConnected(), + ]); + + const hasModelChoices = + currentState.modelProvider !== null || + persistedTaskModelSettings !== null || + Object.values(persistedModelConfig).some((value) => value !== null); + + const status = buildSetupModelStatus({ + runtimeEnv: process.env, + persistedEnvVarNames, + chatgptConnected, + githubCopilotConnected, + xaiSubscriptionConnected, + }); + const hasOperatorProvider = + status.runtimeRoomoteModelSatisfied || + status.providers.some( + (provider) => + provider.savedApiKeySatisfied || + (provider.runtimeApiKeySatisfied && !provider.trialKeySatisfied), + ); + + if (hasModelChoices || hasOperatorProvider) { + return; + } + + const provider = getSetupModelProvider('openrouter'); + const runtimeModelConfig = buildRecommendedDeploymentModelConfig( + provider, + TRIAL_SEED_PRESET_ID, + ); + const defaultModelId = runtimeModelConfig.roomoteModel; + + await Promise.all([ + savePersistedRuntimeModelConfig(runtimeModelConfig, tx), + savePersistedTaskModelSettings( + normalizeTaskModelSettings({ + ...DEFAULT_TASK_MODEL_SETTINGS, + ...(defaultModelId ? { defaultModelId } : {}), + }), + tx, + ), + ]); + }); + + trialModelConfigSeedChecked = true; + } catch (error) { + console.error( + `[ensureTrialModelConfigSeeded] Failed to seed trial model config: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } +} + async function resolveSelectedRepositories(repositoryIds: string[]): Promise<{ normalizedRepositoryIds: string[]; selectedRepositories: SelectedRepositorySummary[]; @@ -1100,6 +1206,7 @@ export async function getSetupNewStatusCommand(auth: UserAuthSuccess) { const { userId } = auth; await purgeSavedDeploymentWorkerImage(); + await ensureTrialModelConfigSeeded(); const [ baseStatus, 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 af8e8a9c8..078c0ec24 100644 --- a/apps/web/src/trpc/commands/task-models/index.test.ts +++ b/apps/web/src/trpc/commands/task-models/index.test.ts @@ -73,6 +73,34 @@ vi.mock('@roomote/db/server', () => ({ mockIsGitHubCopilotSubscriptionConnected, 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. + resolveModelProviderEnvValue: vi.fn( + async (envVarNames: string | readonly string[]) => { + const names = + typeof envVarNames === 'string' ? [envVarNames] : envVarNames; + + for (const name of names) { + const value = process.env[name]?.trim(); + if (value) { + return value; + } + } + + const persisted = (await mockGetPersistedEnvironmentVariableValues( + names, + )) as Partial> | undefined; + + for (const name of names) { + const value = persisted?.[name]?.trim(); + if (value) { + return value; + } + } + + return undefined; + }, + ), })); vi.mock('../environment-variables', () => ({ diff --git a/apps/web/src/trpc/commands/task-models/index.ts b/apps/web/src/trpc/commands/task-models/index.ts index f9441cb95..1142bf896 100644 --- a/apps/web/src/trpc/commands/task-models/index.ts +++ b/apps/web/src/trpc/commands/task-models/index.ts @@ -9,6 +9,7 @@ import { isGitHubCopilotSubscriptionConnected, isXaiSubscriptionConnected, isNull, + resolveModelProviderEnvValue, type DatabaseOrTransaction, } from '@roomote/db/server'; import { @@ -1512,11 +1513,11 @@ export async function lookupTaskModelCommand( return lookupModelFromModelsDevCatalog(modelId); } - const runtimeOpenRouterKey = process.env.OPENROUTER_API_KEY?.trim(); - const openRouterKey = runtimeOpenRouterKey - ? runtimeOpenRouterKey - : (await getPersistedEnvironmentVariableValues(['OPENROUTER_API_KEY'])) - .OPENROUTER_API_KEY; + // Shared runtime-first resolution, including the free-trial fallback key, + // so metadata lookups work on trial-only deployments. + const openRouterKey = await resolveModelProviderEnvValue([ + 'OPENROUTER_API_KEY', + ]); if (!openRouterKey) { return { diff --git a/packages/compute-providers/src/worker-env/base.ts b/packages/compute-providers/src/worker-env/base.ts index cd996bb0c..f7472447c 100644 --- a/packages/compute-providers/src/worker-env/base.ts +++ b/packages/compute-providers/src/worker-env/base.ts @@ -18,6 +18,10 @@ const BLOCKED_WORKER_ENV_KEYS = new Set([ 'DASHBOARD_PASSWORD', 'SETUP_TOKEN', 'MODAL_TOKEN_SECRET', + // The free-trial OpenRouter key is inference-gateway-served like the + // primary OpenRouter credential; block it here too so no env passthrough + // can ever ship it into a sandbox. + 'R_TRIAL_OPENROUTER_API_KEY', ...DISABLED_MODEL_PROVIDER_ENV_VAR_NAMES, ]); diff --git a/packages/db/src/lib/model-runtime-config.test.ts b/packages/db/src/lib/model-runtime-config.test.ts index 4712496d5..a9ae7dd34 100644 --- a/packages/db/src/lib/model-runtime-config.test.ts +++ b/packages/db/src/lib/model-runtime-config.test.ts @@ -74,6 +74,7 @@ import { isBrainProviderConfigured, resetBrainProviderConfiguredCache, resolveEffectiveModelRuntimeEnv, + resolveModelProviderEnvValue, resolveSandboxModelRuntimeEnv, } from './model-runtime-config'; import { TASK_MODEL_ROLE_DESCRIPTORS, TASK_MODEL_ROLES } from '@roomote/types'; @@ -1153,6 +1154,93 @@ describe('resolveEffectiveModelRuntimeEnv', () => { }); }); +describe('free-trial fallback key', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockDecryptSecrets.mockImplementation(async (value) => value); + mockEnvironmentVariablesFindMany.mockResolvedValue([]); + mockResolveGitHubCopilotOpenCodeAuthContent.mockResolvedValue(null); + mockResolveOpenCodeAuthContent.mockResolvedValue(null); + mockIsChatGptSubscriptionFastModeEnabled.mockResolvedValue(false); + mockGetFreshXaiAccessToken.mockResolvedValue(null); + }); + + it('advertises OpenRouter as gateway-served without leaking the trial key', async () => { + mockDeploymentSettingsFindFirst.mockResolvedValue({ + runtimeModelConfig: { roomoteModel: 'openrouter/openai/gpt-5.6-luna' }, + taskModelSettings: null, + }); + + const env = await resolveSandboxModelRuntimeEnv({ + runtimeEnv: { R_TRIAL_OPENROUTER_API_KEY: 'sk-trial' }, + deploymentEnvVars: {}, + }); + + expect(env.R_INFERENCE_GATEWAY_KEYS?.split(',')).toContain( + 'OPENROUTER_API_KEY', + ); + expect(env).not.toHaveProperty('OPENROUTER_API_KEY'); + expect(env).not.toHaveProperty('R_TRIAL_OPENROUTER_API_KEY'); + expect(Object.values(env)).not.toContain('sk-trial'); + }); + + it('materializes the trial key on the control plane when nothing else is configured', async () => { + mockDeploymentSettingsFindFirst.mockResolvedValue({ + runtimeModelConfig: { roomoteModel: 'openrouter/openai/gpt-5.6-luna' }, + taskModelSettings: null, + }); + + const env = await resolveEffectiveModelRuntimeEnv({ + runtimeEnv: { R_TRIAL_OPENROUTER_API_KEY: 'sk-trial' }, + deploymentEnvVars: {}, + }); + + expect(env.OPENROUTER_API_KEY).toBe('sk-trial'); + expect(env).not.toHaveProperty('R_TRIAL_OPENROUTER_API_KEY'); + }); + + it('never outranks a saved operator key', async () => { + mockDeploymentSettingsFindFirst.mockResolvedValue({ + runtimeModelConfig: { roomoteModel: 'openrouter/openai/gpt-5.6-luna' }, + taskModelSettings: null, + }); + + const env = await resolveEffectiveModelRuntimeEnv({ + runtimeEnv: { R_TRIAL_OPENROUTER_API_KEY: 'sk-trial' }, + deploymentEnvVars: { OPENROUTER_API_KEY: 'sk-saved' }, + }); + + expect(env.OPENROUTER_API_KEY).toBe('sk-saved'); + }); + + it('resolveModelProviderEnvValue prefers runtime, then saved, then trial', async () => { + await expect( + resolveModelProviderEnvValue(['OPENROUTER_API_KEY'], { + runtimeEnv: { + OPENROUTER_API_KEY: 'sk-runtime', + R_TRIAL_OPENROUTER_API_KEY: 'sk-trial', + }, + }), + ).resolves.toBe('sk-runtime'); + + mockEnvironmentVariablesFindMany.mockResolvedValue([ + { name: 'OPENROUTER_API_KEY', value: 'sk-saved' }, + ]); + await expect( + resolveModelProviderEnvValue(['OPENROUTER_API_KEY'], { + runtimeEnv: { R_TRIAL_OPENROUTER_API_KEY: 'sk-trial' }, + }), + ).resolves.toBe('sk-saved'); + + mockEnvironmentVariablesFindMany.mockResolvedValue([]); + await expect( + resolveModelProviderEnvValue(['OPENROUTER_API_KEY'], { + runtimeEnv: { R_TRIAL_OPENROUTER_API_KEY: 'sk-trial' }, + }), + ).resolves.toBe('sk-trial'); + }); +}); + describe('isBrainProviderConfigured', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/packages/db/src/lib/model-runtime-config.ts b/packages/db/src/lib/model-runtime-config.ts index 608258e5b..fb3080898 100644 --- a/packages/db/src/lib/model-runtime-config.ts +++ b/packages/db/src/lib/model-runtime-config.ts @@ -8,6 +8,7 @@ import { getEnabledTaskModels, getModelProviderEnvKeyCandidates, getTaskModelCatalog, + getTrialModelProviderEnvVarName, INFERENCE_GATEWAY_CHATGPT_ENV_VAR_NAME, INFERENCE_GATEWAY_GITHUB_COPILOT_ENV_VAR_NAME, INFERENCE_GATEWAY_KEYS_ENV_VAR_NAME, @@ -163,7 +164,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. + * deployment environment variables. When neither source configures a name, + * its free-trial fallback (`TRIAL_MODEL_PROVIDER_ENV_VAR_FALLBACKS`) is + * consulted last, from the runtime env only, so a Roomote-minted trial key + * never outranks a credential the operator configured themselves. */ export async function resolveModelProviderEnvValue( envVarNames: string | readonly string[], @@ -197,6 +201,17 @@ export async function resolveModelProviderEnvValue( } } + for (const envVarName of names) { + const trialEnvVarName = getTrialModelProviderEnvVarName(envVarName); + const trialValue = trialEnvVarName + ? normalizeConfiguredValue(runtimeEnv[trialEnvVarName]) + : undefined; + + if (trialValue) { + return trialValue; + } + } + return undefined; } @@ -429,12 +444,23 @@ async function resolveModelRuntimeEnv( // gateway URL from its own platform URL and rebases exactly these providers. // Only configured keys are withheld; credentials for disabled providers are // filtered before this point and never flow to the task runtime. + // Free-trial fallback keys count as configured for gateway coverage and + // control-plane resolution, but only when the primary name is configured + // nowhere, matching `resolveModelProviderEnvValue`. + const resolveTrialFallbackValue = (name: string): string | undefined => { + const trialEnvVarName = getTrialModelProviderEnvVarName(name); + + return trialEnvVarName + ? normalizeConfiguredValue(runtimeEnv[trialEnvVarName]) + : undefined; + }; const gatewayServedKeyNames = inferenceGateway ? gatewayProviderKeyNames.filter( (name) => isInferenceGatewayCoveredEnvVar(name) && (normalizeConfiguredValue(runtimeEnv[name]) !== undefined || - normalizeConfiguredValue(persistedEnvVars[name]) !== undefined), + normalizeConfiguredValue(persistedEnvVars[name]) !== undefined || + resolveTrialFallbackValue(name) !== undefined), ) : []; const gatewayServedKeyNameSet = new Set(gatewayServedKeyNames); @@ -447,7 +473,8 @@ async function resolveModelRuntimeEnv( const value = normalizeConfiguredValue(runtimeEnv[envVarName]) ?? - normalizeConfiguredValue(persistedEnvVars[envVarName]); + normalizeConfiguredValue(persistedEnvVars[envVarName]) ?? + resolveTrialFallbackValue(envVarName); return value ? [[envVarName, value]] : []; }), diff --git a/packages/env/src/index.ts b/packages/env/src/index.ts index 853671e1d..c32114343 100644 --- a/packages/env/src/index.ts +++ b/packages/env/src/index.ts @@ -364,6 +364,14 @@ const serverSchema = { // an OpenAI key for something else never silently re-points an existing // 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. + R_TRIAL_OPENROUTER_API_KEY: z.string().min(1).optional(), // Shared secret between this deployment and its Brain container, so the // Brain can reach /api/brain/inference without holding a provider key of // its own. It is the Brain's whole credential: the real provider key stays @@ -515,6 +523,7 @@ const OPTIONAL_NON_EMPTY_KEYS = new Set([ 'R_GBRAIN_ADMIN_TOKEN_FILE', 'R_BRAIN_OPENROUTER_API_KEY', 'R_BRAIN_OPENAI_API_KEY', + 'R_TRIAL_OPENROUTER_API_KEY', 'R_BRAIN_GATEWAY_TOKEN', 'R_BRAIN_GATEWAY_TOKEN_FILE', 'R_BRAIN_MODEL', diff --git a/packages/types/src/model-provider-config.test.ts b/packages/types/src/model-provider-config.test.ts index efd1ed453..f75e0bdd0 100644 --- a/packages/types/src/model-provider-config.test.ts +++ b/packages/types/src/model-provider-config.test.ts @@ -1054,6 +1054,30 @@ describe('buildRecommendedDeploymentModelConfig', () => { }, ); + it('builds the openrouter Efficient preset on the inexpensive model for every role', () => { + expect( + buildRecommendedDeploymentModelConfig( + getSetupModelProvider('openrouter'), + 'efficient', + ), + ).toEqual({ + roomoteModel: 'openrouter/openai/gpt-5.6-luna', + roomoteOrchestrationModel: null, + roomoteSmallModel: 'openrouter/openai/gpt-5.6-luna', + roomoteVisionModel: null, + roomoteCodeReviewModel: 'openrouter/openai/gpt-5.6-luna', + roomoteExploreModel: 'openrouter/openai/gpt-5.6-luna', + roomotePlanningModel: 'openrouter/openai/gpt-5.6-luna', + roomoteModelReasoningEffort: 'medium', + roomoteOrchestrationModelReasoningEffort: null, + roomoteSmallModelReasoningEffort: 'low', + roomoteVisionModelReasoningEffort: null, + roomoteCodeReviewModelReasoningEffort: 'medium', + roomoteExploreModelReasoningEffort: 'low', + roomotePlanningModelReasoningEffort: 'medium', + }); + }); + it('maps the provider default to coding and recommended models to their roles', () => { expect( buildRecommendedDeploymentModelConfig(getSetupModelProvider('anthropic')), @@ -1553,6 +1577,60 @@ describe('buildSetupModelStatus', () => { expect(status.setupSatisfied).toBe(true); }); + it('satisfies openrouter through the free-trial fallback key and flags it', () => { + const status = buildSetupModelStatus({ + runtimeEnv: { + R_TRIAL_OPENROUTER_API_KEY: 'sk-trial', + }, + persistedModelConfig: { + roomoteModel: 'openrouter/openai/gpt-5.6-luna', + roomoteSmallModel: null, + roomoteVisionModel: null, + }, + persistedEnvVarNames: [], + }); + + expect(status.setupSatisfied).toBe(true); + expect( + status.providers.find((provider) => provider.id === 'openrouter'), + ).toMatchObject({ + runtimeApiKeySatisfied: true, + savedApiKeySatisfied: false, + trialKeySatisfied: true, + }); + }); + + it('does not flag the trial when a real openrouter key is configured', () => { + const runtimeStatus = buildSetupModelStatus({ + runtimeEnv: { + OPENROUTER_API_KEY: 'sk-runtime', + R_TRIAL_OPENROUTER_API_KEY: 'sk-trial', + }, + persistedEnvVarNames: [], + }); + + expect( + runtimeStatus.providers.find((provider) => provider.id === 'openrouter'), + ).toMatchObject({ + runtimeApiKeySatisfied: true, + trialKeySatisfied: false, + }); + + const savedStatus = buildSetupModelStatus({ + runtimeEnv: { + R_TRIAL_OPENROUTER_API_KEY: 'sk-trial', + }, + persistedEnvVarNames: ['OPENROUTER_API_KEY'], + }); + + expect( + savedStatus.providers.find((provider) => provider.id === 'openrouter'), + ).toMatchObject({ + savedApiKeySatisfied: true, + trialKeySatisfied: false, + }); + }); + it('resolves the vercel provider from a runtime AI Gateway model id', () => { const status = buildSetupModelStatus({ runtimeEnv: { diff --git a/packages/types/src/model-provider-config.ts b/packages/types/src/model-provider-config.ts index 16a3372a9..c01efd80c 100644 --- a/packages/types/src/model-provider-config.ts +++ b/packages/types/src/model-provider-config.ts @@ -470,6 +470,32 @@ export const SETUP_MODEL_PROVIDER_CATALOG = [ }, }, }, + { + id: 'efficient', + label: 'Efficient', + roles: { + coding: { + modelId: 'openrouter/openai/gpt-5.6-luna', + reasoningEffort: 'medium', + }, + helper: { + modelId: 'openrouter/openai/gpt-5.6-luna', + reasoningEffort: 'low', + }, + codeReview: { + modelId: 'openrouter/openai/gpt-5.6-luna', + reasoningEffort: 'medium', + }, + explore: { + modelId: 'openrouter/openai/gpt-5.6-luna', + reasoningEffort: 'low', + }, + planning: { + modelId: 'openrouter/openai/gpt-5.6-luna', + reasoningEffort: 'medium', + }, + }, + }, ], }, { @@ -1362,6 +1388,13 @@ export function getDefaultRecommendedModelPreset( export type SetupModelProviderStatus = SetupModelProviderDescriptor & { runtimeApiKeySatisfied: boolean; savedApiKeySatisfied: boolean; + /** + * True when `runtimeApiKeySatisfied` holds only because a free-trial + * fallback credential (`TRIAL_MODEL_PROVIDER_ENV_VAR_FALLBACKS`) is + * present. The UI keeps the connect/edit affordances in this state so an + * operator can save their own key, which then outranks the trial key. + */ + trialKeySatisfied?: boolean; additionalEnvValues: Record; }; @@ -1785,6 +1818,39 @@ export function isConfiguredEnvValue( return normalizeOptionalString(value) !== null; } +/** + * Free-trial fallback credentials, keyed by the provider credential they + * stand in for. A hosting provisioner can inject a capped, Roomote-minted + * key under the fallback name; it acts as the provider's credential only + * when the primary name is configured nowhere (runtime env or saved), so + * connecting a real key always wins. Runtime-env only by design: the values + * are never persisted, never editable in Settings, and stay on the control + * plane like any other inference-gateway-served key. + */ +export const TRIAL_MODEL_PROVIDER_ENV_VAR_FALLBACKS: Readonly< + Partial> +> = { + OPENROUTER_API_KEY: 'R_TRIAL_OPENROUTER_API_KEY', +}; + +export function getTrialModelProviderEnvVarName( + primaryEnvVarName: string, +): string | undefined { + return TRIAL_MODEL_PROVIDER_ENV_VAR_FALLBACKS[primaryEnvVarName]; +} + +export function isTrialModelProviderEnvVarConfigured( + primaryEnvVarName: string, + runtimeEnv: Partial>, +): boolean { + const trialEnvVarName = getTrialModelProviderEnvVarName(primaryEnvVarName); + + return ( + trialEnvVarName !== undefined && + isConfiguredEnvValue(runtimeEnv[trialEnvVarName]) + ); +} + /** Legacy Google Vertex credential name, reserved and stripped while the provider is disabled. */ const GOOGLE_APPLICATION_CREDENTIALS_ENV_VAR_NAME = 'GOOGLE_APPLICATION_CREDENTIALS'; @@ -1966,6 +2032,7 @@ export function buildSetupModelStatus(input: { additionalEnvValues: {}, runtimeApiKeySatisfied: false, savedApiKeySatisfied: oauthConnected, + trialKeySatisfied: false, }; } @@ -1977,8 +2044,11 @@ export function buildSetupModelStatus(input: { const requiredEnvVarNames = getSetupModelProviderRequiredEnvVarNames(provider); const hasRequiredEnvVars = requiredEnvVarNames.length > 0; + // A free-trial fallback credential satisfies its primary name at runtime + // so setup skips the inference step and the provider reads as connected. const isRuntimeConfigured = (name: string) => - isConfiguredEnvValue(runtimeEnv[name]); + isConfiguredEnvValue(runtimeEnv[name]) || + isTrialModelProviderEnvVarConfigured(name, runtimeEnv); const isPersisted = (name: string) => persistedEnvVarNameSet.has(name); const additionalEnvValues = Object.fromEntries( [ @@ -2014,12 +2084,21 @@ export function buildSetupModelStatus(input: { (name) => isPersisted(name) || isRuntimeConfigured(name), ) && requiredEnvVarNames.some(isPersisted); + const trialKeySatisfied = + runtimeApiKeySatisfied && + !savedApiKeySatisfied && + requiredEnvVarNames.some( + (name) => + !isConfiguredEnvValue(runtimeEnv[name]) && + isTrialModelProviderEnvVarConfigured(name, runtimeEnv), + ); return { ...provider, additionalEnvValues, runtimeApiKeySatisfied, savedApiKeySatisfied, + trialKeySatisfied, }; }; From 9959c1675144ab13ba40ddf5dd4743a42df40957 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:33:14 -0400 Subject: [PATCH 02/17] fix: address review on trial-key reservation, seeding guards, and test hermeticity - Reserve R_TRIAL_OPENROUTER_API_KEY (via TRIAL_MODEL_PROVIDER_ENV_VAR_FALLBACKS) in CONTROL_PLANE_ENV_VAR_NAMES so the generic environment editor rejects it and the job env-injection denylist strips a persisted copy from sandboxes - Take a FOR UPDATE lock on the deployment_settings row (inserting the bare row first if missing) so trial seeding cannot overwrite a concurrent operator save - Treat any role-model env override (R_SMALL_MODEL, R_PLANNING_MODEL, ...) as an operator model choice that blocks seeding, not just R_MODEL - Make the seeding tests hermetic: stub all role-model and provider credential env vars so host shells with R_MODEL or provider keys set cannot change outcomes --- .../src/trpc/commands/setup-new/index.test.ts | 49 +++++++++++++++++-- apps/web/src/trpc/commands/setup-new/index.ts | 25 +++++++++- packages/types/src/control-plane-env-vars.ts | 13 ++++- 3 files changed, 82 insertions(+), 5 deletions(-) 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 19e548548..d68b840e8 100644 --- a/apps/web/src/trpc/commands/setup-new/index.test.ts +++ b/apps/web/src/trpc/commands/setup-new/index.test.ts @@ -263,6 +263,9 @@ import { resetTrialModelConfigSeedCheckForTests, } from './index'; import { + DEFAULT_MODEL_PROVIDER_CREDENTIAL_ENV_VAR_NAMES, + TASK_MODEL_ROLE_DESCRIPTORS, + TASK_MODEL_ROLES, WORKER_RUNTIME_SCHEMA_VERSION, type SetupNewState, } from '@roomote/types'; @@ -1344,13 +1347,27 @@ describe('setup recommendation commands', () => { describe('ensureTrialModelConfigSeeded', () => { function createTxStub(row: Record) { + // Records only the config upserts; the bare row insert that backs the + // FOR UPDATE lock carries neither field and stays out of assertions. const inserted: Array> = []; const tx = { - select: vi.fn(() => createSelectChain([row])), + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + limit: vi.fn(async () => [row]), + for: vi.fn(async () => [row]), + })), + })), + })), insert: vi.fn(() => ({ values: vi.fn((values: Record) => { - inserted.push(values); - return { onConflictDoUpdate: vi.fn(async () => undefined) }; + if ('runtimeModelConfig' in values || 'taskModelSettings' in values) { + inserted.push(values); + } + return { + onConflictDoUpdate: vi.fn(async () => undefined), + onConflictDoNothing: vi.fn(async () => undefined), + }; }), })), }; @@ -1363,6 +1380,15 @@ describe('ensureTrialModelConfigSeeded', () => { vi.unstubAllEnvs(); resetTrialModelConfigSeedCheckForTests(); mockGetPersistedEnvironmentVariableNames.mockResolvedValue([]); + // Hermetic against the host environment: a developer or CI shell with + // role models or provider keys set must not change these outcomes. + for (const role of TASK_MODEL_ROLES) { + vi.stubEnv(TASK_MODEL_ROLE_DESCRIPTORS[role].modelEnvVar, ''); + } + for (const name of DEFAULT_MODEL_PROVIDER_CREDENTIAL_ENV_VAR_NAMES) { + vi.stubEnv(name, ''); + } + vi.stubEnv('R_TRIAL_OPENROUTER_API_KEY', ''); }); afterEach(() => { @@ -1423,6 +1449,23 @@ describe('ensureTrialModelConfigSeeded', () => { expect(inserted).toEqual([]); }); + it('treats any role-model env override as an operator model choice', async () => { + vi.stubEnv('R_TRIAL_OPENROUTER_API_KEY', 'sk-trial'); + vi.stubEnv('R_PLANNING_MODEL', 'anthropic/claude-opus-5'); + const { tx, inserted } = createTxStub({ + setupNewState: {}, + runtimeModelConfig: null, + taskModelSettings: null, + }); + mockDbTransaction.mockImplementation( + async (callback: (tx: unknown) => Promise) => callback(tx), + ); + + await ensureTrialModelConfigSeeded(); + + expect(inserted).toEqual([]); + }); + it('never overwrites existing model choices', async () => { vi.stubEnv('R_TRIAL_OPENROUTER_API_KEY', 'sk-trial'); const { tx, inserted } = createTxStub({ diff --git a/apps/web/src/trpc/commands/setup-new/index.ts b/apps/web/src/trpc/commands/setup-new/index.ts index 74235a2fb..1a9176aa2 100644 --- a/apps/web/src/trpc/commands/setup-new/index.ts +++ b/apps/web/src/trpc/commands/setup-new/index.ts @@ -80,6 +80,8 @@ import { isRequiredComputeField, normalizeTaskModelSettings, DEFAULT_TASK_MODEL_SETTINGS, + TASK_MODEL_ROLE_DESCRIPTORS, + TASK_MODEL_ROLES, NON_SECRET_AUTH_ENV_VAR_NAMES, NON_SECRET_COMPUTE_ENV_VAR_NAMES, NON_SECRET_SOURCE_CONTROL_ENV_VAR_NAMES, @@ -322,6 +324,19 @@ export async function ensureTrialModelConfigSeeded(): Promise { try { await db.transaction(async (tx) => { + // Serialize against concurrent configuration writes: every check below + // must observe the row state the seed upserts will replace, or an + // operator's save landing between read and write would be overwritten. + await tx + .insert(deploymentSettings) + .values({ id: 'default' }) + .onConflictDoNothing(); + await tx + .select({ id: deploymentSettings.id }) + .from(deploymentSettings) + .where(eq(deploymentSettings.id, 'default')) + .for('update'); + const [ currentState, persistedModelConfig, @@ -352,8 +367,16 @@ export async function ensureTrialModelConfigSeeded(): Promise { githubCopilotConnected, xaiSubscriptionConnected, }); + // Any role-model env override (R_MODEL, R_PLANNING_MODEL, …) is an + // operator model choice: seeding around it would persist config that + // unexpectedly activates once the override is removed. + const hasRuntimeRoleModelOverride = TASK_MODEL_ROLES.some((role) => + isConfiguredEnvValue( + process.env[TASK_MODEL_ROLE_DESCRIPTORS[role].modelEnvVar], + ), + ); const hasOperatorProvider = - status.runtimeRoomoteModelSatisfied || + hasRuntimeRoleModelOverride || status.providers.some( (provider) => provider.savedApiKeySatisfied || diff --git a/packages/types/src/control-plane-env-vars.ts b/packages/types/src/control-plane-env-vars.ts index 11d58fc6a..abb9eec70 100644 --- a/packages/types/src/control-plane-env-vars.ts +++ b/packages/types/src/control-plane-env-vars.ts @@ -2,7 +2,10 @@ import { COMMS_PROVIDER_ENV_VAR_NAMES } from './setup-auth-config'; import { COMPUTE_PROVIDER_ENV_VAR_NAMES } from './setup-compute-config'; import { SETUP_SOURCE_CONTROL_PROVIDER_CATALOG } from './setup-source-control-config'; import { OPENCODE_AUTH_CONTENT_ENV_VAR_NAME } from './chatgpt-subscription'; -import { DISABLED_MODEL_PROVIDER_ENV_VAR_NAMES } from './model-provider-config'; +import { + DISABLED_MODEL_PROVIDER_ENV_VAR_NAMES, + TRIAL_MODEL_PROVIDER_ENV_VAR_FALLBACKS, +} from './model-provider-config'; /** * Per-repo source-control access tokens. A task legitimately receives the @@ -148,6 +151,14 @@ export const CONTROL_PLANE_ENV_VAR_NAMES: ReadonlySet = new Set( ...MEDIA_PROVIDER_ENV_VAR_NAMES, ...DECLARATIVE_ENVIRONMENT_ENV_VAR_NAMES, ...DISABLED_MODEL_PROVIDER_ENV_VAR_NAMES, + // Free-trial fallback provider keys are injected by the hosting + // platform's deployment environment and served through the inference + // gateway; a persisted copy would flow into sandboxes through the + // generic env path, so the names are reserved from the editor and + // stripped from job env like the other control-plane credentials. + ...Object.values(TRIAL_MODEL_PROVIDER_ENV_VAR_FALLBACKS).filter( + (name): name is string => typeof name === 'string', + ), ], ); From a136a94acb6fe23570f982ad14798427221c52cc Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:40:53 -0400 Subject: [PATCH 03/17] fix: let the Efficient preset inherit the shared per-role reasoning defaults The preset previously pinned codeReview and planning to medium while the shared role defaults are high. Leaving the efforts unset means the runtime applies the same per-role defaults as a hand-configured model, and the preset can never drift from them. --- .../types/src/model-provider-config.test.ts | 11 ++++---- packages/types/src/model-provider-config.ts | 27 +++++-------------- 2 files changed, 13 insertions(+), 25 deletions(-) diff --git a/packages/types/src/model-provider-config.test.ts b/packages/types/src/model-provider-config.test.ts index f75e0bdd0..242d36a62 100644 --- a/packages/types/src/model-provider-config.test.ts +++ b/packages/types/src/model-provider-config.test.ts @@ -1055,6 +1055,7 @@ describe('buildRecommendedDeploymentModelConfig', () => { ); it('builds the openrouter Efficient preset on the inexpensive model for every role', () => { + // Reasoning efforts stay null so the shared per-role defaults apply. expect( buildRecommendedDeploymentModelConfig( getSetupModelProvider('openrouter'), @@ -1068,13 +1069,13 @@ describe('buildRecommendedDeploymentModelConfig', () => { roomoteCodeReviewModel: 'openrouter/openai/gpt-5.6-luna', roomoteExploreModel: 'openrouter/openai/gpt-5.6-luna', roomotePlanningModel: 'openrouter/openai/gpt-5.6-luna', - roomoteModelReasoningEffort: 'medium', + roomoteModelReasoningEffort: null, roomoteOrchestrationModelReasoningEffort: null, - roomoteSmallModelReasoningEffort: 'low', + roomoteSmallModelReasoningEffort: null, roomoteVisionModelReasoningEffort: null, - roomoteCodeReviewModelReasoningEffort: 'medium', - roomoteExploreModelReasoningEffort: 'low', - roomotePlanningModelReasoningEffort: 'medium', + roomoteCodeReviewModelReasoningEffort: null, + roomoteExploreModelReasoningEffort: null, + roomotePlanningModelReasoningEffort: null, }); }); diff --git a/packages/types/src/model-provider-config.ts b/packages/types/src/model-provider-config.ts index c01efd80c..9a930cb13 100644 --- a/packages/types/src/model-provider-config.ts +++ b/packages/types/src/model-provider-config.ts @@ -473,27 +473,14 @@ 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', - reasoningEffort: 'medium', - }, - helper: { - modelId: 'openrouter/openai/gpt-5.6-luna', - reasoningEffort: 'low', - }, - codeReview: { - modelId: 'openrouter/openai/gpt-5.6-luna', - reasoningEffort: 'medium', - }, - explore: { - modelId: 'openrouter/openai/gpt-5.6-luna', - reasoningEffort: 'low', - }, - planning: { - modelId: 'openrouter/openai/gpt-5.6-luna', - reasoningEffort: 'medium', - }, + 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' }, }, }, ], From c41748d6758566934baa7f75ba319187db517074 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:52:57 -0400 Subject: [PATCH 04/17] feat: make free-trial inference an explicit onboarding choice The inference step no longer auto-skips on trial deployments. Instead of seeding on the first setup-status read, the step now leads with a 'Start with free credits' card (rendered when a provider is trial-key satisfied) next to the existing connect-your-own-provider flow. - new setupNew.chooseTrialInference mutation: applies the Efficient preset and records the provider choice inside the same FOR UPDATE-locked transaction; no-ops once any inference choice exists and refuses when a real provider is already connected - getSetupNewStatusCommand no longer seeds; a fresh trial deployment has no model config, so the step renders naturally until a choice is made - trial-satisfied providers no longer present as configured-via-env in the step, so picking OpenRouter from the list asks for a real key - tests: command guards, trial card render/click, editable key field --- .../StepInferenceProvider.client.test.tsx | 81 ++++++- .../setup/StepInferenceProvider.tsx | 59 ++++- .../src/trpc/commands/setup-new/index.test.ts | 55 ++--- apps/web/src/trpc/commands/setup-new/index.ts | 211 +++++++++--------- apps/web/src/trpc/routers/_app.ts | 5 + 5 files changed, 273 insertions(+), 138 deletions(-) diff --git a/apps/web/src/app/(onboarding)/setup/StepInferenceProvider.client.test.tsx b/apps/web/src/app/(onboarding)/setup/StepInferenceProvider.client.test.tsx index 2bdfb7062..ebc6b4baa 100644 --- a/apps/web/src/app/(onboarding)/setup/StepInferenceProvider.client.test.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepInferenceProvider.client.test.tsx @@ -9,8 +9,9 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import type { SetupModelProviderId, SetupModelStatus } from '@roomote/types'; import { toast } from 'sonner'; -const { mutateAsyncMock } = vi.hoisted(() => ({ +const { mutateAsyncMock, mutateMock } = vi.hoisted(() => ({ mutateAsyncMock: vi.fn(), + mutateMock: vi.fn(), })); vi.mock('sonner', () => ({ @@ -26,6 +27,9 @@ vi.mock('@/trpc/client', () => ({ saveModelConfig: { mutationOptions: (options: Record) => options, }, + chooseTrialInference: { + mutationOptions: (options: Record) => options, + }, status: { queryKey: () => ['setupNew.status'], }, @@ -110,6 +114,8 @@ vi.mock('@/components/system', () => ({ {children} ), + Card: ({ children }: { children: ReactNode }) =>
{children}
, + CardContent: ({ children }: { children: ReactNode }) =>
{children}
, Check: (props: SVGProps) => , Lock: (props: SVGProps) => , Input: ({ @@ -272,9 +278,11 @@ function buildModelSetup( function setupMutationMock() { mutateAsyncMock.mockReset(); mutateAsyncMock.mockResolvedValue(undefined); + mutateMock.mockReset(); mockUseMutation.mockReturnValue({ mutateAsync: mutateAsyncMock, + mutate: mutateMock, isPending: false, } as unknown as ReturnType); } @@ -781,3 +789,74 @@ describe('StepInferenceProvider ChatGPT subscription', () => { expect(onContinue).toHaveBeenCalled(); }); }); + +describe('StepInferenceProvider free trial', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseQueryClient.mockReturnValue({ + invalidateQueries: vi.fn(), + } as unknown as ReturnType); + setupMutationMock(); + setupQueryMocks({ chatgptConnected: false }); + }); + + function trialOpenrouterProviderStatus(): SetupModelStatus['providers'][number] { + return { + ...openrouterProviderStatus(), + runtimeApiKeySatisfied: true, + trialKeySatisfied: true, + }; + } + + it('offers free credits when a trial key is available and starts the trial on click', () => { + render( + , + ); + + fireEvent.click( + screen.getByRole('button', { name: 'Start with free credits' }), + ); + + expect(mutateMock).toHaveBeenCalled(); + }); + + it('does not offer free credits without a trial key', () => { + render( + , + ); + + expect( + screen.queryByRole('button', { name: 'Start with free credits' }), + ).not.toBeInTheDocument(); + }); + + it('keeps the API key field editable for a trial-satisfied provider', () => { + render( + , + ); + + selectProvider('openrouter'); + + const input = screen.getByPlaceholderText('API key for OpenRouter'); + expect(input).toBeEnabled(); + }); +}); diff --git a/apps/web/src/app/(onboarding)/setup/StepInferenceProvider.tsx b/apps/web/src/app/(onboarding)/setup/StepInferenceProvider.tsx index 0b77dc861..08a2ff74d 100644 --- a/apps/web/src/app/(onboarding)/setup/StepInferenceProvider.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepInferenceProvider.tsx @@ -23,6 +23,8 @@ import { useTRPC } from '@/trpc/client'; import { ArrowRight, Button, + Card, + CardContent, Check, Input, Lock, @@ -149,6 +151,19 @@ export function StepInferenceProvider({ }, }), ); + const chooseTrialInference = useMutation( + trpc.setupNew.chooseTrialInference.mutationOptions({ + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: trpc.setupNew.status.queryKey(), + }); + onContinue(); + }, + onError: (error) => { + toast.error(error.message); + }, + }), + ); const discoverProviderModels = useMutation( trpc.taskModels.discoverProviderModels.mutationOptions(), ); @@ -212,10 +227,20 @@ export function StepInferenceProvider({ const xaiSubscriptionConnected = Boolean( modelSetup.xaiSubscriptionConnected || xaiStatus?.connected, ); + // A free-trial fallback key must not present the provider as configured + // via env: picking it from the list means connecting a real credential. const hasRuntimeProviderKey = - selectedProviderStatus?.runtimeApiKeySatisfied === true; + selectedProviderStatus?.runtimeApiKeySatisfied === true && + selectedProviderStatus?.trialKeySatisfied !== true; const hasSavedProviderKey = selectedProviderStatus?.savedApiKeySatisfied === true; + const trialInferenceAvailable = useMemo( + () => + modelSetup.providers.some( + (provider) => provider.trialKeySatisfied === true, + ), + [modelSetup.providers], + ); const primaryCredentialLabel = selectedProviderStatus?.envVarLabel ?? 'API key'; const additionalEnvFields = selectedProviderStatus?.additionalEnvFields ?? []; @@ -258,6 +283,7 @@ export function StepInferenceProvider({ requiresConnectionName && connectionName.trim().length === 0; const isActionDisabled = saveModelConfig.isPending || + chooseTrialInference.isPending || discoverProviderModels.isPending || qualifyProviderModel.isPending || selectedProvider === null || @@ -358,6 +384,37 @@ export function StepInferenceProvider({

+ {trialInferenceAvailable ? ( + <> + + +
+

+ Start with free credits +

+

+ Your first tasks are on us, running on an efficient model. You + can connect your own provider anytime from Settings. +

+
+ +
+
+

+ Or connect your own provider: +

+ + ) : null} +
> = {}) { } as unknown as ReturnType); } +function trialModelSetup(overrides: Partial> = {}) { + return { + runtimeRoomoteModel: null, + runtimeRoomoteModelSatisfied: false, + runtimeProviderId: 'openrouter', + persistedRoomoteModel: null, + persistedProviderId: null, + preselectedProvider: 'openrouter', + setupSatisfied: true, + setupSatisfiedByRuntimeEnv: true, + chatgptConnected: false, + providers: [ + { + id: 'openrouter', + label: 'OpenRouter', + runtimeApiKeySatisfied: true, + savedApiKeySatisfied: false, + trialKeySatisfied: true, + }, + ], + ...overrides, + }; +} + function mockReadyForRepository({ onboardingTaskId = null, selectedRepositoryIds = [], @@ -292,6 +316,67 @@ describe('useSetupFlow', () => { expect(result.current.step).toBe('env-vars'); }); + it('shows the inference choice before provider configuration when trial inference is available', async () => { + mockStatus({ modelSetup: trialModelSetup() }); + + const { result } = renderHook(() => useSetupFlow()); + + await waitFor(() => { + expect(result.current.step).toBe('welcome'); + }); + + act(() => { + result.current.goToNextStep(); + }); + + expect(result.current.step).toBe('inference'); + }); + + it('returns from custom provider configuration to the trial choice', async () => { + markSetupWelcomeSeen(); + mockStatus({ modelSetup: trialModelSetup() }); + + const { result } = renderHook(() => useSetupFlow()); + + await waitFor(() => { + expect(result.current.step).toBe('inference'); + }); + + act(() => { + result.current.goToStep('env-vars', { revisit: true }); + }); + expect(result.current.step).toBe('env-vars'); + + act(() => { + result.current.goToPreviousStep(); + }); + expect(result.current.step).toBe('inference'); + }); + + it('skips custom provider configuration after trial inference is chosen', async () => { + markSetupWelcomeSeen(); + mockStatus({ + modelSetup: trialModelSetup(), + setupNewState: { + authProvider: null, + modelProvider: 'openrouter', + computeProvider: null, + sourceControlProvider: null, + selectedRepositoryIds: [], + onboardingTaskId: null, + onboardingTaskStartedAt: null, + slackChannel: null, + slackThreadTs: null, + }, + }); + + const { result } = renderHook(() => useSetupFlow()); + + await waitFor(() => { + expect(result.current.step).toBe('source-control-provider'); + }); + }); + it('skips the wizard welcome when the bootstrap flow already showed it', async () => { // The signed-out bootstrap flow marks the welcome screen as seen when // "Get started" is clicked; after account creation the signed-in wizard diff --git a/apps/web/src/app/(onboarding)/setup/hooks.ts b/apps/web/src/app/(onboarding)/setup/hooks.ts index a3d0193cf..617a25ac8 100644 --- a/apps/web/src/app/(onboarding)/setup/hooks.ts +++ b/apps/web/src/app/(onboarding)/setup/hooks.ts @@ -47,6 +47,7 @@ const PINNABLE_SETUP_STEPS: readonly SetupStep[] = [ 'auth-provider', 'auth-env-vars', 'slack', + 'inference', 'env-vars', 'source-control-provider', 'source-control-config', @@ -393,6 +394,24 @@ export function useSetupFlow( )?.setupSatisfied ?? false) ); + case 'inference': { + const trialInferenceAvailable = status.modelSetup.providers?.some( + (provider) => provider.trialKeySatisfied === true, + ); + const operatorProviderConfigured = status.modelSetup.providers?.some( + (provider) => + provider.savedApiKeySatisfied || + (provider.runtimeApiKeySatisfied && !provider.trialKeySatisfied), + ); + + return ( + !trialInferenceAvailable || + operatorProviderConfigured || + status.modelSetup.runtimeRoomoteModelSatisfied || + status.modelSetup.persistedRoomoteModel !== null || + status.setupNewState.modelProvider !== null + ); + } case 'env-vars': return status.modelSetup.setupSatisfied; case 'source-control-provider': diff --git a/apps/web/src/app/(onboarding)/setup/setup-docs.ts b/apps/web/src/app/(onboarding)/setup/setup-docs.ts index 12a657694..3b54101e5 100644 --- a/apps/web/src/app/(onboarding)/setup/setup-docs.ts +++ b/apps/web/src/app/(onboarding)/setup/setup-docs.ts @@ -18,6 +18,7 @@ const SETUP_DOC_PATHS: Record = { 'auth-provider': 'communications', 'auth-env-vars': 'communications', slack: 'providers/communications/slack', + inference: 'models', 'env-vars': 'models', 'source-control-provider': 'source-control', 'source-control-config': 'source-control', diff --git a/apps/web/src/app/(onboarding)/setup/types.test.ts b/apps/web/src/app/(onboarding)/setup/types.test.ts index 7b88bbb05..d8f84c062 100644 --- a/apps/web/src/app/(onboarding)/setup/types.test.ts +++ b/apps/web/src/app/(onboarding)/setup/types.test.ts @@ -8,6 +8,7 @@ describe('getSetupSteps', () => { expect(new Set(emailPasswordSteps)).toEqual(new Set(SETUP_STEPS)); expect(emailPasswordSteps).toEqual([ 'welcome', + 'inference', 'env-vars', 'source-control-provider', 'source-control-config', @@ -24,5 +25,8 @@ describe('getSetupSteps', () => { it('uses the canonical order when communication handled authentication', () => { expect(getSetupSteps(true)).toBe(SETUP_STEPS); + expect(SETUP_STEPS.indexOf('inference')).toBe( + SETUP_STEPS.indexOf('env-vars') - 1, + ); }); }); diff --git a/apps/web/src/app/(onboarding)/setup/types.ts b/apps/web/src/app/(onboarding)/setup/types.ts index f44a32690..cb5858e39 100644 --- a/apps/web/src/app/(onboarding)/setup/types.ts +++ b/apps/web/src/app/(onboarding)/setup/types.ts @@ -22,6 +22,10 @@ const SETUP_STEP_DEFINITIONS = [ id: 'slack', title: 'Connect Slack', }, + { + id: 'inference', + title: 'Configure inference', + }, { id: 'env-vars', title: 'Configure inference provider', From 2d248f581a9df98a30aa4d2399921664ace0921f Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 27 Aug 2026 13:14:10 +0000 Subject: [PATCH 07/17] fix: restore back navigation from trial inference choice --- .../(onboarding)/setup/SetupSignedInFlow.tsx | 1 + .../StepConfigureInference.client.test.tsx | 24 ++++++++++ .../setup/StepConfigureInference.tsx | 5 ++ .../(onboarding)/setup/hooks.client.test.tsx | 48 +++++++++++++++++++ 4 files changed, 78 insertions(+) diff --git a/apps/web/src/app/(onboarding)/setup/SetupSignedInFlow.tsx b/apps/web/src/app/(onboarding)/setup/SetupSignedInFlow.tsx index 219dd77b7..13f129f79 100644 --- a/apps/web/src/app/(onboarding)/setup/SetupSignedInFlow.tsx +++ b/apps/web/src/app/(onboarding)/setup/SetupSignedInFlow.tsx @@ -359,6 +359,7 @@ export function SetupSignedInFlow() { onConfigureProvider={() => goToStep('env-vars', { revisit: true }) } + onBack={canGoBack ? goToPreviousStep : undefined} /> )} {step === 'env-vars' && ( diff --git a/apps/web/src/app/(onboarding)/setup/StepConfigureInference.client.test.tsx b/apps/web/src/app/(onboarding)/setup/StepConfigureInference.client.test.tsx index f6f67453f..5fca1f2d8 100644 --- a/apps/web/src/app/(onboarding)/setup/StepConfigureInference.client.test.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepConfigureInference.client.test.tsx @@ -60,6 +60,15 @@ vi.mock('./StepTitle', () => ({ StepTitle: ({ text }: { text: string }) =>

{text}

, })); +vi.mock('./SetupFooter', () => ({ + SetupFooter: ({ onBack }: { onBack?: () => void }) => + onBack ? ( + + ) : null, +})); + const mockUseMutation = vi.mocked(useMutation); const mockUseQueryClient = vi.mocked(useQueryClient); @@ -146,4 +155,19 @@ describe('StepConfigureInference', () => { expect(onConfigureProvider).toHaveBeenCalledOnce(); expect(mutateMock).not.toHaveBeenCalled(); }); + + it('returns to the preceding setup step', () => { + const onBack = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Back' })); + + expect(onBack).toHaveBeenCalledOnce(); + }); }); diff --git a/apps/web/src/app/(onboarding)/setup/StepConfigureInference.tsx b/apps/web/src/app/(onboarding)/setup/StepConfigureInference.tsx index 00c185c6f..81f4ef142 100644 --- a/apps/web/src/app/(onboarding)/setup/StepConfigureInference.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepConfigureInference.tsx @@ -9,6 +9,7 @@ import { ArrowRight, Button, Spinner } from '@/components/system'; import { cn } from '@/lib/utils'; import { StepTitle } from './StepTitle'; +import { SetupFooter } from './SetupFooter'; import { getSetupStepDefinition } from './types'; const INFERENCE_STEP = getSetupStepDefinition('inference'); @@ -16,9 +17,11 @@ const INFERENCE_STEP = getSetupStepDefinition('inference'); export function StepConfigureInference({ onUseTrial, onConfigureProvider, + onBack, }: { onUseTrial: () => void; onConfigureProvider: () => void; + onBack?: () => void; }) { const trpc = useTRPC(); const queryClient = useQueryClient(); @@ -87,6 +90,8 @@ export function StepConfigureInference({

Roomote trial inference goes through OpenRouter.

+ +
); 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 d31aa1496..969520501 100644 --- a/apps/web/src/app/(onboarding)/setup/hooks.client.test.tsx +++ b/apps/web/src/app/(onboarding)/setup/hooks.client.test.tsx @@ -457,6 +457,54 @@ describe('useSetupFlow', () => { expect(result.current.step).toBe('source-control-provider'); }); + it('returns from the trial inference choice to communication setup', async () => { + mockStatus({ + authSetup: { + setupSatisfiedByRuntimeEnv: false, + selectedProvider: 'slack', + preselectedProvider: 'slack', + runtimeConfiguredProvider: null, + runtimeConfiguredProviders: [], + lockReason: null, + providers: [ + { + id: 'slack', + label: 'Slack', + fields: [], + runtimeSatisfied: false, + savedSatisfied: false, + setupSatisfied: false, + }, + ], + }, + modelSetup: trialModelSetup(), + setupNewState: { + authProvider: 'slack', + modelProvider: null, + selectedRepositoryIds: [], + onboardingTaskId: null, + onboardingTaskStartedAt: null, + slackChannel: null, + slackThreadTs: null, + }, + }); + + const { result } = renderHook(() => useSetupFlow()); + + await waitFor(() => { + expect(result.current.step).toBe('auth-env-vars'); + }); + + act(() => result.current.goToNextStep()); + expect(result.current.step).toBe('slack'); + + act(() => result.current.goToNextStep()); + expect(result.current.step).toBe('inference'); + + act(() => result.current.goToPreviousStep()); + expect(result.current.step).toBe('slack'); + }); + it('offers communication setup after source control for email/password auth', async () => { markSetupWelcomeSeen(); mockStatus(); From f49cc91408e42c53fde5b374fe1f25fc10d14004 Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Thu, 27 Aug 2026 15:29:51 +0100 Subject: [PATCH 08/17] Tweaks --- apps/docs/environment-variables.mdx | 1 - apps/web/src/app/(onboarding)/setup/StepConfigureInference.tsx | 2 -- 2 files changed, 3 deletions(-) diff --git a/apps/docs/environment-variables.mdx b/apps/docs/environment-variables.mdx index 50e606b43..3d546b22e 100644 --- a/apps/docs/environment-variables.mdx +++ b/apps/docs/environment-variables.mdx @@ -187,7 +187,6 @@ as per-task auth tokens or workspace paths. | `R_PLANNING_MODEL_REASONING_EFFORT` | Optional | Reasoning level for the advisor model. | | `R_MODEL_ENV_KEYS` | Optional | Comma- or space-separated list of extra provider key env vars to forward to task workers. | | `OPENROUTER_API_KEY` | Provider key | OpenRouter API key. Can also be saved from **Settings > Models**. | -| `R_TRIAL_OPENROUTER_API_KEY` | Optional | Fallback OpenRouter key a hosting platform can inject for free-trial credits. Used only while no `OPENROUTER_API_KEY` is configured (environment or saved), so connecting your own key always wins. | | `REQUESTY_API_KEY` | Provider key | Requesty API key. Can also be saved from **Settings > Models**. | | `AI_GATEWAY_API_KEY` | Provider key | Vercel AI Gateway API key. | | `BASETEN_API_KEY` | Provider key | Baseten API key. | diff --git a/apps/web/src/app/(onboarding)/setup/StepConfigureInference.tsx b/apps/web/src/app/(onboarding)/setup/StepConfigureInference.tsx index 81f4ef142..0b66e8454 100644 --- a/apps/web/src/app/(onboarding)/setup/StepConfigureInference.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepConfigureInference.tsx @@ -55,7 +55,6 @@ export function StepConfigureInference({

- Roomote trial inference goes through OpenRouter. + Roomote inference includes a few free credits when it is available.

diff --git a/apps/web/src/app/(onboarding)/setup/StepInferenceProvider.client.test.tsx b/apps/web/src/app/(onboarding)/setup/StepInferenceProvider.client.test.tsx index 6ff45f598..5a5bf22dc 100644 --- a/apps/web/src/app/(onboarding)/setup/StepInferenceProvider.client.test.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepInferenceProvider.client.test.tsx @@ -785,7 +785,7 @@ describe('StepInferenceProvider ChatGPT subscription', () => { }); }); -describe('StepInferenceProvider trial fallback', () => { +describe('StepInferenceProvider managed Roomote inference', () => { beforeEach(() => { vi.clearAllMocks(); mockUseQueryClient.mockReturnValue({ @@ -795,20 +795,21 @@ describe('StepInferenceProvider trial fallback', () => { setupQueryMocks({ chatgptConnected: false }); }); - function trialOpenrouterProviderStatus(): SetupModelStatus['providers'][number] { + function managedRoomoteProviderStatus(): SetupModelStatus['providers'][number] { return { ...openrouterProviderStatus(), + id: 'roomote', + label: 'Roomote inference', runtimeApiKeySatisfied: true, - trialKeySatisfied: true, }; } - it('keeps the API key field editable for a trial-satisfied provider', () => { + it('does not offer managed Roomote inference as a selectable connection', () => { render( { />, ); - selectProvider('openrouter'); - - const input = screen.getByPlaceholderText('API key for OpenRouter'); - expect(input).toBeEnabled(); + expect( + screen.queryByRole('button', { name: /roomote inference/i }), + ).not.toBeInTheDocument(); }); }); diff --git a/apps/web/src/app/(onboarding)/setup/StepInferenceProvider.tsx b/apps/web/src/app/(onboarding)/setup/StepInferenceProvider.tsx index c6aefd04c..f3c193552 100644 --- a/apps/web/src/app/(onboarding)/setup/StepInferenceProvider.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepInferenceProvider.tsx @@ -11,6 +11,7 @@ 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, @@ -212,11 +213,8 @@ export function StepInferenceProvider({ const xaiSubscriptionConnected = Boolean( modelSetup.xaiSubscriptionConnected || xaiStatus?.connected, ); - // A free-trial fallback key must not present the provider as configured - // via env: picking it from the list means connecting a real credential. const hasRuntimeProviderKey = - selectedProviderStatus?.runtimeApiKeySatisfied === true && - selectedProviderStatus?.trialKeySatisfied !== true; + selectedProviderStatus?.runtimeApiKeySatisfied === true; const hasSavedProviderKey = selectedProviderStatus?.savedApiKeySatisfied === true; const primaryCredentialLabel = @@ -226,9 +224,12 @@ export function StepInferenceProvider({ // connected, so the picker only offers providers that can be selected. const sortedModelProviders = useMemo( () => - [...modelSetup.providers].sort((left, right) => - left.label.localeCompare(right.label), - ), + modelSetup.providers + .filter( + (provider) => + !provider.hidden && provider.id !== ROOMOTE_INFERENCE_PROVIDER_ID, + ) + .sort((left, right) => left.label.localeCompare(right.label)), [modelSetup.providers], ); const shouldShowSavedValueMask = 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 969520501..92d5815f4 100644 --- a/apps/web/src/app/(onboarding)/setup/hooks.client.test.tsx +++ b/apps/web/src/app/(onboarding)/setup/hooks.client.test.tsx @@ -175,20 +175,19 @@ function trialModelSetup(overrides: Partial> = {}) { return { runtimeRoomoteModel: null, runtimeRoomoteModelSatisfied: false, - runtimeProviderId: 'openrouter', + runtimeProviderId: 'roomote', persistedRoomoteModel: null, persistedProviderId: null, - preselectedProvider: 'openrouter', + preselectedProvider: 'roomote', setupSatisfied: true, setupSatisfiedByRuntimeEnv: true, chatgptConnected: false, providers: [ { - id: 'openrouter', - label: 'OpenRouter', + id: 'roomote', + label: 'Roomote inference', runtimeApiKeySatisfied: true, savedApiKeySatisfied: false, - trialKeySatisfied: true, }, ], ...overrides, @@ -359,7 +358,7 @@ describe('useSetupFlow', () => { modelSetup: trialModelSetup(), setupNewState: { authProvider: null, - modelProvider: 'openrouter', + modelProvider: 'roomote', computeProvider: null, sourceControlProvider: null, selectedRepositoryIds: [], diff --git a/apps/web/src/app/(onboarding)/setup/hooks.ts b/apps/web/src/app/(onboarding)/setup/hooks.ts index 617a25ac8..9b7f65ae0 100644 --- a/apps/web/src/app/(onboarding)/setup/hooks.ts +++ b/apps/web/src/app/(onboarding)/setup/hooks.ts @@ -6,6 +6,7 @@ import { useMutation, useQuery } from '@tanstack/react-query'; import { getSetupNewComputeProvisioningState, isSetupProvisionableComputeProvider, + ROOMOTE_INFERENCE_PROVIDER_ID, type SetupAuthProviderId, } from '@roomote/types'; @@ -396,12 +397,15 @@ export function useSetupFlow( ); case 'inference': { const trialInferenceAvailable = status.modelSetup.providers?.some( - (provider) => provider.trialKeySatisfied === true, + (provider) => + provider.id === ROOMOTE_INFERENCE_PROVIDER_ID && + provider.runtimeApiKeySatisfied, ); const operatorProviderConfigured = status.modelSetup.providers?.some( (provider) => - provider.savedApiKeySatisfied || - (provider.runtimeApiKeySatisfied && !provider.trialKeySatisfied), + provider.id !== ROOMOTE_INFERENCE_PROVIDER_ID && + (provider.savedApiKeySatisfied || + provider.runtimeApiKeySatisfied), ); return ( diff --git a/apps/web/src/components/settings/InferenceProviderSection.test.tsx b/apps/web/src/components/settings/InferenceProviderSection.test.tsx index bb22116fc..d6ee7b13f 100644 --- a/apps/web/src/components/settings/InferenceProviderSection.test.tsx +++ b/apps/web/src/components/settings/InferenceProviderSection.test.tsx @@ -160,8 +160,8 @@ import { InferenceProviderSection } from './InferenceProviderSection'; function buildProviderSetup( overrides: { openrouterRuntimeKey?: boolean; - openrouterTrialKey?: boolean; openrouterSavedKey?: boolean; + managedRoomote?: boolean; openaiSavedKey?: boolean; anthropicSavedKey?: boolean; chatgptConnected?: boolean; @@ -185,6 +185,22 @@ function buildProviderSetup( persistedProviderId: null, preselectedProvider: 'openrouter' as const, providers: [ + ...(overrides.managedRoomote + ? [ + { + id: 'roomote' as SetupModelProviderId, + label: 'Roomote inference', + envVarName: 'R_TRIAL_OPENROUTER_API_KEY', + defaultRoomoteModel: 'roomote/openai/gpt-5.6-luna', + authKind: 'api-key' as const, + suggestedTaskModels: [], + hidden: true, + runtimeApiKeySatisfied: true, + savedApiKeySatisfied: false, + additionalEnvValues: {} satisfies Record, + }, + ] + : []), { id: 'openrouter' as SetupModelProviderId, label: 'OpenRouter', @@ -192,10 +208,7 @@ function buildProviderSetup( defaultRoomoteModel: 'openrouter/openai/gpt-5.4', authKind: 'api-key' as const, suggestedTaskModels: [], - runtimeApiKeySatisfied: - (overrides.openrouterRuntimeKey ?? false) || - (overrides.openrouterTrialKey ?? false), - trialKeySatisfied: overrides.openrouterTrialKey ?? false, + runtimeApiKeySatisfied: overrides.openrouterRuntimeKey ?? false, savedApiKeySatisfied: overrides.openrouterSavedKey ?? false, additionalEnvValues: {} satisfies Record, }, @@ -899,39 +912,25 @@ describe('InferenceProviderSection', () => { expect(mutateAsyncMock).toHaveBeenCalledWith({ provider: 'anthropic' }); }); - it('keeps a trial-satisfied key editable and requires a real key to save', async () => { + it('shows managed Roomote inference separately while OpenRouter remains addable', () => { providerSetupData.current = buildProviderSetup({ - openrouterTrialKey: true, + managedRoomote: true, }); - mutateAsyncMock.mockResolvedValue({}); renderInferenceProviderSection(); - // Trial keys are not presented as env-locked: the edit affordance stays. - const editButton = screen.getByRole('button', { - name: 'Edit OpenRouter API key', - }); - fireEvent.click(editButton); - - // The empty form must not be submittable: the trial key is not an - // operator credential, so saving requires typing a real key. - expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); - - await act(async () => { - fireEvent.change(screen.getByLabelText('New API key for OpenRouter'), { - target: { value: 'sk-or-own-key' }, - }); - }); - expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled(); - - await act(async () => { - fireEvent.click(screen.getByRole('button', { name: 'Save' })); - }); - - expect(mutateAsyncMock).toHaveBeenCalledWith({ - provider: 'openrouter', - apiKey: 'sk-or-own-key', - }); + expect(screen.getByText('Roomote inference')).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /edit roomote inference/i }), + ).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /Add provider/ })); + fireEvent.click(screen.getByRole('combobox', { name: 'Provider to add' })); + expect( + screen.getByRole('option', { name: 'OpenRouter' }), + ).toBeInTheDocument(); + expect( + screen.queryByRole('option', { name: 'Roomote inference' }), + ).not.toBeInTheDocument(); }); it('locks a runtime env-managed key behind a masked field and lock tooltip', () => { diff --git a/apps/web/src/components/settings/InferenceProviderSection.tsx b/apps/web/src/components/settings/InferenceProviderSection.tsx index 6ec16e239..5a2c3ac35 100644 --- a/apps/web/src/components/settings/InferenceProviderSection.tsx +++ b/apps/web/src/components/settings/InferenceProviderSection.tsx @@ -6,6 +6,7 @@ import { toast } from 'sonner'; import { CHATGPT_SUBSCRIPTION_PROVIDER_ID, OPENAI_COMPATIBLE_PROVIDER_ID, + ROOMOTE_INFERENCE_PROVIDER_ID, XAI_SUBSCRIPTION_PROVIDER_ID, getDefaultAdditionalEnvValues, getModelProviderLabel, @@ -149,10 +150,21 @@ function ConnectedProviderRow({ onEdit: () => void; onDelete: () => void; }) { - // A free-trial fallback key connects the provider but must not lock the - // row: saving an operator key over it is exactly how a trial ends. - const hasRuntimeKey = - provider.runtimeApiKeySatisfied && !provider.trialKeySatisfied; + if (provider.id === ROOMOTE_INFERENCE_PROVIDER_ID) { + return ( +
+ + {provider.label} + +

+ Managed by Roomote with free credits. Add your own provider at any + time to continue after credits end. +

+
+ ); + } + + const hasRuntimeKey = provider.runtimeApiKeySatisfied; const primaryCredentialLabel = provider.envVarLabel ?? 'API key'; const runtimeKeyTooltip = provider.envVarName ? `Set by ${provider.envVarName}, not changeable in the UI.` @@ -316,15 +328,11 @@ function ProviderCredentialsDialog({ field.required && (additionalEnvValues[field.envVarName]?.trim() ?? '').length === 0, ); - // A free-trial fallback key is not an operator credential: editing a - // trial-only provider is exactly the save-your-own-key flow, so an empty - // form must stay unsubmittable (the server validator would reject it). const hasExistingPrimaryCredential = Boolean( mode === 'edit' && selectedProvider && (selectedProvider.savedApiKeySatisfied || - (selectedProvider.runtimeApiKeySatisfied && - !selectedProvider.trialKeySatisfied)), + selectedProvider.runtimeApiKeySatisfied), ); const hasMissingPrimaryCredential = !hasExistingPrimaryCredential && apiKey.trim().length === 0; diff --git a/apps/web/src/components/settings/ModelSettingsSection.tsx b/apps/web/src/components/settings/ModelSettingsSection.tsx index 8a4c8b9df..a9dc75d43 100644 --- a/apps/web/src/components/settings/ModelSettingsSection.tsx +++ b/apps/web/src/components/settings/ModelSettingsSection.tsx @@ -57,6 +57,7 @@ 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, @@ -753,9 +754,9 @@ export function ModelSettingsSection({ const settingsData = settingsQuery.data; const sortedConnectedProviders = useMemo( () => - [...connectedProviders].sort((left, right) => - left.label.localeCompare(right.label), - ), + connectedProviders + .filter((provider) => provider.id !== ROOMOTE_INFERENCE_PROVIDER_ID) + .sort((left, right) => left.label.localeCompare(right.label)), [connectedProviders], ); const chatgptConnected = sortedConnectedProviders.some( diff --git a/apps/web/src/components/settings/taskModelProviderSetup.ts b/apps/web/src/components/settings/taskModelProviderSetup.ts index 87ba6af47..6e4ac3ff5 100644 --- a/apps/web/src/components/settings/taskModelProviderSetup.ts +++ b/apps/web/src/components/settings/taskModelProviderSetup.ts @@ -21,6 +21,10 @@ export function splitInferenceProviders( // connected, so operators can create another named instance. Named // connected rows themselves stay out of the Add Provider list. availableProviders: providers.filter((provider) => { + if (provider.hidden) { + return false; + } + const isConnected = provider.runtimeApiKeySatisfied || provider.savedApiKeySatisfied; diff --git a/apps/web/src/lib/server/setup-funnel-telemetry.test.ts b/apps/web/src/lib/server/setup-funnel-telemetry.test.ts index f0a5b94b0..426bd3587 100644 --- a/apps/web/src/lib/server/setup-funnel-telemetry.test.ts +++ b/apps/web/src/lib/server/setup-funnel-telemetry.test.ts @@ -53,7 +53,7 @@ describe('setup funnel telemetry', () => { const setupNewState = { ...createEmptySetupNewState(), authProvider: 'slack' as const, - modelProvider: 'openai' as const, + modelProvider: 'roomote' as const, sourceControlProvider: 'github' as const, computeProvider: 'modal' as const, }; @@ -64,9 +64,9 @@ describe('setup funnel telemetry', () => { } as unknown as SetupAuthStatus; const modelSetup = { setupSatisfied: true, - persistedProviderId: 'openai', + persistedProviderId: 'roomote', runtimeProviderId: null, - preselectedProvider: 'openai', + preselectedProvider: 'roomote', } as unknown as SetupModelStatus; const sourceControlSetup = { selectedProvider: 'github', @@ -107,7 +107,7 @@ describe('setup funnel telemetry', () => { }, { milestone: 'inference_configured', - provider: 'openai', + provider: 'roomote', preexisting: false, }, { 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 762cfd509..349efe148 100644 --- a/apps/web/src/trpc/commands/setup-new/index.test.ts +++ b/apps/web/src/trpc/commands/setup-new/index.test.ts @@ -1393,7 +1393,7 @@ describe('chooseSetupTrialInferenceCommand', () => { vi.unstubAllEnvs(); }); - it('seeds the Efficient OpenRouter defaults and records the provider choice', async () => { + it('seeds the Efficient Roomote defaults and records the managed provider choice', async () => { vi.stubEnv('R_TRIAL_OPENROUTER_API_KEY', 'sk-trial'); const { tx, inserted } = createTxStub({ setupNewState: {}, @@ -1406,7 +1406,7 @@ describe('chooseSetupTrialInferenceCommand', () => { const result = await chooseSetupTrialInferenceCommand(buildMockAuth()); - expect(result.setupNewState.modelProvider).toBe('openrouter'); + expect(result.setupNewState.modelProvider).toBe('roomote'); const runtimeModelConfigInsert = inserted.find( (values) => 'runtimeModelConfig' in values, ); @@ -1415,12 +1415,12 @@ describe('chooseSetupTrialInferenceCommand', () => { ); expect(runtimeModelConfigInsert?.runtimeModelConfig).toMatchObject({ - roomoteModel: 'openrouter/openai/gpt-5.6-luna', - roomoteSmallModel: 'openrouter/openai/gpt-5.6-luna', - roomotePlanningModel: 'openrouter/openai/gpt-5.6-luna', + roomoteModel: 'roomote/openai/gpt-5.6-luna', + roomoteSmallModel: 'roomote/openai/gpt-5.6-luna', + roomotePlanningModel: 'roomote/openai/gpt-5.6-luna', }); expect(taskModelSettingsInsert?.taskModelSettings).toMatchObject({ - defaultModelId: 'openrouter/openai/gpt-5.6-luna', + defaultModelId: 'roomote/openai/gpt-5.6-luna', }); }); @@ -1455,8 +1455,8 @@ describe('chooseSetupTrialInferenceCommand', () => { setupNewState: {}, runtimeModelConfig: null, taskModelSettings: { - allowedModelIds: ['openrouter/openai/gpt-5.6-terra'], - defaultModelId: 'openrouter/openai/gpt-5.6-terra', + allowedModelIds: ['roomote/openai/gpt-5.6-terra'], + defaultModelId: 'roomote/openai/gpt-5.6-terra', }, }); mockDbTransaction.mockImplementation( @@ -1482,7 +1482,7 @@ describe('chooseSetupTrialInferenceCommand', () => { const result = await chooseSetupTrialInferenceCommand(buildMockAuth()); - expect(result.setupNewState.modelProvider).toBe('openrouter'); + expect(result.setupNewState.modelProvider).toBe('roomote'); expect( inserted.find((values) => 'runtimeModelConfig' in values), ).toBeDefined(); diff --git a/apps/web/src/trpc/commands/setup-new/index.ts b/apps/web/src/trpc/commands/setup-new/index.ts index 92bc694d4..0da1e5dbd 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_PROVIDER_ID, OPENAI_COMPATIBLE_PROVIDER_ID, collectSetupModelProviderCredentialValues, createEmptyDeploymentModelConfig, @@ -79,7 +80,6 @@ import { isConfiguredEnvValue, isRequiredComputeField, normalizeTaskModelSettings, - DEFAULT_TASK_MODEL_SETTINGS, NON_SECRET_AUTH_ENV_VAR_NAMES, NON_SECRET_COMPUTE_ENV_VAR_NAMES, NON_SECRET_SOURCE_CONTROL_ENV_VAR_NAMES, @@ -291,11 +291,9 @@ async function savePersistedTaskModelSettings( /** * 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 "start with free credits" alongside - * connecting a provider; choosing it applies OpenRouter's "Efficient" preset - * as ordinary editable config, so first tasks run on an inexpensive default - * and every model and provider control keeps working because nothing is - * pinned through env. + * 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 @@ -370,8 +368,8 @@ export async function chooseSetupTrialInferenceCommand(auth: UserAuthSuccess) { }); const hasOperatorProvider = status.providers.some( (provider) => - provider.savedApiKeySatisfied || - (provider.runtimeApiKeySatisfied && !provider.trialKeySatisfied), + provider.id !== ROOMOTE_INFERENCE_PROVIDER_ID && + (provider.savedApiKeySatisfied || provider.runtimeApiKeySatisfied), ); if (hasOperatorProvider) { @@ -380,12 +378,19 @@ export async function chooseSetupTrialInferenceCommand(auth: UserAuthSuccess) { ); } - const provider = getSetupModelProvider('openrouter'); + const provider = getSetupModelProvider(ROOMOTE_INFERENCE_PROVIDER_ID); const runtimeModelConfig = buildRecommendedDeploymentModelConfig( provider, TRIAL_PRESET_ID, ); const defaultModelId = runtimeModelConfig.roomoteModel; + const trialModels = provider.suggestedTaskModels.map((suggestion) => + buildTaskModelOption({ + id: suggestion.id, + displayName: suggestion.displayName, + family: suggestion.family, + }), + ); const setupNewState = normalizeSetupNewState({ ...currentState, modelProvider: provider.id, @@ -397,8 +402,9 @@ export async function chooseSetupTrialInferenceCommand(auth: UserAuthSuccess) { savePersistedRuntimeModelConfig(runtimeModelConfig, tx), savePersistedTaskModelSettings( normalizeTaskModelSettings({ - ...DEFAULT_TASK_MODEL_SETTINGS, - ...(defaultModelId ? { defaultModelId } : {}), + models: trialModels, + allowedModelIds: trialModels.map((model) => model.id), + defaultModelId: defaultModelId ?? provider.defaultRoomoteModel, }), tx, ), @@ -1463,6 +1469,9 @@ export async function saveSetupNewModelConfigCommand( } const provider = getSetupModelProvider(providerId); + if (provider.id === ROOMOTE_INFERENCE_PROVIDER_ID) { + throw new Error('Roomote inference is managed by your hosting provider.'); + } const isOauthProvider = provider.authKind === 'oauth'; const [chatgptConnected, githubCopilotConnected, xaiSubscriptionConnected] = 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 078c0ec24..ff15e0744 100644 --- a/apps/web/src/trpc/commands/task-models/index.test.ts +++ b/apps/web/src/trpc/commands/task-models/index.test.ts @@ -124,6 +124,7 @@ import { } from './index'; const PROVIDER_ENV_VAR_NAMES = [ + 'R_TRIAL_OPENROUTER_API_KEY', 'OPENROUTER_API_KEY', 'OPENAI_API_KEY', 'AZURE_API_KEY', @@ -475,6 +476,40 @@ 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'; + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + data: { + id: 'openai/gpt-5.6-luna', + name: 'GPT 5.6 Luna', + }, + }), + { headers: { 'content-type': 'application/json' } }, + ), + ); + + await expect( + lookupTaskModelCommand(buildMockAuth(), { + modelId: 'roomote/openai/gpt-5.6-luna', + }), + ).resolves.toMatchObject({ + modelId: 'roomote/openai/gpt-5.6-luna', + displayName: 'GPT 5.6 Luna', + }); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://openrouter.ai/api/v1/model/openai/gpt-5.6-luna', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer managed-roomote-key', + }), + signal: expect.any(AbortSignal), + }), + ); + }); + it('extracts reasoning support from the OpenRouter supported_parameters list', async () => { process.env.OPENROUTER_API_KEY = 'openrouter-test-key'; fetchMock.mockResolvedValue( @@ -1758,6 +1793,14 @@ describe('task model provider commands', () => { expect(txInsert).not.toHaveBeenCalled(); }); + it('rejects saving the hosting-managed Roomote provider', async () => { + await expect( + saveTaskModelProviderCommand(buildMockAuth(), { provider: 'roomote' }), + ).rejects.toThrow('Roomote inference is managed by your hosting provider.'); + + expect(mockUpsertDeploymentEnvironmentVariables).not.toHaveBeenCalled(); + }); + it('saves the API key and seeds the recommended models for a newly connected provider', async () => { mockGetPersistedEnvironmentVariableNames .mockResolvedValueOnce([]) @@ -2120,6 +2163,14 @@ describe('task model provider commands', () => { expect(txOnConflictDoUpdate).not.toHaveBeenCalled(); }); + it('rejects deleting the hosting-managed Roomote provider', async () => { + await expect( + deleteTaskModelProviderCommand(buildMockAuth(), { provider: 'roomote' }), + ).rejects.toThrow('Roomote inference is managed by your hosting provider.'); + + expect(mockTxDelete).not.toHaveBeenCalled(); + }); + it('deletes provider credentials and cascades removing provider models without deleting usage data', async () => { const persistedRow = { taskModelSettings: { diff --git a/apps/web/src/trpc/commands/task-models/index.ts b/apps/web/src/trpc/commands/task-models/index.ts index 1142bf896..a1ca134d3 100644 --- a/apps/web/src/trpc/commands/task-models/index.ts +++ b/apps/web/src/trpc/commands/task-models/index.ts @@ -14,6 +14,8 @@ import { } from '@roomote/db/server'; import { OPENAI_COMPATIBLE_PROVIDER_ID, + ROOMOTE_INFERENCE_API_KEY_ENV_VAR_NAME, + ROOMOTE_INFERENCE_PROVIDER_ID, XAI_SUBSCRIPTION_PROVIDER_ID, TASK_MODEL_CATALOG, TASK_MODEL_ROLE_DESCRIPTORS, @@ -585,6 +587,10 @@ export async function saveTaskModelProviderCommand( const provider = getSetupModelProvider(providerId); + if (provider.id === ROOMOTE_INFERENCE_PROVIDER_ID) { + throw new Error('Roomote inference is managed by your hosting provider.'); + } + if (provider.authKind === 'oauth') { throw new Error( `${provider.label} is connected with a subscription account from the Models settings page and does not use an API key.`, @@ -914,6 +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.'); + } + if (provider.authKind === 'oauth') { throw new Error( `${provider.label} is connected with a subscription account and cannot be deleted here.`, @@ -1506,18 +1516,25 @@ 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 openRouterLookupModelId = roomoteInferenceModel + ? `openrouter/${modelId.slice('roomote/'.length)}` + : modelId; + // Only the OpenRouter model API supports single-model lookup; every other // provider (Vercel AI Gateway and direct labs such as Anthropic) resolves // from the models.dev catalog. - if (!modelId.startsWith('openrouter/')) { + if (!openRouterLookupModelId.startsWith('openrouter/')) { return lookupModelFromModelsDevCatalog(modelId); } - // Shared runtime-first resolution, including the free-trial fallback key, - // so metadata lookups work on trial-only deployments. - const openRouterKey = await resolveModelProviderEnvValue([ - 'OPENROUTER_API_KEY', - ]); + const openRouterKey = await resolveModelProviderEnvValue( + roomoteInferenceModel + ? ROOMOTE_INFERENCE_API_KEY_ENV_VAR_NAME + : 'OPENROUTER_API_KEY', + ); if (!openRouterKey) { return { @@ -1528,7 +1545,9 @@ export async function lookupTaskModelCommand( }; } - const openRouterModelSlug = modelId.slice('openrouter/'.length); + const openRouterModelSlug = openRouterLookupModelId.slice( + 'openrouter/'.length, + ); const [openRouterAuthor, ...openRouterSlugParts] = openRouterModelSlug.split('/'); const openRouterSlug = openRouterSlugParts.join('/'); diff --git a/apps/worker/src/run-task/agent-home.test.ts b/apps/worker/src/run-task/agent-home.test.ts index 83e5abff8..c9d83dae1 100644 --- a/apps/worker/src/run-task/agent-home.test.ts +++ b/apps/worker/src/run-task/agent-home.test.ts @@ -398,6 +398,25 @@ describe('generateOpenCodeConfig provider support', () => { }); }); + it('rebases managed Roomote inference onto its separate gateway route', () => { + const result = generateOpenCodeConfig({ + homeDir: createHomeDir(), + runtimeEnv: { + R_MODEL: 'roomote/openai/gpt-5.6-luna', + R_INFERENCE_GATEWAY_URL: 'https://api.example.com/api/inference', + R_INFERENCE_GATEWAY_KEYS: 'R_TRIAL_OPENROUTER_API_KEY', + }, + }); + const config = JSON.parse(result.configContent) as { + provider: { roomote: { options: Record } }; + }; + + expect(config.provider.roomote.options).toMatchObject({ + baseURL: 'https://api.example.com/api/inference/roomote/v1', + apiKey: '{env:ROOMOTE_CLOUD_TOKEN}', + }); + }); + it('binds OpenCode Go to its dedicated key in direct mode', () => { const result = generateOpenCodeConfig({ homeDir: createHomeDir(), diff --git a/packages/compute-providers/src/worker-env/base.ts b/packages/compute-providers/src/worker-env/base.ts index f7472447c..909f78563 100644 --- a/packages/compute-providers/src/worker-env/base.ts +++ b/packages/compute-providers/src/worker-env/base.ts @@ -18,9 +18,8 @@ const BLOCKED_WORKER_ENV_KEYS = new Set([ 'DASHBOARD_PASSWORD', 'SETUP_TOKEN', 'MODAL_TOKEN_SECRET', - // The free-trial OpenRouter key is inference-gateway-served like the - // primary OpenRouter credential; block it here too so no env passthrough - // can ever ship it into a sandbox. + // The hosting-managed Roomote inference key is gateway-served. Block it so + // no env passthrough can ever ship it into a sandbox. 'R_TRIAL_OPENROUTER_API_KEY', ...DISABLED_MODEL_PROVIDER_ENV_VAR_NAMES, ]); diff --git a/packages/db/src/lib/model-runtime-config.test.ts b/packages/db/src/lib/model-runtime-config.test.ts index a9ae7dd34..c776d5762 100644 --- a/packages/db/src/lib/model-runtime-config.test.ts +++ b/packages/db/src/lib/model-runtime-config.test.ts @@ -1154,7 +1154,7 @@ describe('resolveEffectiveModelRuntimeEnv', () => { }); }); -describe('free-trial fallback key', () => { +describe('managed Roomote inference key', () => { beforeEach(() => { vi.clearAllMocks(); mockDecryptSecrets.mockImplementation(async (value) => value); @@ -1165,9 +1165,9 @@ describe('free-trial fallback key', () => { mockGetFreshXaiAccessToken.mockResolvedValue(null); }); - it('advertises OpenRouter as gateway-served without leaking the trial key', async () => { + it('advertises Roomote inference as gateway-served without leaking its key', async () => { mockDeploymentSettingsFindFirst.mockResolvedValue({ - runtimeModelConfig: { roomoteModel: 'openrouter/openai/gpt-5.6-luna' }, + runtimeModelConfig: { roomoteModel: 'roomote/openai/gpt-5.6-luna' }, taskModelSettings: null, }); @@ -1177,16 +1177,33 @@ describe('free-trial fallback key', () => { }); expect(env.R_INFERENCE_GATEWAY_KEYS?.split(',')).toContain( - 'OPENROUTER_API_KEY', + 'R_TRIAL_OPENROUTER_API_KEY', ); expect(env).not.toHaveProperty('OPENROUTER_API_KEY'); expect(env).not.toHaveProperty('R_TRIAL_OPENROUTER_API_KEY'); expect(Object.values(env)).not.toContain('sk-trial'); }); - it('materializes the trial key on the control plane when nothing else is configured', async () => { + it('keeps an unavailable Roomote provider on the gateway for an actionable failure', async () => { mockDeploymentSettingsFindFirst.mockResolvedValue({ - runtimeModelConfig: { roomoteModel: 'openrouter/openai/gpt-5.6-luna' }, + runtimeModelConfig: { roomoteModel: 'roomote/openai/gpt-5.6-luna' }, + taskModelSettings: null, + }); + + const env = await resolveSandboxModelRuntimeEnv({ + runtimeEnv: {}, + deploymentEnvVars: {}, + }); + + expect(env.R_INFERENCE_GATEWAY_KEYS?.split(',')).toContain( + 'R_TRIAL_OPENROUTER_API_KEY', + ); + expect(env).not.toHaveProperty('R_TRIAL_OPENROUTER_API_KEY'); + }); + + it('materializes the managed key on the control plane for Roomote models', async () => { + mockDeploymentSettingsFindFirst.mockResolvedValue({ + runtimeModelConfig: { roomoteModel: 'roomote/openai/gpt-5.6-luna' }, taskModelSettings: null, }); @@ -1195,13 +1212,13 @@ describe('free-trial fallback key', () => { deploymentEnvVars: {}, }); - expect(env.OPENROUTER_API_KEY).toBe('sk-trial'); - expect(env).not.toHaveProperty('R_TRIAL_OPENROUTER_API_KEY'); + expect(env.R_TRIAL_OPENROUTER_API_KEY).toBe('sk-trial'); + expect(env).not.toHaveProperty('OPENROUTER_API_KEY'); }); - it('never outranks a saved operator key', async () => { + it('does not materialize an unrelated user-provided OpenRouter key', async () => { mockDeploymentSettingsFindFirst.mockResolvedValue({ - runtimeModelConfig: { roomoteModel: 'openrouter/openai/gpt-5.6-luna' }, + runtimeModelConfig: { roomoteModel: 'roomote/openai/gpt-5.6-luna' }, taskModelSettings: null, }); @@ -1210,25 +1227,25 @@ describe('free-trial fallback key', () => { deploymentEnvVars: { OPENROUTER_API_KEY: 'sk-saved' }, }); - expect(env.OPENROUTER_API_KEY).toBe('sk-saved'); + expect(env.R_TRIAL_OPENROUTER_API_KEY).toBe('sk-trial'); + expect(env).not.toHaveProperty('OPENROUTER_API_KEY'); }); - it('resolveModelProviderEnvValue prefers runtime, then saved, then trial', async () => { + it('resolveModelProviderEnvValue only resolves the requested provider key', async () => { await expect( - resolveModelProviderEnvValue(['OPENROUTER_API_KEY'], { + resolveModelProviderEnvValue(['R_TRIAL_OPENROUTER_API_KEY'], { runtimeEnv: { - OPENROUTER_API_KEY: 'sk-runtime', - R_TRIAL_OPENROUTER_API_KEY: 'sk-trial', + R_TRIAL_OPENROUTER_API_KEY: 'sk-runtime', }, }), ).resolves.toBe('sk-runtime'); mockEnvironmentVariablesFindMany.mockResolvedValue([ - { name: 'OPENROUTER_API_KEY', value: 'sk-saved' }, + { name: 'R_TRIAL_OPENROUTER_API_KEY', value: 'sk-saved' }, ]); await expect( - resolveModelProviderEnvValue(['OPENROUTER_API_KEY'], { - runtimeEnv: { R_TRIAL_OPENROUTER_API_KEY: 'sk-trial' }, + resolveModelProviderEnvValue(['R_TRIAL_OPENROUTER_API_KEY'], { + runtimeEnv: {}, }), ).resolves.toBe('sk-saved'); @@ -1237,7 +1254,7 @@ describe('free-trial fallback key', () => { resolveModelProviderEnvValue(['OPENROUTER_API_KEY'], { runtimeEnv: { R_TRIAL_OPENROUTER_API_KEY: 'sk-trial' }, }), - ).resolves.toBe('sk-trial'); + ).resolves.toBeUndefined(); }); }); diff --git a/packages/db/src/lib/model-runtime-config.ts b/packages/db/src/lib/model-runtime-config.ts index fb3080898..87717570c 100644 --- a/packages/db/src/lib/model-runtime-config.ts +++ b/packages/db/src/lib/model-runtime-config.ts @@ -8,7 +8,6 @@ import { getEnabledTaskModels, getModelProviderEnvKeyCandidates, getTaskModelCatalog, - getTrialModelProviderEnvVarName, INFERENCE_GATEWAY_CHATGPT_ENV_VAR_NAME, INFERENCE_GATEWAY_GITHUB_COPILOT_ENV_VAR_NAME, INFERENCE_GATEWAY_KEYS_ENV_VAR_NAME, @@ -18,6 +17,8 @@ import { normalizeDeploymentModelConfig, normalizeOptionalReasoningEffort, parseModelProviderEnvKeys, + ROOMOTE_INFERENCE_API_KEY_ENV_VAR_NAME, + ROOMOTE_INFERENCE_PROVIDER_ID, resolveSetupModelProviderIdFromModel, TASK_MODEL_ROLE_DESCRIPTORS, TASK_MODEL_ROLES, @@ -164,10 +165,7 @@ 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. When neither source configures a name, - * its free-trial fallback (`TRIAL_MODEL_PROVIDER_ENV_VAR_FALLBACKS`) is - * consulted last, from the runtime env only, so a Roomote-minted trial key - * never outranks a credential the operator configured themselves. + * deployment environment variables. */ export async function resolveModelProviderEnvValue( envVarNames: string | readonly string[], @@ -201,17 +199,6 @@ export async function resolveModelProviderEnvValue( } } - for (const envVarName of names) { - const trialEnvVarName = getTrialModelProviderEnvVarName(envVarName); - const trialValue = trialEnvVarName - ? normalizeConfiguredValue(runtimeEnv[trialEnvVarName]) - : undefined; - - if (trialValue) { - return trialValue; - } - } - return undefined; } @@ -437,6 +424,14 @@ async function resolveModelRuntimeEnv( }), ]), ]; + const managedRoomoteInferenceSelected = [ + ...resolvedRoleModels, + ...gatewaySwitchableModelIds, + ].some( + (modelId) => + resolveSetupModelProviderIdFromModel(modelId) === + ROOMOTE_INFERENCE_PROVIDER_ID, + ); // When the gateway is active, the configured provider keys it can serve // (OpenRouter, Anthropic, OpenAI, Gemini, the aggregators, Bedrock) stay on // the control plane and are advertised to the worker by name via @@ -444,23 +439,14 @@ async function resolveModelRuntimeEnv( // gateway URL from its own platform URL and rebases exactly these providers. // Only configured keys are withheld; credentials for disabled providers are // filtered before this point and never flow to the task runtime. - // Free-trial fallback keys count as configured for gateway coverage and - // control-plane resolution, but only when the primary name is configured - // nowhere, matching `resolveModelProviderEnvValue`. - const resolveTrialFallbackValue = (name: string): string | undefined => { - const trialEnvVarName = getTrialModelProviderEnvVarName(name); - - return trialEnvVarName - ? normalizeConfiguredValue(runtimeEnv[trialEnvVarName]) - : undefined; - }; const gatewayServedKeyNames = inferenceGateway ? gatewayProviderKeyNames.filter( (name) => isInferenceGatewayCoveredEnvVar(name) && - (normalizeConfiguredValue(runtimeEnv[name]) !== undefined || - normalizeConfiguredValue(persistedEnvVars[name]) !== undefined || - resolveTrialFallbackValue(name) !== undefined), + ((name === ROOMOTE_INFERENCE_API_KEY_ENV_VAR_NAME && + managedRoomoteInferenceSelected) || + normalizeConfiguredValue(runtimeEnv[name]) !== undefined || + normalizeConfiguredValue(persistedEnvVars[name]) !== undefined), ) : []; const gatewayServedKeyNameSet = new Set(gatewayServedKeyNames); @@ -473,8 +459,7 @@ async function resolveModelRuntimeEnv( const value = normalizeConfiguredValue(runtimeEnv[envVarName]) ?? - normalizeConfiguredValue(persistedEnvVars[envVarName]) ?? - resolveTrialFallbackValue(envVarName); + normalizeConfiguredValue(persistedEnvVars[envVarName]); return value ? [[envVarName, value]] : []; }), diff --git a/packages/types/src/__tests__/inference-gateway.test.ts b/packages/types/src/__tests__/inference-gateway.test.ts index 0798e555e..ed8e78b2a 100644 --- a/packages/types/src/__tests__/inference-gateway.test.ts +++ b/packages/types/src/__tests__/inference-gateway.test.ts @@ -54,6 +54,23 @@ describe('inference gateway URL builders', () => { ); }); + it('registers Roomote inference as a managed OpenRouter-backed route', () => { + const provider = getInferenceGatewayProvider('roomote'); + + expect(provider).toMatchObject({ + envVarNames: ['R_TRIAL_OPENROUTER_API_KEY'], + upstreamBaseUrl: 'https://openrouter.ai/api', + authHeader: { name: 'authorization', scheme: 'bearer' }, + openCodeBaseUrlSuffix: '/v1', + }); + expect( + buildInferenceGatewayOpenCodeBaseUrl( + 'https://api.example.com/api/inference', + provider!, + ), + ).toBe('https://api.example.com/api/inference/roomote/v1'); + }); + it('registers OpenCode Go with its subscription endpoint and API key', () => { const provider = getInferenceGatewayProvider('opencode-go'); diff --git a/packages/types/src/__tests__/opencode-provider-config.test.ts b/packages/types/src/__tests__/opencode-provider-config.test.ts index 2f17199f6..80f8ab8b1 100644 --- a/packages/types/src/__tests__/opencode-provider-config.test.ts +++ b/packages/types/src/__tests__/opencode-provider-config.test.ts @@ -3,6 +3,28 @@ import { describe, expect, it } from 'vitest'; import { mergeOpenAiCompatibleProviderConfig } from '../opencode-provider-config'; describe('mergeOpenAiCompatibleProviderConfig', () => { + it('materializes managed Roomote models as an isolated OpenAI-compatible provider', () => { + expect( + mergeOpenAiCompatibleProviderConfig( + {}, + { R_TRIAL_OPENROUTER_API_KEY: 'managed-key' }, + ['roomote/openai/gpt-5.6-luna'], + ), + ).toMatchObject({ + roomote: { + npm: '@ai-sdk/openai-compatible', + name: 'Roomote inference', + options: { + baseURL: 'https://openrouter.ai/api/v1', + apiKey: '{env:R_TRIAL_OPENROUTER_API_KEY}', + }, + models: { + 'openai/gpt-5.6-luna': { name: 'openai/gpt-5.6-luna' }, + }, + }, + }); + }); + it('materializes LiteLLM provider metadata for selected models', () => { expect( mergeOpenAiCompatibleProviderConfig( diff --git a/packages/types/src/control-plane-env-vars.test.ts b/packages/types/src/control-plane-env-vars.test.ts index b88246fa7..cfbd5559e 100644 --- a/packages/types/src/control-plane-env-vars.test.ts +++ b/packages/types/src/control-plane-env-vars.test.ts @@ -27,6 +27,7 @@ describe('CONTROL_PLANE_ENV_VAR_NAMES', () => { 'DATABASE_URL', 'S3_SECRET_ACCESS_KEY', 'R_LICENSE_KEY', + 'R_TRIAL_OPENROUTER_API_KEY', 'R_ELEVENLABS_API_KEY', 'R_ELEVENLABS_VOICE_ID', ]) { diff --git a/packages/types/src/control-plane-env-vars.ts b/packages/types/src/control-plane-env-vars.ts index abb9eec70..68265e601 100644 --- a/packages/types/src/control-plane-env-vars.ts +++ b/packages/types/src/control-plane-env-vars.ts @@ -4,7 +4,7 @@ import { SETUP_SOURCE_CONTROL_PROVIDER_CATALOG } from './setup-source-control-co import { OPENCODE_AUTH_CONTENT_ENV_VAR_NAME } from './chatgpt-subscription'; import { DISABLED_MODEL_PROVIDER_ENV_VAR_NAMES, - TRIAL_MODEL_PROVIDER_ENV_VAR_FALLBACKS, + ROOMOTE_INFERENCE_API_KEY_ENV_VAR_NAME, } from './model-provider-config'; /** @@ -151,14 +151,9 @@ export const CONTROL_PLANE_ENV_VAR_NAMES: ReadonlySet = new Set( ...MEDIA_PROVIDER_ENV_VAR_NAMES, ...DECLARATIVE_ENVIRONMENT_ENV_VAR_NAMES, ...DISABLED_MODEL_PROVIDER_ENV_VAR_NAMES, - // Free-trial fallback provider keys are injected by the hosting - // platform's deployment environment and served through the inference - // gateway; a persisted copy would flow into sandboxes through the - // generic env path, so the names are reserved from the editor and - // stripped from job env like the other control-plane credentials. - ...Object.values(TRIAL_MODEL_PROVIDER_ENV_VAR_FALLBACKS).filter( - (name): name is string => typeof name === 'string', - ), + // Hosting-managed Roomote inference is served only through the inference + // gateway, never configured through the generic environment editor. + ROOMOTE_INFERENCE_API_KEY_ENV_VAR_NAME, ], ); diff --git a/packages/types/src/inference-gateway.ts b/packages/types/src/inference-gateway.ts index c20812be7..898c74ed1 100644 --- a/packages/types/src/inference-gateway.ts +++ b/packages/types/src/inference-gateway.ts @@ -1,5 +1,8 @@ import type { SetupModelProviderId } from './model-provider-config'; -import { OPENCODE_GO_API_KEY_ENV_VAR_NAME } from './model-provider-config'; +import { + OPENCODE_GO_API_KEY_ENV_VAR_NAME, + ROOMOTE_INFERENCE_API_KEY_ENV_VAR_NAME, +} from './model-provider-config'; import { getOpenAiCompatibleProviderInstance, isOpenAiCompatibleProviderEnvVarName, @@ -223,6 +226,15 @@ const ZAI_INFERENCE_PATHS: readonly string[] = [ */ export const INFERENCE_GATEWAY_PROVIDERS: readonly InferenceGatewayProvider[] = [ + { + id: 'roomote', + name: 'Roomote inference', + envVarNames: [ROOMOTE_INFERENCE_API_KEY_ENV_VAR_NAME], + upstreamBaseUrl: 'https://openrouter.ai/api', + authHeader: { name: 'authorization', scheme: 'bearer' }, + allowedPaths: OPENAI_COMPATIBLE_INFERENCE_PATHS, + openCodeBaseUrlSuffix: '/v1', + }, { id: 'openrouter', name: 'OpenRouter', diff --git a/packages/types/src/model-provider-config.test.ts b/packages/types/src/model-provider-config.test.ts index 7ec9ba887..fd9e247d7 100644 --- a/packages/types/src/model-provider-config.test.ts +++ b/packages/types/src/model-provider-config.test.ts @@ -270,37 +270,53 @@ describe('normalizeDeploymentModelConfig', () => { }); describe('SETUP_MODEL_PROVIDER_CATALOG', () => { + const userSelectableProviders = SETUP_MODEL_PROVIDER_CATALOG.filter( + (provider) => !('hidden' in provider && provider.hidden), + ); + it('exposes the supported setup providers for the onboarding UI', () => { - expect(SETUP_MODEL_PROVIDER_CATALOG.map((provider) => provider.id)).toEqual( - [ - 'openrouter', - 'vercel', - 'requesty', - 'baseten', - 'togetherai', - 'openai', - 'azure', - 'azure-cognitive-services', - 'anthropic', - 'moonshotai', - 'kimi-for-coding', - 'minimax', - 'opencode', - 'opencode-go', - 'amazon-bedrock', - 'google', - 'xai', - 'zai', - 'zai-coding-plan', - 'github-copilot', - 'openai-compatible', - 'litellm', - 'ollama', - 'vllm', - 'chatgpt', - 'xai-subscription', - ], - ); + expect(userSelectableProviders.map((provider) => provider.id)).toEqual([ + 'openrouter', + 'vercel', + 'requesty', + 'baseten', + 'togetherai', + 'openai', + 'azure', + 'azure-cognitive-services', + 'anthropic', + 'moonshotai', + 'kimi-for-coding', + 'minimax', + 'opencode', + 'opencode-go', + 'amazon-bedrock', + 'google', + 'xai', + 'zai', + 'zai-coding-plan', + 'github-copilot', + 'openai-compatible', + 'litellm', + 'ollama', + 'vllm', + 'chatgpt', + 'xai-subscription', + ]); + }); + + it('keeps Roomote inference hidden and maps its curated models to roomote IDs', () => { + expect(getSetupModelProvider('roomote')).toMatchObject({ + id: 'roomote', + hidden: true, + envVarName: 'R_TRIAL_OPENROUTER_API_KEY', + defaultRoomoteModel: 'roomote/openai/gpt-5.6-luna', + }); + expect( + getSetupModelProvider('roomote').suggestedTaskModels.every((model) => + model.id.startsWith('roomote/'), + ), + ).toBe(true); }); it('keeps recommended-model slugs and default models under each provider prefix', () => { @@ -369,15 +385,13 @@ describe('SETUP_MODEL_PROVIDER_CATALOG', () => { }); it('recommends Kimi K3 only from supported providers', () => { - const kimiK3ByProvider = SETUP_MODEL_PROVIDER_CATALOG.flatMap( - (provider) => { - const model = provider.suggestedTaskModels.find( - (suggestion) => suggestion.displayName === 'Kimi K3', - ); + const kimiK3ByProvider = userSelectableProviders.flatMap((provider) => { + const model = provider.suggestedTaskModels.find( + (suggestion) => suggestion.displayName === 'Kimi K3', + ); - return model ? [{ providerId: provider.id, modelId: model.id }] : []; - }, - ); + return model ? [{ providerId: provider.id, modelId: model.id }] : []; + }); expect(kimiK3ByProvider).toEqual([ { providerId: 'openrouter', modelId: 'openrouter/moonshotai/kimi-k3' }, @@ -394,15 +408,13 @@ describe('SETUP_MODEL_PROVIDER_CATALOG', () => { }); it('recommends Qwen3.8 Max only from supported providers', () => { - const providersByModel = SETUP_MODEL_PROVIDER_CATALOG.flatMap( - (provider) => { - const model = provider.suggestedTaskModels.find( - (suggestion) => suggestion.displayName === 'Qwen3.8 Max', - ); + const providersByModel = userSelectableProviders.flatMap((provider) => { + const model = provider.suggestedTaskModels.find( + (suggestion) => suggestion.displayName === 'Qwen3.8 Max', + ); - return model ? [{ providerId: provider.id, modelId: model.id }] : []; - }, - ); + return model ? [{ providerId: provider.id, modelId: model.id }] : []; + }); expect(providersByModel).toEqual([ { providerId: 'openrouter', modelId: 'openrouter/qwen/qwen3.8-max' }, @@ -427,7 +439,7 @@ describe('SETUP_MODEL_PROVIDER_CATALOG', () => { }); it('recommends GLM 5.3 where available and retains GLM 5.2 elsewhere', () => { - const glm53ByProvider = SETUP_MODEL_PROVIDER_CATALOG.flatMap((provider) => { + const glm53ByProvider = userSelectableProviders.flatMap((provider) => { const model = provider.suggestedTaskModels.find( (suggestion) => suggestion.displayName === 'GLM 5.3', ); @@ -454,7 +466,7 @@ describe('SETUP_MODEL_PROVIDER_CATALOG', () => { .roomoteModel, ).toBe('zai/glm-5.3'); expect( - SETUP_MODEL_PROVIDER_CATALOG.flatMap((provider) => { + userSelectableProviders.flatMap((provider) => { const model = provider.suggestedTaskModels.find( (suggestion) => suggestion.displayName === 'GLM 5.2', ); @@ -469,15 +481,13 @@ describe('SETUP_MODEL_PROVIDER_CATALOG', () => { }); it('recommends GLM 5.3 Flash from every supported provider', () => { - const glm53FlashByProvider = SETUP_MODEL_PROVIDER_CATALOG.flatMap( - (provider) => { - const model = provider.suggestedTaskModels.find( - (suggestion) => suggestion.displayName === 'GLM 5.3 Flash', - ); + const glm53FlashByProvider = userSelectableProviders.flatMap((provider) => { + const model = provider.suggestedTaskModels.find( + (suggestion) => suggestion.displayName === 'GLM 5.3 Flash', + ); - return model ? [{ providerId: provider.id, modelId: model.id }] : []; - }, - ); + return model ? [{ providerId: provider.id, modelId: model.id }] : []; + }); expect(glm53FlashByProvider).toEqual([ { @@ -511,15 +521,13 @@ describe('SETUP_MODEL_PROVIDER_CATALOG', () => { ])( 'recommends $displayName only from providers that support it', ({ displayName, modelId }) => { - const providersByModel = SETUP_MODEL_PROVIDER_CATALOG.flatMap( - (provider) => { - const model = provider.suggestedTaskModels.find( - (suggestion) => suggestion.displayName === displayName, - ); + const providersByModel = userSelectableProviders.flatMap((provider) => { + const model = provider.suggestedTaskModels.find( + (suggestion) => suggestion.displayName === displayName, + ); - return model ? [{ providerId: provider.id, modelId: model.id }] : []; - }, - ); + return model ? [{ providerId: provider.id, modelId: model.id }] : []; + }); expect(providersByModel).toEqual([ { providerId: 'openrouter', modelId: `openrouter/openai/${modelId}` }, @@ -551,7 +559,7 @@ describe('SETUP_MODEL_PROVIDER_CATALOG', () => { ); it('recommends Gemini 3.7 Flash from every provider that offered 3.6', () => { - const geminiFlashByProvider = SETUP_MODEL_PROVIDER_CATALOG.flatMap( + const geminiFlashByProvider = userSelectableProviders.flatMap( (provider) => { const model = provider.suggestedTaskModels.find( (suggestion) => suggestion.displayName === 'Gemini 3.7 Flash', @@ -574,7 +582,7 @@ describe('SETUP_MODEL_PROVIDER_CATALOG', () => { }); it("uses each provider's DeepSeek V4 Flash 0731 model slug", () => { - const deepSeekFlashByProvider = SETUP_MODEL_PROVIDER_CATALOG.flatMap( + const deepSeekFlashByProvider = userSelectableProviders.flatMap( (provider) => { const model = provider.suggestedTaskModels.find( (suggestion) => suggestion.displayName === 'DeepSeek V4 Flash 0731', @@ -613,7 +621,7 @@ describe('SETUP_MODEL_PROVIDER_CATALOG', () => { }); it("uses each provider's DeepSeek V4 Pro 0813 model slug", () => { - const deepSeekProByProvider = SETUP_MODEL_PROVIDER_CATALOG.flatMap( + const deepSeekProByProvider = userSelectableProviders.flatMap( (provider) => { const model = provider.suggestedTaskModels.find( (suggestion) => suggestion.displayName === 'DeepSeek V4 Pro 0813', @@ -1606,13 +1614,13 @@ describe('buildSetupModelStatus', () => { expect(status.setupSatisfied).toBe(true); }); - it('satisfies openrouter through the free-trial fallback key and flags it', () => { + it('treats Roomote inference as a distinct hosting-managed provider', () => { const status = buildSetupModelStatus({ runtimeEnv: { R_TRIAL_OPENROUTER_API_KEY: 'sk-trial', }, persistedModelConfig: { - roomoteModel: 'openrouter/openai/gpt-5.6-luna', + roomoteModel: 'roomote/openai/gpt-5.6-luna', roomoteSmallModel: null, roomoteVisionModel: null, }, @@ -1621,42 +1629,29 @@ describe('buildSetupModelStatus', () => { expect(status.setupSatisfied).toBe(true); expect( - status.providers.find((provider) => provider.id === 'openrouter'), + status.providers.find((provider) => provider.id === 'roomote'), ).toMatchObject({ runtimeApiKeySatisfied: true, savedApiKeySatisfied: false, - trialKeySatisfied: true, }); + expect( + status.providers.find((provider) => provider.id === 'openrouter'), + ).toMatchObject({ runtimeApiKeySatisfied: false }); }); - it('does not flag the trial when a real openrouter key is configured', () => { - const runtimeStatus = buildSetupModelStatus({ + it('keeps OpenRouter separate from the hosting-managed Roomote key', () => { + const status = buildSetupModelStatus({ runtimeEnv: { - OPENROUTER_API_KEY: 'sk-runtime', R_TRIAL_OPENROUTER_API_KEY: 'sk-trial', }, persistedEnvVarNames: [], }); expect( - runtimeStatus.providers.find((provider) => provider.id === 'openrouter'), - ).toMatchObject({ - runtimeApiKeySatisfied: true, - trialKeySatisfied: false, - }); - - const savedStatus = buildSetupModelStatus({ - runtimeEnv: { - R_TRIAL_OPENROUTER_API_KEY: 'sk-trial', - }, - persistedEnvVarNames: ['OPENROUTER_API_KEY'], - }); - - expect( - savedStatus.providers.find((provider) => provider.id === 'openrouter'), + status.providers.find((provider) => provider.id === 'openrouter'), ).toMatchObject({ - savedApiKeySatisfied: true, - trialKeySatisfied: false, + runtimeApiKeySatisfied: false, + savedApiKeySatisfied: false, }); }); diff --git a/packages/types/src/model-provider-config.ts b/packages/types/src/model-provider-config.ts index 64a6de7e9..b17f6a42b 100644 --- a/packages/types/src/model-provider-config.ts +++ b/packages/types/src/model-provider-config.ts @@ -9,6 +9,7 @@ import { import { OPENROUTER_RECOMMENDED_TASK_MODEL_SLUGS, mapRecommendedTaskModels, + type RecommendedTaskModelSlugMap, type SuggestedTaskModel, } from './recommended-task-models'; import { @@ -46,6 +47,11 @@ export const CHATGPT_SUBSCRIPTION_PROVIDER_ID = 'chatgpt' as const; */ export const XAI_SUBSCRIPTION_PROVIDER_ID = 'xai-subscription' as const; +/** Hosting-managed inference backed by Roomote credits. */ +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; + /** * Model-id prefix used when composing or looking up task models for a setup * catalog provider. Subscription connect surfaces are not prefixes: ChatGPT @@ -69,6 +75,7 @@ export function getSetupProviderTaskModelPrefix( export const OPENCODE_GO_API_KEY_ENV_VAR_NAME = 'OPENCODE_GO_API_KEY' as const; export const SETUP_MODEL_PROVIDER_IDS = [ + ROOMOTE_INFERENCE_PROVIDER_ID, 'openrouter', ...ENABLED_DIRECT_TASK_MODEL_PROVIDER_IDS, CHATGPT_SUBSCRIPTION_PROVIDER_ID, @@ -408,6 +415,39 @@ const OPENAI_RECOMMENDED_MODEL_PRESETS = [ ] as const satisfies readonly 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', + authKind: 'api-key', + suggestedTaskModels: mapRecommendedTaskModels( + Object.fromEntries( + Object.entries(OPENROUTER_RECOMMENDED_TASK_MODEL_SLUGS).map( + ([modelId, openRouterModelId]) => [ + modelId, + openRouterModelId.replace(/^openrouter\//u, 'roomote/'), + ], + ), + ) 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' }, + }, + }, + ], + // Only hosting can enable this provider. It is never a user connection. + hidden: true, + }, { id: 'openrouter', label: 'OpenRouter', @@ -1380,13 +1420,6 @@ export function getDefaultRecommendedModelPreset( export type SetupModelProviderStatus = SetupModelProviderDescriptor & { runtimeApiKeySatisfied: boolean; savedApiKeySatisfied: boolean; - /** - * True when `runtimeApiKeySatisfied` holds only because a free-trial - * fallback credential (`TRIAL_MODEL_PROVIDER_ENV_VAR_FALLBACKS`) is - * present. The UI keeps the connect/edit affordances in this state so an - * operator can save their own key, which then outranks the trial key. - */ - trialKeySatisfied?: boolean; additionalEnvValues: Record; }; @@ -1810,39 +1843,6 @@ export function isConfiguredEnvValue( return normalizeOptionalString(value) !== null; } -/** - * Free-trial fallback credentials, keyed by the provider credential they - * stand in for. A hosting provisioner can inject a capped, Roomote-minted - * key under the fallback name; it acts as the provider's credential only - * when the primary name is configured nowhere (runtime env or saved), so - * connecting a real key always wins. Runtime-env only by design: the values - * are never persisted, never editable in Settings, and stay on the control - * plane like any other inference-gateway-served key. - */ -export const TRIAL_MODEL_PROVIDER_ENV_VAR_FALLBACKS: Readonly< - Partial> -> = { - OPENROUTER_API_KEY: 'R_TRIAL_OPENROUTER_API_KEY', -}; - -export function getTrialModelProviderEnvVarName( - primaryEnvVarName: string, -): string | undefined { - return TRIAL_MODEL_PROVIDER_ENV_VAR_FALLBACKS[primaryEnvVarName]; -} - -export function isTrialModelProviderEnvVarConfigured( - primaryEnvVarName: string, - runtimeEnv: Partial>, -): boolean { - const trialEnvVarName = getTrialModelProviderEnvVarName(primaryEnvVarName); - - return ( - trialEnvVarName !== undefined && - isConfiguredEnvValue(runtimeEnv[trialEnvVarName]) - ); -} - /** Legacy Google Vertex credential name, reserved and stripped while the provider is disabled. */ const GOOGLE_APPLICATION_CREDENTIALS_ENV_VAR_NAME = 'GOOGLE_APPLICATION_CREDENTIALS'; @@ -2024,7 +2024,6 @@ export function buildSetupModelStatus(input: { additionalEnvValues: {}, runtimeApiKeySatisfied: false, savedApiKeySatisfied: oauthConnected, - trialKeySatisfied: false, }; } @@ -2036,11 +2035,8 @@ export function buildSetupModelStatus(input: { const requiredEnvVarNames = getSetupModelProviderRequiredEnvVarNames(provider); const hasRequiredEnvVars = requiredEnvVarNames.length > 0; - // A free-trial fallback credential satisfies its primary name at runtime - // so setup skips the inference step and the provider reads as connected. const isRuntimeConfigured = (name: string) => - isConfiguredEnvValue(runtimeEnv[name]) || - isTrialModelProviderEnvVarConfigured(name, runtimeEnv); + isConfiguredEnvValue(runtimeEnv[name]); const isPersisted = (name: string) => persistedEnvVarNameSet.has(name); const additionalEnvValues = Object.fromEntries( [ @@ -2076,21 +2072,11 @@ export function buildSetupModelStatus(input: { (name) => isPersisted(name) || isRuntimeConfigured(name), ) && requiredEnvVarNames.some(isPersisted); - const trialKeySatisfied = - runtimeApiKeySatisfied && - !savedApiKeySatisfied && - requiredEnvVarNames.some( - (name) => - !isConfiguredEnvValue(runtimeEnv[name]) && - isTrialModelProviderEnvVarConfigured(name, runtimeEnv), - ); - return { ...provider, additionalEnvValues, runtimeApiKeySatisfied, savedApiKeySatisfied, - trialKeySatisfied, }; }; diff --git a/packages/types/src/opencode-provider-config.ts b/packages/types/src/opencode-provider-config.ts index 741412ebb..88d81c7ea 100644 --- a/packages/types/src/opencode-provider-config.ts +++ b/packages/types/src/opencode-provider-config.ts @@ -6,6 +6,7 @@ import { OPENAI_COMPATIBLE_PROVIDER_ID, type OpenAiCompatibleProviderInstance, } from './openai-compatible-providers'; +import { ROOMOTE_INFERENCE_API_KEY_ENV_VAR_NAME } from './model-provider-config'; /** Fallback direct-mode base URL for default and named OpenAI-compatible ids. */ const OPENAI_COMPATIBLE_DEFAULT_FALLBACK_BASE_URL = 'http://127.0.0.1:4000/v1'; @@ -46,6 +47,14 @@ export function parseTaskModelContextWindows( } const STATIC_OPENAI_COMPATIBLE_PROVIDER_CONFIGS = { + roomote: { + name: 'Roomote inference', + baseUrlEnvVarName: 'ROOMOTE_INFERENCE_BASE_URL', + fallbackBaseUrl: 'https://openrouter.ai/api/v1', + apiKeyEnvVarName: ROOMOTE_INFERENCE_API_KEY_ENV_VAR_NAME, + keyless: false, + allowOpenAiEnvFallback: false, + }, [OPENAI_COMPATIBLE_PROVIDER_ID]: { name: DEFAULT_OPENAI_COMPATIBLE_INSTANCE.label, baseUrlEnvVarName: DEFAULT_OPENAI_COMPATIBLE_INSTANCE.baseUrlEnvVarName, From d1345b2f4d97313048b1d86caa8d13b716343483 Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Thu, 27 Aug 2026 16:28:59 +0100 Subject: [PATCH 10/17] feat: show managed inference credits --- .../InferenceProviderSection.test.tsx | 13 +++++++ .../settings/InferenceProviderSection.tsx | 34 +++++++++---------- .../settings/ProviderCreditBalanceLine.tsx | 10 +++--- .../__tests__/provider-credit-balance.test.ts | 30 ++++++++++++++++ .../db/src/lib/provider-credit-balance.ts | 28 ++++++++++++++- packages/types/src/provider-credit-balance.ts | 6 ++-- 6 files changed, 95 insertions(+), 26 deletions(-) diff --git a/apps/web/src/components/settings/InferenceProviderSection.test.tsx b/apps/web/src/components/settings/InferenceProviderSection.test.tsx index d6ee7b13f..0ccb85843 100644 --- a/apps/web/src/components/settings/InferenceProviderSection.test.tsx +++ b/apps/web/src/components/settings/InferenceProviderSection.test.tsx @@ -916,10 +916,23 @@ describe('InferenceProviderSection', () => { providerSetupData.current = buildProviderSetup({ managedRoomote: true, }); + providerCreditsData.current = [ + { + providerId: 'roomote', + remaining: 3, + limit: 5, + currency: 'USD', + fetchedAt: new Date().toISOString(), + }, + ]; renderInferenceProviderSection(); expect(screen.getByText('Roomote inference')).toBeInTheDocument(); + expect(screen.getByText(/Credits:.*left/i)).toBeInTheDocument(); + expect( + screen.getByRole('progressbar', { name: 'Credit balance' }), + ).toBeInTheDocument(); expect( screen.queryByRole('button', { name: /edit roomote inference/i }), ).not.toBeInTheDocument(); diff --git a/apps/web/src/components/settings/InferenceProviderSection.tsx b/apps/web/src/components/settings/InferenceProviderSection.tsx index 5a2c3ac35..37d42a95f 100644 --- a/apps/web/src/components/settings/InferenceProviderSection.tsx +++ b/apps/web/src/components/settings/InferenceProviderSection.tsx @@ -55,10 +55,11 @@ 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)] md:items-center'; + 'grid gap-2 md:grid-cols-[minmax(160px,220px)_minmax(0,1fr)]'; type InferenceProviderSectionProps = { providerSetup: SetupModelStatus | null; @@ -156,10 +157,16 @@ function ConnectedProviderRow({ {provider.label} -

- Managed by Roomote with free 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. +

+ +
); } @@ -180,7 +187,7 @@ function ConnectedProviderRow({ return (
-
+
{provider.label} @@ -188,16 +195,8 @@ function ConnectedProviderRow({
- - +
{hasRuntimeKey ? ( @@ -1382,8 +1381,9 @@ export function InferenceProviderSection({ : undefined } creditBalance={ - provider.id === 'openrouter' - ? creditBalanceByProvider.get('openrouter') + provider.id === 'openrouter' || + provider.id === ROOMOTE_INFERENCE_PROVIDER_ID + ? creditBalanceByProvider.get(provider.id) : undefined } isSaving={savingProviderId === provider.id} diff --git a/apps/web/src/components/settings/ProviderCreditBalanceLine.tsx b/apps/web/src/components/settings/ProviderCreditBalanceLine.tsx index 433268f8c..abe38663e 100644 --- a/apps/web/src/components/settings/ProviderCreditBalanceLine.tsx +++ b/apps/web/src/components/settings/ProviderCreditBalanceLine.tsx @@ -59,22 +59,22 @@ export function ProviderCreditBalanceLine({ const remainingLabel = formatMoney(balance.remaining, currency); const label = balance.limit !== undefined - ? `Credits: ${remainingLabel} of ${formatMoney(balance.limit, currency)} left` - : `Credits: ${remainingLabel} left`; + ? `${remainingLabel} of ${formatMoney(balance.limit, currency)} left` + : `${remainingLabel} left`; const usedPercent = getUsedPercent(balance); return ( -
-

{label}

+
{usedPercent !== undefined ? ( ) : null} +

{label}

); } 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( [