Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
5862f50
feat: free-trial inference via a provisioner-injected OpenRouter fall…
mrubens Aug 26, 2026
9959c16
fix: address review on trial-key reservation, seeding guards, and tes…
mrubens Aug 26, 2026
a136a94
fix: let the Efficient preset inherit the shared per-role reasoning d…
mrubens Aug 26, 2026
c41748d
feat: make free-trial inference an explicit onboarding choice
mrubens Aug 26, 2026
f10052e
fix: require a real key when editing a trial-satisfied provider
mrubens Aug 26, 2026
4145d7c
Merge remote-tracking branch 'origin/develop' into feat/free-trial-in…
roomote Aug 27, 2026
251c2c8
feat: add trial inference choice to setup
roomote Aug 27, 2026
2d248f5
fix: restore back navigation from trial inference choice
roomote Aug 27, 2026
f49cc91
Tweaks
brunobergher Aug 27, 2026
9e0a016
feat: add managed Roomote inference provider
brunobergher Aug 27, 2026
d1345b2
feat: show managed inference credits
brunobergher Aug 27, 2026
0b1e595
fix: remove unused inference provider imports
brunobergher Aug 27, 2026
7260346
fix: derive Roomote trial model preset
brunobergher Aug 27, 2026
09397ba
test: align inference provider settings coverage
brunobergher Aug 27, 2026
081b170
feat: store the Roomote inference key in Settings instead of living o…
mrubens Aug 27, 2026
e091580
feat: let operators delete Roomote inference to disable the trial
mrubens Aug 27, 2026
337fbe0
test: read the Roomote credit balance from the stored key
mrubens Aug 27, 2026
86b3b8f
fix: harden the Roomote trial inference lifecycle
mrubens Aug 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

65 changes: 61 additions & 4 deletions apps/api/src/handlers/inference/index.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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<string, unknown>;
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.
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
51 changes: 49 additions & 2 deletions apps/api/src/handlers/inference/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
getInferenceGatewayProvider,
INFERENCE_GATEWAY_RESOURCE_PATTERN,
INFERENCE_GATEWAY_REGION_PATTERN,
ROOMOTE_INFERENCE_PROVIDER_ID,
type InferenceGatewayProvider,
} from '@roomote/types';
import {
Expand All @@ -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<string | undefined> {
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;
Expand Down Expand Up @@ -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
Expand All @@ -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`,
};
}

Expand Down
10 changes: 10 additions & 0 deletions apps/docs/models.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
mrubens marked this conversation as resolved.
**Settings > Models** disables it permanently.

For example, a deployment might use:

- an OpenRouter-routed model for the default coding model
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions apps/web/src/app/(onboarding)/setup/SetupSignedInFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -352,6 +353,15 @@ export function SetupSignedInFlow() {
bootstrapMode={false}
/>
))}
{step === 'inference' && (
<StepConfigureInference
onUseTrial={goToNextStep}
onConfigureProvider={() =>
goToStep('env-vars', { revisit: true })
}
onBack={canGoBack ? goToPreviousStep : undefined}
/>
)}
{step === 'env-vars' && (
<StepInferenceProvider
modelSetup={status.modelSetup}
Expand Down
Loading
Loading