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 fc9a5f6b2..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); @@ -260,6 +264,70 @@ describe('inference gateway', () => { } }); + it('routes Roomote models through OpenRouter with only the managed key', async () => { + const fetchMock = stubUpstreamFetch(); + const response = await appRequest( + createApp(createRunToken()), + '/api/inference/roomote/v1/chat/completions', + { model: 'roomote/openai/gpt-5.6-luna', messages: [] }, + ); + + expect(response.status).toBe(200); + expect(mockResolveModelProviderEnvValue).toHaveBeenCalledWith([ + 'R_TRIAL_OPENROUTER_API_KEY', + ]); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://openrouter.ai/api/v1/chat/completions'); + expect(new Headers(init.headers).get('authorization')).toBe( + 'Bearer provider-secret-key', + ); + expect(JSON.parse(String(init.body))).toMatchObject({ + model: 'openai/gpt-5.6-luna', + }); + }); + + it('returns an actionable response when managed Roomote inference is unavailable', async () => { + mockResolveModelProviderEnvValue.mockResolvedValue(undefined); + const fetchMock = stubUpstreamFetch(); + + const response = await appRequest( + createApp(createRunToken()), + '/api/inference/roomote/v1/chat/completions', + { model: 'roomote/openai/gpt-5.6-luna', messages: [] }, + ); + + expect(response.status).toBe(404); + await expect(response.json()).resolves.toMatchObject({ + error: + 'Roomote inference is unavailable. Connect an inference provider to continue.', + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('returns an actionable response when Roomote inference credits are exhausted', async () => { + stubUpstreamFetch( + new Response( + JSON.stringify({ error: { message: 'insufficient credits' } }), + { + status: 402, + headers: { 'content-type': 'application/json' }, + }, + ), + ); + + const response = await appRequest( + createApp(createRunToken()), + '/api/inference/roomote/v1/chat/completions', + { model: 'roomote/openai/gpt-5.6-luna', messages: [] }, + ); + + expect(response.status).toBe(402); + await expect(response.json()).resolves.toMatchObject({ + error: + 'Roomote inference credits are exhausted. Connect an inference provider to continue.', + }); + }); + it.each([ [ 'azure', diff --git a/apps/api/src/handlers/inference/index.ts b/apps/api/src/handlers/inference/index.ts index 087ceb90d..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'; @@ -153,6 +157,39 @@ function buildInferenceResponseHeaders(upstreamHeaders: Headers): Headers { return 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); + + if (!body || typeof body !== 'object' || Array.isArray(body)) { + return bodyText; + } + + const request = body as Record; + const model = request.model; + const upstreamModel = + typeof model === 'string' ? rebaseRoomoteModelIdToUpstream(model) : null; + if (upstreamModel === null) { + return bodyText; + } + + return JSON.stringify({ + ...request, + model: upstreamModel, + }); + } catch { + return bodyText; + } +} + /** * The upstream path is everything after the provider segment. The router * only matches `/:provider/*`, so the marker is always present. @@ -365,12 +402,12 @@ 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); + // 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 = bodyText; @@ -381,6 +418,13 @@ inference.on(['POST', 'GET'], '/:provider/*', async (c) => { } } + // 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, @@ -417,6 +461,19 @@ inference.on(['POST', 'GET'], '/:provider/*', async (c) => { elapsedMs: Date.now() - startedAt, }), ); + + if ( + providerId === ROOMOTE_INFERENCE_PROVIDER_ID && + upstreamResponse.status === 402 + ) { + return c.json( + { + error: + 'Roomote inference credits are exhausted. Connect an inference provider to continue.', + }, + 402, + ); + } } return new Response( diff --git a/apps/api/src/handlers/inference/registry.ts b/apps/api/src/handlers/inference/registry.ts index 27e8808c7..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 @@ -75,7 +119,10 @@ export async function resolveGatewayUpstream( return { ok: false, status: 404, - error: `No ${provider.name} API key is configured for this deployment`, + error: + 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/docs/models.mdx b/apps/docs/models.mdx index 067c749fb..10d03e8de 100644 --- a/apps/docs/models.mdx +++ b/apps/docs/models.mdx @@ -22,6 +22,16 @@ You can connect more than one inference provider in the same deployment. That lets you mix and match models by provider instead of betting the whole deployment on one account, one vendor, or one model family. +### Managed Roomote inference + +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. 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: - an OpenRouter-routed model for the default coding model diff --git a/apps/web/src/app/(onboarding)/setup/SetupDocs.client.test.tsx b/apps/web/src/app/(onboarding)/setup/SetupDocs.client.test.tsx index dc9f5b568..f03711bda 100644 --- a/apps/web/src/app/(onboarding)/setup/SetupDocs.client.test.tsx +++ b/apps/web/src/app/(onboarding)/setup/SetupDocs.client.test.tsx @@ -18,6 +18,7 @@ describe('SetupDocs', () => { expect(getSetupDocsPath('slack', { authProvider: 'microsoft' })).toBe( 'providers/communications/microsoft-teams', ); + expect(getSetupDocsPath('inference')).toBe('models'); expect( getSetupDocsPath('source-control-connect', { sourceControlProvider: 'github', diff --git a/apps/web/src/app/(onboarding)/setup/SetupSignedInFlow.tsx b/apps/web/src/app/(onboarding)/setup/SetupSignedInFlow.tsx index d9be3fbd8..13f129f79 100644 --- a/apps/web/src/app/(onboarding)/setup/SetupSignedInFlow.tsx +++ b/apps/web/src/app/(onboarding)/setup/SetupSignedInFlow.tsx @@ -31,6 +31,7 @@ import { import { StepTelegramSetup } from './StepTelegramSetup'; import { StepDiscordSetup } from './StepDiscordSetup'; import { StepInferenceProvider } from './StepInferenceProvider'; +import { StepConfigureInference } from './StepConfigureInference'; import { StepComputeProvider } from './StepComputeProvider'; import { StepComputeConfig } from './StepComputeConfig'; import { StepSourceControlProvider } from './StepSourceControlProvider'; @@ -352,6 +353,15 @@ export function SetupSignedInFlow() { bootstrapMode={false} /> ))} + {step === 'inference' && ( + + goToStep('env-vars', { revisit: true }) + } + onBack={canGoBack ? goToPreviousStep : undefined} + /> + )} {step === 'env-vars' && ( ({ + mutateMock: vi.fn(), +})); + +vi.mock('@/trpc/client', () => ({ + useTRPC: () => ({ + setupNew: { + chooseTrialInference: { + mutationOptions: (options: Record) => options, + }, + status: { + queryKey: () => ['setupNew.status'], + }, + }, + }), +})); + +vi.mock('@tanstack/react-query', async () => { + const actual = await vi.importActual('@tanstack/react-query'); + return { + ...actual, + useMutation: vi.fn(), + useQueryClient: vi.fn(), + }; +}); + +vi.mock('lucide-react', () => ({ + Gift: (props: SVGProps) => ( + + ), + Plug: (props: SVGProps) => ( + + ), +})); + +vi.mock('@/components/system', () => ({ + ArrowRight: (props: SVGProps) => , + Button: ({ + children, + size: _size, + variant: _variant, + ...props + }: { + children: ReactNode; + size?: string; + variant?: string; + } & ButtonHTMLAttributes) => ( + + ), + Spinner: (props: SVGProps) => , +})); + +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); + +import { StepConfigureInference } from './StepConfigureInference'; + +describe('StepConfigureInference', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseMutation.mockReturnValue({ + mutate: mutateMock, + isPending: false, + } as unknown as ReturnType); + mockUseQueryClient.mockReturnValue({ + invalidateQueries: vi.fn(), + } as unknown as ReturnType); + }); + + it('renders the trial and custom choices with the requested copy', () => { + render( + , + ); + + expect( + screen.getByRole('heading', { name: 'Configure inference' }), + ).toBeInTheDocument(); + expect(screen.getByText(/Roomote needs a model provider/).textContent).toBe( + 'Roomote needs a model provider for, you know, AI stuff.If you want, we can give you a few credits to try Roomote out or you can configure your provider directly.', + ); + expect( + screen.getByRole('button', { + name: 'Use free Roomote trial inference', + }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Configure your provider' }), + ).toBeInTheDocument(); + expect(screen.getByTestId('gift')).toBeInTheDocument(); + expect(screen.getByTestId('plug')).toBeInTheDocument(); + expect( + screen.getByText( + 'Roomote inference includes a few free credits when it is available.', + ), + ).toBeInTheDocument(); + }); + + it('starts trial inference and advances after the setup mutation succeeds', async () => { + const onUseTrial = vi.fn(); + render( + , + ); + + fireEvent.click( + screen.getByRole('button', { + name: 'Use free Roomote trial inference', + }), + ); + + expect(mutateMock).toHaveBeenCalledOnce(); + + const options = mockUseMutation.mock.calls[0]?.[0] as + | { onSuccess?: () => Promise | void } + | undefined; + await options?.onSuccess?.(); + expect(onUseTrial).toHaveBeenCalledOnce(); + }); + + it('opens custom provider configuration without mutating trial state', () => { + const onConfigureProvider = vi.fn(); + render( + , + ); + + fireEvent.click( + screen.getByRole('button', { name: 'Configure your provider' }), + ); + + 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 new file mode 100644 index 000000000..e57697071 --- /dev/null +++ b/apps/web/src/app/(onboarding)/setup/StepConfigureInference.tsx @@ -0,0 +1,96 @@ +'use client'; + +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { Gift, Plug } from 'lucide-react'; +import { toast } from 'sonner'; + +import { useTRPC } from '@/trpc/client'; +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'); + +export function StepConfigureInference({ + onUseTrial, + onConfigureProvider, + onBack, +}: { + onUseTrial: () => void; + onConfigureProvider: () => void; + onBack?: () => void; +}) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const chooseTrialInference = useMutation( + trpc.setupNew.chooseTrialInference.mutationOptions({ + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: trpc.setupNew.status.queryKey(), + }); + onUseTrial(); + }, + onError: (error) => toast.error(error.message), + }), + ); + const choiceButtonClassName = cn( + 'group flex w-full items-center gap-3 py-5', + 'hover:text-accent-foreground hover:bg-foreground', + ); + + return ( +
+ +
+

+ Roomote needs a model provider for, you know, AI stuff. +
+ If you want, we can give you a few credits to try Roomote out or you + can configure your provider directly. +

+ +
+ + +
+ +

+ 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 2bdfb7062..5a5bf22dc 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', () => ({ @@ -272,9 +273,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 +784,41 @@ describe('StepInferenceProvider ChatGPT subscription', () => { expect(onContinue).toHaveBeenCalled(); }); }); + +describe('StepInferenceProvider managed Roomote inference', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseQueryClient.mockReturnValue({ + invalidateQueries: vi.fn(), + } as unknown as ReturnType); + setupMutationMock(); + setupQueryMocks({ chatgptConnected: false }); + }); + + function managedRoomoteProviderStatus(): SetupModelStatus['providers'][number] { + return { + ...openrouterProviderStatus(), + id: 'roomote', + label: 'Roomote inference', + runtimeApiKeySatisfied: true, + }; + } + + it('does not offer managed Roomote inference as a selectable connection', () => { + render( + , + ); + + 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 0b77dc861..e6098db4f 100644 --- a/apps/web/src/app/(onboarding)/setup/StepInferenceProvider.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepInferenceProvider.tsx @@ -219,13 +219,13 @@ 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].sort((left, right) => - left.label.localeCompare(right.label), - ), + modelSetup.providers + .filter((provider) => !provider.hidden) + .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 dec441cdc..4ecf594b3 100644 --- a/apps/web/src/app/(onboarding)/setup/hooks.client.test.tsx +++ b/apps/web/src/app/(onboarding)/setup/hooks.client.test.tsx @@ -171,6 +171,31 @@ function mockStatus(overrides: Partial> = {}) { } as unknown as ReturnType); } +function trialModelSetup(overrides: Partial> = {}) { + return { + runtimeRoomoteModel: null, + runtimeRoomoteModelSatisfied: false, + runtimeProviderId: 'roomote', + persistedRoomoteModel: null, + persistedProviderId: null, + preselectedProvider: 'roomote', + setupSatisfied: true, + setupSatisfiedByRuntimeEnv: true, + chatgptConnected: false, + providers: [ + { + id: 'roomote', + label: 'Roomote inference', + // The imported Settings row, not the hosting-injected env variable, + // is what connects the provider. + runtimeApiKeySatisfied: false, + savedApiKeySatisfied: true, + }, + ], + ...overrides, + }; +} + function mockReadyForRepository({ onboardingTaskId = null, selectedRepositoryIds = [], @@ -292,6 +317,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: 'roomote', + 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 @@ -372,6 +458,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(); diff --git a/apps/web/src/app/(onboarding)/setup/hooks.ts b/apps/web/src/app/(onboarding)/setup/hooks.ts index a3d0193cf..6813ef6b6 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'; @@ -47,6 +48,7 @@ const PINNABLE_SETUP_STEPS: readonly SetupStep[] = [ 'auth-provider', 'auth-env-vars', 'slack', + 'inference', 'env-vars', 'source-control-provider', 'source-control-config', @@ -393,6 +395,27 @@ export function useSetupFlow( )?.setupSatisfied ?? false) ); + case 'inference': { + const trialInferenceAvailable = status.modelSetup.providers?.some( + (provider) => + provider.id === ROOMOTE_INFERENCE_PROVIDER_ID && + provider.savedApiKeySatisfied, + ); + const operatorProviderConfigured = status.modelSetup.providers?.some( + (provider) => + provider.id !== ROOMOTE_INFERENCE_PROVIDER_ID && + (provider.savedApiKeySatisfied || + provider.runtimeApiKeySatisfied), + ); + + 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', diff --git a/apps/web/src/components/settings/InferenceProviderSection.test.tsx b/apps/web/src/components/settings/InferenceProviderSection.test.tsx index cddc4b59c..9f9d3dfbf 100644 --- a/apps/web/src/components/settings/InferenceProviderSection.test.tsx +++ b/apps/web/src/components/settings/InferenceProviderSection.test.tsx @@ -161,6 +161,7 @@ function buildProviderSetup( overrides: { openrouterRuntimeKey?: boolean; openrouterSavedKey?: boolean; + managedRoomote?: boolean; openaiSavedKey?: boolean; anthropicSavedKey?: boolean; chatgptConnected?: boolean; @@ -184,6 +185,24 @@ 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, + // The imported Settings row, not the hosting-injected env + // variable, is what connects the provider. + runtimeApiKeySatisfied: false, + savedApiKeySatisfied: true, + additionalEnvValues: {} satisfies Record, + }, + ] + : []), { id: 'openrouter' as SetupModelProviderId, label: 'OpenRouter', @@ -534,7 +553,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(); @@ -663,7 +684,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(); @@ -762,7 +785,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, }); @@ -770,10 +793,6 @@ describe('InferenceProviderSection', () => { renderInferenceProviderSection(); - expect(screen.getByLabelText('API key for Anthropic')).toHaveValue( - '••••••••••••••••••••••••••••', - ); - fireEvent.click( screen.getByRole('button', { name: 'Edit Anthropic API key' }), ); @@ -895,17 +914,78 @@ describe('InferenceProviderSection', () => { expect(mutateAsyncMock).toHaveBeenCalledWith({ provider: 'anthropic' }); }); - it('locks a runtime env-managed key behind a masked field and lock tooltip', () => { + it('shows managed Roomote inference separately while OpenRouter remains addable', () => { + 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(/3[.,]00.*of.*5[.,]00.*left/i)).toBeInTheDocument(); + expect( + screen.getByRole('progressbar', { name: 'Credit balance' }), + ).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('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, }); 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/InferenceProviderSection.tsx b/apps/web/src/components/settings/InferenceProviderSection.tsx index 5c1f4a22c..f2f3a01ac 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, @@ -55,12 +56,18 @@ import { XaiConnectDialog } from '@/components/settings/XaiConnectDialog'; import { ProviderCreditBalanceLine } from '@/components/settings/ProviderCreditBalanceLine'; import { SubscriptionUsageLine } from '@/components/settings/SubscriptionUsageLine'; -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; + /** + * 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[]; @@ -149,6 +156,47 @@ function ConnectedProviderRow({ onEdit: () => void; onDelete: () => void; }) { + if (provider.id === ROOMOTE_INFERENCE_PROVIDER_ID) { + return ( +
+ + {provider.label} + +
+
+

+ Managed by Roomote with free trial 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 @@ -157,15 +205,9 @@ 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 (
-
+
{provider.label} @@ -173,16 +215,8 @@ function ConnectedProviderRow({
- - +
{hasRuntimeKey ? ( @@ -835,6 +869,7 @@ function DeleteProviderDialog({ export function InferenceProviderSection({ providerSetup, + trialInferenceActive = false, providerSetupPending, connectedProviders, availableProviders, @@ -1240,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); @@ -1367,12 +1410,13 @@ 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} - 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 8a4c8b9df..88f1a827d 100644 --- a/apps/web/src/components/settings/ModelSettingsSection.tsx +++ b/apps/web/src/components/settings/ModelSettingsSection.tsx @@ -751,11 +751,13 @@ 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].sort((left, right) => - left.label.localeCompare(right.label), - ), + connectedProviders + .filter((provider) => !provider.hidden) + .sort((left, right) => left.label.localeCompare(right.label)), [connectedProviders], ); const chatgptConnected = sortedConnectedProviders.some( 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(); }); }); 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/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}
{ + 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 13a028a72..9c6afdad3 100644 --- a/apps/web/src/trpc/commands/setup-new/index.test.ts +++ b/apps/web/src/trpc/commands/setup-new/index.test.ts @@ -259,8 +259,13 @@ import { trackSetupBootstrapWelcomeSeenCommand, trackSetupCommsStateCommand, trackSetupWelcomeSeenCommand, + chooseSetupTrialInferenceCommand, + importTrialInferenceKeyIfNeeded, } 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'; @@ -1339,3 +1344,270 @@ describe('setup recommendation commands', () => { }); }); }); + +describe('chooseSetupTrialInferenceCommand', () => { + function createTxStub(row: Record) { + // Records only the config upserts; the setup-state save and the bare row + // insert that backs the FOR UPDATE lock stay out of assertions. + const inserted: Array> = []; + const tx = { + 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) => { + if ('runtimeModelConfig' in values || 'taskModelSettings' in values) { + inserted.push(values); + } + return { + onConflictDoUpdate: vi.fn(async () => undefined), + onConflictDoNothing: vi.fn(async () => undefined), + }; + }), + })), + }; + + // 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 }; + } + + beforeEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + 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(() => { + vi.unstubAllEnvs(); + }); + + 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, + taskModelSettings: null, + }); + mockDbTransaction.mockImplementation( + async (callback: (tx: unknown) => Promise) => callback(tx), + ); + + const result = await chooseSetupTrialInferenceCommand(buildMockAuth()); + + expect(result.setupNewState.modelProvider).toBe('roomote'); + const runtimeModelConfigInsert = inserted.find( + (values) => 'runtimeModelConfig' in values, + ); + const taskModelSettingsInsert = inserted.find( + (values) => 'taskModelSettings' in values, + ); + + expect(runtimeModelConfigInsert?.runtimeModelConfig).toMatchObject({ + 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: 'roomote/openai/gpt-5.6-luna', + }); + }); + + 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'); + }); + + 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: {}, + runtimeModelConfig: null, + taskModelSettings: null, + }); + mockDbTransaction.mockImplementation( + async (callback: (tx: unknown) => Promise) => callback(tx), + ); + + await expect( + chooseSetupTrialInferenceCommand(buildMockAuth()), + ).rejects.toThrow('already connected'); + expect(inserted).toEqual([]); + }); + + 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: { modelProvider: 'roomote' }, + runtimeModelConfig: null, + taskModelSettings: { + allowedModelIds: ['roomote/openai/gpt-5.6-terra'], + defaultModelId: 'roomote/openai/gpt-5.6-terra', + }, + }); + mockDbTransaction.mockImplementation( + async (callback: (tx: unknown) => Promise) => callback(tx), + ); + + await chooseSetupTrialInferenceCommand(buildMockAuth()); + + 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([ + 'R_TRIAL_OPENROUTER_API_KEY', + ]); + 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), + ); + + const result = await chooseSetupTrialInferenceCommand(buildMockAuth()); + + 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 7f89857ee..1945a5c1d 100644 --- a/apps/web/src/trpc/commands/setup-new/index.ts +++ b/apps/web/src/trpc/commands/setup-new/index.ts @@ -54,6 +54,9 @@ 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, collectSetupModelProviderCredentialValues, createEmptyDeploymentModelConfig, @@ -287,6 +290,236 @@ async function savePersistedTaskModelSettings( // Persisted runtime compute config helpers are shared with the compute // settings commands and imported from '../compute'. +/** + * Imports the hosting-injected Roomote inference key from the process + * 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' }) + .onConflictDoNothing(); + await tx + .select({ id: deploymentSettings.id }) + .from(deploymentSettings) + .where(eq(deploymentSettings.id, 'default')) + .for('update'); + + // 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: injectedValue!, + }, + ], + }); + + 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); + + const { userId } = auth; + await importTrialInferenceKeyIfNeeded(userId); + + return db.transaction(async (tx) => { + // Serialize against concurrent configuration writes: every check below + // must observe the row state the 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, + persistedEnvVarNames, + chatgptConnected, + githubCopilotConnected, + xaiSubscriptionConnected, + ] = await Promise.all([ + getPersistedSetupNewState(tx), + getPersistedRuntimeModelConfig(tx), + getPersistedEnvironmentVariableNames(tx), + isChatGptSubscriptionConnected(), + isGitHubCopilotSubscriptionConnected(), + isXaiSubscriptionConnected(), + ]); + + // Any prior inference choice wins: a repeat click (or a stale wizard tab) + // 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 || + Object.values(persistedModelConfig).some((value) => value !== null); + + if (hasModelChoices) { + return { + setupNewState: currentState, + runtimeModelConfig: persistedModelConfig, + }; + } + + const status = buildSetupModelStatus({ + runtimeEnv: process.env, + persistedEnvVarNames, + chatgptConnected, + 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 && + (provider.savedApiKeySatisfied || provider.runtimeApiKeySatisfied), + ); + + if (hasOperatorProvider) { + throw new Error( + 'A model provider is already connected, so the free trial is not needed.', + ); + } + + const provider = getSetupModelProvider(ROOMOTE_INFERENCE_PROVIDER_ID); + const runtimeModelConfig = buildRecommendedDeploymentModelConfig( + provider, + ROOMOTE_TRIAL_MODEL_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, + lastInteractedByUserId: userId, + }); + + await Promise.all([ + savePersistedSetupNewState(setupNewState, tx), + savePersistedRuntimeModelConfig(runtimeModelConfig, tx), + savePersistedTaskModelSettings( + normalizeTaskModelSettings({ + models: trialModels, + allowedModelIds: trialModels.map((model) => model.id), + defaultModelId: defaultModelId ?? provider.defaultRoomoteModel, + }), + tx, + ), + ]); + + return { setupNewState, runtimeModelConfig }; + }); +} + async function resolveSelectedRepositories(repositoryIds: string[]): Promise<{ normalizedRepositoryIds: string[]; selectedRepositories: SelectedRepositorySummary[]; @@ -1100,6 +1333,7 @@ export async function getSetupNewStatusCommand(auth: UserAuthSuccess) { const { userId } = auth; await purgeSavedDeploymentWorkerImage(); + await importTrialInferenceKeyIfNeeded(userId); const [ baseStatus, @@ -1342,6 +1576,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 af8e8a9c8..100fa30af 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, @@ -73,6 +74,39 @@ 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, 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; + } + } + + 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', () => ({ @@ -96,6 +130,7 @@ import { } from './index'; const PROVIDER_ENV_VAR_NAMES = [ + 'R_TRIAL_OPENROUTER_API_KEY', 'OPENROUTER_API_KEY', 'OPENAI_API_KEY', 'AZURE_API_KEY', @@ -447,6 +482,44 @@ describe('lookupTaskModelCommand', () => { ); }); + it('uses the managed Roomote key to look up Roomote model metadata through OpenRouter', async () => { + // 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({ + 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( @@ -1730,6 +1803,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([]) @@ -2017,6 +2098,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. @@ -2092,6 +2226,16 @@ describe('task model provider commands', () => { expect(txOnConflictDoUpdate).not.toHaveBeenCalled(); }); + 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('does not have saved credentials'); + + 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 f9441cb95..b7f644efe 100644 --- a/apps/web/src/trpc/commands/task-models/index.ts +++ b/apps/web/src/trpc/commands/task-models/index.ts @@ -9,10 +9,14 @@ import { isGitHubCopilotSubscriptionConnected, isXaiSubscriptionConnected, isNull, + resolveModelProviderEnvValue, type DatabaseOrTransaction, } 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, TASK_MODEL_CATALOG, TASK_MODEL_ROLE_DESCRIPTORS, @@ -369,7 +373,7 @@ export async function getPersistedRawTaskModelSettings( */ export async function getTaskModelProviderSetupCommand( auth: UserAuthSuccess, -): Promise<{ providerSetup: SetupModelStatus }> { +): Promise<{ providerSetup: SetupModelStatus; trialInferenceActive: boolean }> { assertAdmin(auth); const [ @@ -379,6 +383,7 @@ export async function getTaskModelProviderSetupCommand( chatgptConnected, githubCopilotConnected, xaiSubscriptionConnected, + persistedTaskModelSettings, ] = await Promise.all([ getDeploymentRuntimeModelConfig(), getPersistedEnvironmentVariableNames(), @@ -386,6 +391,7 @@ export async function getTaskModelProviderSetupCommand( isChatGptSubscriptionConnected(), isGitHubCopilotSubscriptionConnected(), isXaiSubscriptionConnected(), + getDeploymentTaskModelSettings(), ]); // Include non-secret OpenAI-compatible env values (base URLs + connection @@ -428,7 +434,14 @@ export async function getTaskModelProviderSetupCommand( xaiSubscriptionConnected, }); - return { providerSetup }; + return { + providerSetup, + trialInferenceActive: isTrialInferenceActive({ + modelProvider: setupNewState.modelProvider, + taskModelSettings: persistedTaskModelSettings, + runtimeModelConfig: persistedRuntimeModelConfig, + }), + }; } /** @@ -584,6 +597,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.`, @@ -826,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; } @@ -913,6 +973,10 @@ export async function deleteTaskModelProviderCommand( const provider = getSetupModelProvider(input.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.`, @@ -925,6 +989,7 @@ export async function deleteTaskModelProviderCommand( persistedEnvVarNames, setupNewState, chatgptConnected, + githubCopilotConnected, xaiSubscriptionConnected, persistedTaskModelSettings, ] = await Promise.all([ @@ -932,6 +997,7 @@ export async function deleteTaskModelProviderCommand( getPersistedEnvironmentVariableNames(tx), getDeploymentSetupNewState(tx), isChatGptSubscriptionConnected(), + isGitHubCopilotSubscriptionConnected(), isXaiSubscriptionConnected(), getDeploymentTaskModelSettings(), ]); @@ -942,6 +1008,7 @@ export async function deleteTaskModelProviderCommand( persistedEnvVarNames, selectedProvider: setupNewState.modelProvider, chatgptConnected, + githubCopilotConnected, xaiSubscriptionConnected, }); const providerStatus = providerSetup.providers.find( @@ -962,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.'); } @@ -1505,18 +1581,26 @@ 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 roomoteUpstreamSlug = rebaseRoomoteModelIdToUpstream(modelId); + const roomoteInferenceModel = roomoteUpstreamSlug !== null; + const openRouterLookupModelId = roomoteInferenceModel + ? `openrouter/${roomoteUpstreamSlug}` + : 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); } - const runtimeOpenRouterKey = process.env.OPENROUTER_API_KEY?.trim(); - const openRouterKey = runtimeOpenRouterKey - ? runtimeOpenRouterKey - : (await getPersistedEnvironmentVariableValues(['OPENROUTER_API_KEY'])) - .OPENROUTER_API_KEY; + const openRouterKey = await resolveModelProviderEnvValue( + roomoteInferenceModel + ? ROOMOTE_INFERENCE_API_KEY_ENV_VAR_NAME + : 'OPENROUTER_API_KEY', + ); if (!openRouterKey) { return { @@ -1527,7 +1611,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/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts index 822f503b9..daa6a7713 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -287,6 +287,7 @@ import { createSetupBootstrapSlackAppFromManifestCommand, saveSetupBootstrapAuthConfigCommand, saveSetupBootstrapAuthProviderChoiceCommand, + chooseSetupTrialInferenceCommand, saveSetupNewAuthConfigCommand, saveSetupNewAuthProviderChoiceCommand, saveSetupNewComputeConfigCommand, @@ -2593,6 +2594,10 @@ export const appRouter = createRouter({ }), ), + chooseTrialInference: protectedProcedure.mutation(({ ctx: { auth } }) => + chooseSetupTrialInferenceCommand(auth), + ), + saveComputeProviderChoice: protectedProcedure .input( z.object({ 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/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/compute-providers/src/worker-env/base.ts b/packages/compute-providers/src/worker-env/base.ts index cd996bb0c..909f78563 100644 --- a/packages/compute-providers/src/worker-env/base.ts +++ b/packages/compute-providers/src/worker-env/base.ts @@ -18,6 +18,9 @@ const BLOCKED_WORKER_ENV_KEYS = new Set([ 'DASHBOARD_PASSWORD', 'SETUP_TOKEN', 'MODAL_TOKEN_SECRET', + // 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/__tests__/provider-credit-balance.test.ts b/packages/db/src/lib/__tests__/provider-credit-balance.test.ts index c0be9977c..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,16 +1,16 @@ 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, + fetchRoomoteCreditBalance, getProviderCreditBalances, 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 }); @@ -66,6 +66,62 @@ describe('parseOpenRouterKeyBalance', () => { }); }); +describe('fetchRoomoteCreditBalance', () => { + 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: 'env-delivery-value' }, + fetchImpl, + }), + ).resolves.toBeNull(); + expect(fetchImpl).not.toHaveBeenCalled(); + }); +}); + describe('fetchOpenRouterCreditBalance', () => { it('returns null when no API key is configured', async () => { const fetchImpl = vi.fn(); diff --git a/packages/db/src/lib/model-runtime-config.test.ts b/packages/db/src/lib/model-runtime-config.test.ts index 4712496d5..d628970b0 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,132 @@ describe('resolveEffectiveModelRuntimeEnv', () => { }); }); +describe('managed Roomote inference 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 Roomote inference as gateway-served without leaking its key', async () => { + mockDeploymentSettingsFindFirst.mockResolvedValue({ + runtimeModelConfig: { roomoteModel: 'roomote/openai/gpt-5.6-luna' }, + taskModelSettings: null, + }); + + const env = await resolveSandboxModelRuntimeEnv({ + runtimeEnv: {}, + deploymentEnvVars: { R_TRIAL_OPENROUTER_API_KEY: 'sk-trial' }, + }); + + expect(env.R_INFERENCE_GATEWAY_KEYS?.split(',')).toContain( + '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('keeps an unavailable Roomote provider on the gateway for an actionable failure', async () => { + mockDeploymentSettingsFindFirst.mockResolvedValue({ + 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 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: {}, + 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('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, + }); + + const env = await resolveEffectiveModelRuntimeEnv({ + runtimeEnv: { R_TRIAL_OPENROUTER_API_KEY: 'sk-trial' }, + 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 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.toBeUndefined(); + + mockEnvironmentVariablesFindMany.mockResolvedValue([ + { name: 'R_TRIAL_OPENROUTER_API_KEY', value: 'sk-saved' }, + ]); + await expect( + resolveModelProviderEnvValue(['R_TRIAL_OPENROUTER_API_KEY'], { + runtimeEnv: {}, + }), + ).resolves.toBe('sk-saved'); + + mockEnvironmentVariablesFindMany.mockResolvedValue([]); + await expect( + resolveModelProviderEnvValue(['OPENROUTER_API_KEY'], { + runtimeEnv: { R_TRIAL_OPENROUTER_API_KEY: 'sk-trial' }, + }), + ).resolves.toBeUndefined(); + }); +}); + 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..b9eca68ae 100644 --- a/packages/db/src/lib/model-runtime-config.ts +++ b/packages/db/src/lib/model-runtime-config.ts @@ -14,9 +14,12 @@ import { INFERENCE_GATEWAY_XAI_ENV_VAR_NAME, isConfiguredEnvValue, isInferenceGatewayCoveredEnvVar, + isSettingsOnlyProviderEnvVar, normalizeDeploymentModelConfig, normalizeOptionalReasoningEffort, parseModelProviderEnvKeys, + ROOMOTE_INFERENCE_API_KEY_ENV_VAR_NAME, + ROOMOTE_INFERENCE_PROVIDER_ID, resolveSetupModelProviderIdFromModel, TASK_MODEL_ROLE_DESCRIPTORS, TASK_MODEL_ROLES, @@ -163,7 +166,12 @@ 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. 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. */ export async function resolveModelProviderEnvValue( envVarNames: string | readonly string[], @@ -176,6 +184,7 @@ export async function resolveModelProviderEnvValue( const names = typeof envVarNames === 'string' ? [envVarNames] : envVarNames; for (const envVarName of names) { + if (isSettingsOnlyProviderEnvVar(envVarName)) continue; const runtimeValue = normalizeConfiguredValue(runtimeEnv[envVarName]); if (runtimeValue) { @@ -422,6 +431,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 @@ -433,7 +450,9 @@ async function resolveModelRuntimeEnv( ? gatewayProviderKeyNames.filter( (name) => isInferenceGatewayCoveredEnvVar(name) && - (normalizeConfiguredValue(runtimeEnv[name]) !== undefined || + ((name === ROOMOTE_INFERENCE_API_KEY_ENV_VAR_NAME && + managedRoomoteInferenceSelected) || + normalizeConfiguredValue(runtimeEnv[name]) !== undefined || normalizeConfiguredValue(persistedEnvVars[name]) !== undefined), ) : []; @@ -446,7 +465,9 @@ async function resolveModelRuntimeEnv( } const value = - normalizeConfiguredValue(runtimeEnv[envVarName]) ?? + (isSettingsOnlyProviderEnvVar(envVarName) + ? undefined + : normalizeConfiguredValue(runtimeEnv[envVarName])) ?? normalizeConfiguredValue(persistedEnvVars[envVarName]); return value ? [[envVarName, value]] : []; diff --git a/packages/db/src/lib/provider-credit-balance.ts b/packages/db/src/lib/provider-credit-balance.ts index c888cd6a1..19802dc12 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 }), }); @@ -182,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; } @@ -196,12 +205,32 @@ export async function fetchOpenRouterCreditBalance( } return { - providerId: 'openrouter', + providerId, ...parsed, fetchedAt: new Date().toISOString(), }; } +export async function fetchOpenRouterCreditBalance( + options: BalanceFetchOptions = {}, +): Promise { + return fetchOpenRouterBackedCreditBalance( + 'openrouter', + 'OPENROUTER_API_KEY', + options, + ); +} + +export async function fetchRoomoteCreditBalance( + options: BalanceFetchOptions = {}, +): Promise { + return fetchOpenRouterBackedCreditBalance( + 'roomote', + ROOMOTE_INFERENCE_API_KEY_ENV_VAR_NAME, + options, + ); +} + /** * Fetch credit balances for every supported keyed provider. Providers that are * not connected, fail to respond, or return nothing displayable are omitted. @@ -211,6 +240,7 @@ export async function getProviderCreditBalances( ): Promise { const results = await Promise.allSettled([ fetchOpenRouterCreditBalance(options), + fetchRoomoteCreditBalance(options), ]); return results.flatMap((result) => diff --git a/packages/env/src/index.ts b/packages/env/src/index.ts index 27ac802b8..476fc42be 100644 --- a/packages/env/src/index.ts +++ b/packages/env/src/index.ts @@ -364,6 +364,18 @@ 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. 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 // model provider — embeddings and rerank can move to a local or fleet @@ -526,6 +538,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_EMBEDDINGS_UPSTREAM_URL', 'R_BRAIN_RERANK_UPSTREAM_URL', 'R_BRAIN_INFERENCE_UPSTREAM_API_KEY', 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 11d58fc6a..68265e601 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, + ROOMOTE_INFERENCE_API_KEY_ENV_VAR_NAME, +} from './model-provider-config'; /** * Per-repo source-control access tokens. A task legitimately receives the @@ -148,6 +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, + // 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 9cd826d0a..e69fea2cc 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, @@ -270,36 +271,78 @@ 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('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/'), + }, + ]), + ), ); }); @@ -369,15 +412,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 +435,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 +466,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 +493,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 +508,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 +548,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 +586,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 +609,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 +648,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', @@ -1082,6 +1117,31 @@ 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'), + '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: null, + roomoteOrchestrationModelReasoningEffort: null, + roomoteSmallModelReasoningEffort: null, + roomoteVisionModelReasoningEffort: null, + roomoteCodeReviewModelReasoningEffort: null, + roomoteExploreModelReasoningEffort: null, + roomotePlanningModelReasoningEffort: null, + }); + }); + it('maps the provider default to coding and recommended models to their roles', () => { expect( buildRecommendedDeploymentModelConfig(getSetupModelProvider('anthropic')), @@ -1498,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: { @@ -1581,6 +1664,45 @@ describe('buildSetupModelStatus', () => { expect(status.setupSatisfied).toBe(true); }); + it('treats Roomote inference as a distinct hosting-managed provider', () => { + const status = buildSetupModelStatus({ + runtimeEnv: {}, + persistedModelConfig: { + roomoteModel: 'roomote/openai/gpt-5.6-luna', + roomoteSmallModel: null, + roomoteVisionModel: null, + }, + persistedEnvVarNames: ['R_TRIAL_OPENROUTER_API_KEY'], + }); + + expect(status.setupSatisfied).toBe(true); + expect( + status.providers.find((provider) => provider.id === 'roomote'), + ).toMatchObject({ + runtimeApiKeySatisfied: false, + savedApiKeySatisfied: true, + }); + expect( + status.providers.find((provider) => provider.id === 'openrouter'), + ).toMatchObject({ runtimeApiKeySatisfied: false }); + }); + + it('keeps OpenRouter separate from the hosting-managed Roomote key', () => { + const status = buildSetupModelStatus({ + runtimeEnv: { + R_TRIAL_OPENROUTER_API_KEY: 'sk-trial', + }, + persistedEnvVarNames: [], + }); + + expect( + status.providers.find((provider) => provider.id === 'openrouter'), + ).toMatchObject({ + runtimeApiKeySatisfied: false, + savedApiKeySatisfied: 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 87c55d964..ceec615c4 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,30 @@ 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; +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 @@ -69,6 +94,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, @@ -407,7 +433,87 @@ 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; + +/** + * 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 { + 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_TRIAL_MODEL_PRESET.roles.coding!.modelId, + authKind: 'api-key', + suggestedTaskModels: mapRecommendedTaskModels( + Object.fromEntries( + Object.entries(OPENROUTER_RECOMMENDED_TASK_MODEL_SLUGS).map( + ([modelId, openRouterModelId]) => [ + modelId, + rebaseOpenRouterModelIdForRoomote(openRouterModelId), + ], + ), + ) as RecommendedTaskModelSlugMap, + ), + recommendedPresets: [ROOMOTE_TRIAL_MODEL_PRESET], + // Only hosting can enable this provider. It is never a user connection. + hidden: true, + }, { id: 'openrouter', label: 'OpenRouter', @@ -470,6 +576,7 @@ export const SETUP_MODEL_PROVIDER_CATALOG = [ }, }, }, + OPENROUTER_EFFICIENT_MODEL_PRESET, ], }, { @@ -1982,7 +2089,11 @@ export function buildSetupModelStatus(input: { const requiredEnvVarNames = getSetupModelProviderRequiredEnvVarNames(provider); const hasRequiredEnvVars = requiredEnvVarNames.length > 0; + // 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( @@ -2019,7 +2130,6 @@ export function buildSetupModelStatus(input: { (name) => isPersisted(name) || isRuntimeConfigured(name), ) && requiredEnvVarNames.some(isPersisted); - return { ...provider, additionalEnvValues, 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, 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; 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, }; }