Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
e873521
web(dashboard): one accounts list, sidebar team switcher, no page tea…
lawrencecchen Sep 8, 2026
dd979a4
web(dashboard): put the team picker inside the account menu
lawrencecchen Sep 8, 2026
fd92a8e
web(dashboard): move the theme switch into the account menu
lawrencecchen Sep 8, 2026
7b85d7e
web(dashboard): list cr-added accounts, drop the pricing coverage card
lawrencecchen Sep 8, 2026
1c30f5d
coderouter: route Responses calls through OpenAI and OpenRouter API keys
lawrencecchen Sep 8, 2026
94d88ed
web(dashboard): account menu order is settings, theme, billing, team
lawrencecchen Sep 8, 2026
98d7ef1
web(dashboard): pin the theme in the menu order test
lawrencecchen Sep 8, 2026
e9ccefc
web(dashboard): address review findings on the accounts page and menu
lawrencecchen Sep 8, 2026
4ba728b
Merge origin/feat-coderouter-accounts-team-switcher into feat-coderou…
lawrencecchen Sep 8, 2026
e4598ca
web(dashboard): style the active add tab with Base UI's data-active
lawrencecchen Sep 8, 2026
6872c7d
Merge remote-tracking branch 'origin/feat-coderouter-accounts-team-sw…
lawrencecchen Sep 8, 2026
ffdba3b
web(dashboard): assert the active tab without assuming attribute order
lawrencecchen Sep 8, 2026
0adb356
Merge remote-tracking branch 'origin/feat-coderouter-accounts-team-sw…
lawrencecchen Sep 8, 2026
81c5683
web(dashboard): show account identifiers to managers only, label refr…
lawrencecchen Sep 8, 2026
7725599
Merge remote-tracking branch 'origin/feat-coderouter-accounts-team-sw…
lawrencecchen Sep 8, 2026
e222a8e
Merge remote-tracking branch 'origin/main' into feat-coderouter-api-k…
lawrencecchen Sep 8, 2026
8b4b167
coderouter: admit API-key providers in the envelope identity, one ses…
lawrencecchen Sep 8, 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
97 changes: 96 additions & 1 deletion web/app/[locale]/dashboard/components/coderouter-accounts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,14 @@ type FormStatus = {
const idleStatus: FormStatus = { state: "idle" };

/** Everything the add panel offers, in display order. */
type AddKind = ClaudeUpstreamKind | "codex" | "opencode";
type ApiKeyAddKind = "openai-apikey" | "openrouter-apikey";
type AddKind = ClaudeUpstreamKind | ApiKeyAddKind | "codex" | "opencode";
const ADD_KINDS: readonly AddKind[] = [
"anthropic_api_key",
"anthropic_oauth",
"bedrock",
"openai-apikey",
"openrouter-apikey",
"codex",
"opencode",
];
Expand Down Expand Up @@ -82,6 +85,8 @@ export function CoderouterAccountsSection({
const nativeAccounts = native.kind === "ok" ? native.accounts : [];
const sharedAccounts = shared.kind === "ok" ? shared.accounts : [];
const total = claudeAccounts.length + nativeAccounts.length + sharedAccounts.length;
// Field ids are per form, so switching the add tab never leaves two inputs
// with one id.
const partialFailure = claude.kind === "error" || native.kind === "error" || shared.kind === "error";

return (
Expand Down Expand Up @@ -569,6 +574,8 @@ function AddAccountPanel({ teamId }: { readonly teamId: string }) {
command="npx coderouter@latest add opencode"
t={t}
/>
) : kind === "openai-apikey" || kind === "openrouter-apikey" ? (
<ApiKeyForm key={kind} teamId={teamId} kind={kind} />
) : (
<ClaudeUpstreamForm key={kind} teamId={teamId} kind={kind} />
)}
Expand Down Expand Up @@ -598,6 +605,86 @@ function CliInstructions({
);
}

/**
* Stores an OpenAI or OpenRouter key as a coderouter account. Codex on this
* team's machines then routes Responses calls through it, next to any Codex
* sign-ins, and moves off it on a rate limit or a rejected key.
*/
function ApiKeyForm({
teamId,
kind,
}: {
readonly teamId: string;
readonly kind: ApiKeyAddKind;
}) {
const t = useTranslations("dashboard.coderouterAccounts");
const router = useRouter();
const [status, setStatus] = useState<FormStatus>(idleStatus);

const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (status.state === "submitting") return;
const form = event.currentTarget;
const data = new FormData(form);
const label = String(data.get("label") ?? "").trim();
setStatus({ state: "submitting" });
try {
const response = await fetch("/api/coderouter/accounts", {
method: "POST",
headers: { "content-type": "application/json", "x-cmux-team-id": teamId },
body: JSON.stringify({
provider: kind,
apiKey: String(data.get("apiKey") ?? "").trim(),
...(label ? { label } : {}),
}),
});
if (!response.ok) {
setStatus({
state: "error",
message: errorMessageForStatus(response.status, t, t("saveError")),
});
return;
}
form.reset();
setStatus({ state: "success", message: t("saveSuccess") });
router.refresh();
} catch {
setStatus({ state: "error", message: t("saveError") });
}
};

return (
<form onSubmit={submit} className="space-y-3">
<p className="text-xs text-muted">
{kind === "openai-apikey" ? t("openAiKeyHint") : t("openRouterKeyHint")}
</p>
<Field
label={t("apiKeyField")}
name="apiKey"
placeholder={kind === "openai-apikey" ? "sk-proj-..." : "sk-or-v1-..."}
/>
<Field
label={t("labelField")}
name="label"
placeholder={t("labelPlaceholder")}
required={false}
secret={false}
mono={false}
/>
<div className="flex flex-wrap items-center gap-3">
<button type="submit" disabled={status.state === "submitting"} className={primaryButtonClass}>
{status.state === "submitting" ? t("savingAction") : t("saveAction")}
</button>
{status.message ? (
<span className={`text-xs ${status.state === "error" ? "text-foreground" : "text-muted"}`}>
{status.message}
</span>
) : null}
</div>
</form>
);
}

function ClaudeUpstreamForm({
teamId,
kind,
Expand Down Expand Up @@ -759,6 +846,10 @@ function addKindLabel(kind: AddKind, t: Translator): string {
return t("kindCodex");
case "opencode":
return t("kindOpencode");
case "openai-apikey":
return t("kindOpenAiApiKey");
case "openrouter-apikey":
return t("kindOpenRouterApiKey");
default:
return claudeKindLabel(kind, t);
}
Expand All @@ -771,6 +862,10 @@ function nativeKindLabel(kind: CodeRouterAccountSummary["provider"], t: Translat
return t("kindCodex");
case "opencode-go":
return t("kindOpencodeGo");
case "openai-apikey":
return t("kindOpenAiApiKey");
case "openrouter-apikey":
return t("kindOpenRouterApiKey");
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
-- coderouter routes the OpenAI Responses surface through pasted OpenAI and
-- OpenRouter API keys as well as Codex sign-ins. The provider check on every
-- coderouter table widens to admit the two key-based providers.
ALTER TABLE "coderouter_accounts"
DROP CONSTRAINT IF EXISTS "coderouter_accounts_provider_check";
ALTER TABLE "coderouter_accounts"
ADD CONSTRAINT "coderouter_accounts_provider_check"
CHECK ("provider" IN ('codex', 'opencode-go', 'openai-apikey', 'openrouter-apikey'));

ALTER TABLE "coderouter_credentials"
DROP CONSTRAINT IF EXISTS "coderouter_credentials_provider_check";
ALTER TABLE "coderouter_credentials"
ADD CONSTRAINT "coderouter_credentials_provider_check"
CHECK ("provider" IN ('codex', 'opencode-go', 'openai-apikey', 'openrouter-apikey'));

ALTER TABLE "coderouter_session_accounts"
DROP CONSTRAINT IF EXISTS "coderouter_session_accounts_provider_check";
ALTER TABLE "coderouter_session_accounts"
ADD CONSTRAINT "coderouter_session_accounts_provider_check"
CHECK ("provider" IN ('codex', 'opencode-go', 'openai-apikey', 'openrouter-apikey'));
8 changes: 5 additions & 3 deletions web/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1085,12 +1085,14 @@ export const subrouterTenants = pgTable(
* live in the envelope-encrypted coderouterCredentials table; this table
* coordinates selection and rotating refresh-token leases.
*/
type CodeRouterProviderColumn = "codex" | "opencode-go" | "openai-apikey" | "openrouter-apikey";

export const coderouterAccounts = pgTable(
"coderouter_accounts",
{
id: uuid("id").defaultRandom().primaryKey(),
teamId: text("team_id").notNull(),
provider: text("provider").$type<"codex" | "opencode-go">().notNull(),
provider: text("provider").$type<CodeRouterProviderColumn>().notNull(),
providerAccountId: text("provider_account_id").notNull(),
label: text("label").notNull(),
state: text("state")
Expand Down Expand Up @@ -1166,7 +1168,7 @@ export const coderouterCredentials = pgTable(
.primaryKey()
.references(() => coderouterAccounts.id, { onDelete: "cascade" }),
teamId: text("team_id").notNull(),
provider: text("provider").$type<"codex" | "opencode-go">().notNull(),
provider: text("provider").$type<CodeRouterProviderColumn>().notNull(),
credentialRevision: bigint("credential_revision", { mode: "number" })
.notNull(),
algorithm: text("algorithm").notNull().default("aes-256-gcm"),
Expand Down Expand Up @@ -1219,7 +1221,7 @@ export const coderouterSessionAccounts = pgTable(
"coderouter_session_accounts",
{
teamId: text("team_id").notNull(),
provider: text("provider").$type<"codex" | "opencode-go">().notNull(),
provider: text("provider").$type<CodeRouterProviderColumn>().notNull(),
sessionKey: text("session_key").notNull(),
accountId: uuid("account_id")
.notNull()
Expand Down
3 changes: 3 additions & 0 deletions web/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@
"kindOpencode": "OpenCode",
"kindOpencodeGo": "OpenCode Go",
"kindOpenAiApiKey": "OpenAI API key",
"kindOpenRouterApiKey": "OpenRouter API key",
"kindUnknown": "Unknown provider",
"stateActive": "Active",
"stateDisabled": "Disabled",
Expand All @@ -280,6 +281,8 @@
"apiKeyField": "API key",
"oauthTokenField": "OAuth token",
"oauthHint": "Create a long-lived token on your own machine with",
"openAiKeyHint": "Codex on this team's machines sends Responses API calls to api.openai.com with this key, alongside any Codex sign-ins. Billing goes to the OpenAI project that owns the key.",
"openRouterKeyHint": "Codex on this team's machines sends Responses API calls to OpenRouter with this key. Bare OpenAI model ids are sent as openai/<model>.",
"regionField": "AWS region",
"accessKeyIdField": "Access key ID",
"secretAccessKeyField": "Secret access key",
Expand Down
3 changes: 3 additions & 0 deletions web/messages/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@
"kindOpencode": "OpenCode",
"kindOpencodeGo": "OpenCode Go",
"kindOpenAiApiKey": "OpenAI APIキー",
"kindOpenRouterApiKey": "OpenRouter APIキー",
"kindUnknown": "不明なプロバイダー",
"stateActive": "有効",
"stateDisabled": "無効",
Expand All @@ -280,6 +281,8 @@
"apiKeyField": "APIキー",
"oauthTokenField": "OAuthトークン",
"oauthHint": "自分のマシンで次のコマンドを実行して長期トークンを作成します:",
"openAiKeyHint": "このチームのマシン上の Codex は、Codex サインインと並んでこのキーで api.openai.com に Responses API を送信します。請求はキーを所有する OpenAI プロジェクトに行われます。",
"openRouterKeyHint": "このチームのマシン上の Codex は、このキーで OpenRouter に Responses API を送信します。ベンダー接頭辞のない OpenAI モデル ID は openai/<model> として送られます。",
"regionField": "AWSリージョン",
"accessKeyIdField": "アクセスキーID",
"secretAccessKeyField": "シークレットアクセスキー",
Expand Down
47 changes: 45 additions & 2 deletions web/services/coderouter/accounts.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { randomUUID } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
import {
findAccountByProviderIdentity,
deleteAccount,
Expand All @@ -8,7 +8,11 @@ import {
withVaultLease,
} from "./repository";
import { encryptCredential } from "./encryption";
import type { CodeRouterCredential } from "./types";
import {
CODEROUTER_API_KEY_PROVIDERS,
type CodeRouterApiKeyProvider,
type CodeRouterCredential,
} from "./types";
import { deleteVaultCredential } from "./vault";
import { reportCoderouterFailure } from "./observability";

Expand Down Expand Up @@ -97,9 +101,14 @@ export const removeAccount = createAccountRemover({
report: reportCoderouterFailure,
});

const MAX_API_KEY_LENGTH = 512;
const MAX_LABEL_LENGTH = 120;
const API_KEY_PATTERN = /^[A-Za-z0-9._~+/=-]+$/;

export function parseCredential(value: unknown): CodeRouterCredential | null {
if (!isRecord(value)) return null;
const provider = value.provider;
if (isApiKeyProviderName(provider)) return parseApiKeyCredential(provider, value);
const accessToken = boundedString(value.accessToken, 32_768);
const refreshToken = boundedString(value.refreshToken, 32_768);
const accountId = boundedString(value.accountId, 512);
Expand Down Expand Up @@ -147,6 +156,40 @@ export function parseCredential(value: unknown): CodeRouterCredential | null {
return null;
}

function isApiKeyProviderName(value: unknown): value is CodeRouterApiKeyProvider {
return typeof value === "string" &&
(CODEROUTER_API_KEY_PROVIDERS as readonly string[]).includes(value);
}

/**
* `{ provider, apiKey, label? }` from the dashboard or `cr add`. The key is
* validated as one printable token; the fingerprint becomes the provider
* account id, so re-adding the same key updates the existing row.
*/
function parseApiKeyCredential(
provider: CodeRouterApiKeyProvider,
value: Record<string, unknown>,
): CodeRouterCredential | null {
const apiKey = typeof value.apiKey === "string" ? value.apiKey.trim() : "";
if (apiKey.length < 16 || apiKey.length > MAX_API_KEY_LENGTH || !API_KEY_PATTERN.test(apiKey)) {
return null;
}
const rawLabel = value.label;
if (rawLabel !== undefined && rawLabel !== null && typeof rawLabel !== "string") return null;
const label = typeof rawLabel === "string" ? rawLabel.trim() : "";
if (label.length > MAX_LABEL_LENGTH) return null;
return {
provider,
apiKey,
accountId: apiKeyFingerprint(provider, apiKey),
label,
};
}

export function apiKeyFingerprint(provider: CodeRouterApiKeyProvider, apiKey: string): string {
return createHash("sha256").update(`${provider}\n${apiKey}`).digest("hex").slice(0, 24);
}

function boundedString(value: unknown, max: number): string | null {
return typeof value === "string" && value.length > 0 && value.length <= max
? value
Expand Down
1 change: 1 addition & 0 deletions web/services/coderouter/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,7 @@ function accountProvider(value: unknown): string | null {
"openai-apikey",
"anthropic-apikey",
"opencode-go",
"openrouter-apikey",
]);
}

Expand Down
Loading
Loading