Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 10 additions & 0 deletions packages/core/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2363,6 +2363,16 @@ export class ApiClient {
return created;
}

async increaseCompanyCodexQuota(additionalTokens: number): Promise<CompanyCodexKeyStatus> {
const raw = await this.fetch<unknown>("/api/company-codex/key", {
method: "PATCH",
body: JSON.stringify({ additional_tokens: additionalTokens }),
});
return parseWithFallback(raw, CompanyCodexKeyStatusSchema, EMPTY_COMPANY_CODEX_KEY_STATUS, {
endpoint: "PATCH /api/company-codex/key",
});
}

async revokeCompanyCodexKey(): Promise<void> {
await this.fetch("/api/company-codex/key", { method: "DELETE" });
}
Expand Down
18 changes: 18 additions & 0 deletions packages/core/api/schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,24 @@ describe("company Codex schemas", () => {
expect(CreateCompanyCodexKeyResponseSchema.safeParse({ active: true }).success).toBe(false);
});

it("keeps valid company Codex quota fields and rejects invalid percentages", () => {
expect(CompanyCodexKeyStatusSchema.parse({
active: true,
weekly_token_limit: 25_000_000,
current_tokens: 5_000_000,
reset_at: "2026-08-28T00:00:00Z",
upstream_remaining_percent: 74.25,
})).toMatchObject({
weekly_token_limit: 25_000_000,
current_tokens: 5_000_000,
upstream_remaining_percent: 74.25,
});
expect(CompanyCodexKeyStatusSchema.safeParse({
active: true,
upstream_remaining_percent: 101,
}).success).toBe(false);
});

it("keeps future status values and rejects malformed session list items", () => {
expect(CompanyCodexSessionListSchema.parse([{ ...session, status: "archived" }])[0]?.status)
.toBe("archived");
Expand Down
4 changes: 4 additions & 0 deletions packages/core/api/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ export const CompanyCodexKeyStatusSchema = z.object({
active: z.boolean().default(false),
key_prefix: z.string().optional(),
created_at: z.string().optional(),
weekly_token_limit: z.number().nonnegative().optional(),
current_tokens: z.number().nonnegative().optional(),
reset_at: z.string().optional(),
upstream_remaining_percent: z.number().min(0).max(100).nullable().optional(),
}).loose();

export const EMPTY_COMPANY_CODEX_KEY_STATUS: CompanyCodexKeyStatus = {
Expand Down
4 changes: 4 additions & 0 deletions packages/core/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,10 @@ export interface CompanyCodexKeyStatus {
active: boolean;
key_prefix?: string;
created_at?: string;
weekly_token_limit?: number;
current_tokens?: number;
reset_at?: string;
upstream_remaining_percent?: number | null;
}

export interface CreateCompanyCodexKeyResponse extends CompanyCodexKeyStatus {
Expand Down
14 changes: 14 additions & 0 deletions packages/views/locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -842,6 +842,20 @@
"enabled": "Active",
"not_enabled": "Not configured",
"key_metadata": "{{prefix}} · issued {{date}}",
"quota_summary": "{{used}} / {{limit}} tokens used this week",
"quota_unavailable": "Quota status is temporarily unavailable. Refresh and try again.",
"quota_resets": "resets {{date}}",
"upstream_remaining": "Shared upstream pool remaining: {{percent}}%",
"increase_quota": "Increase quota",
"increase_quota_title": "Increase weekly quota",
"additional_tokens": "Additional tokens",
"quota_preview": "{{current}} + {{additional}} = {{next}} tokens per week",
"quota_invalid": "Enter a positive whole number of tokens.",
"quota_warning": "This updates your existing key without rotating it or resetting usage. It does not add capacity to the shared upstream Pro account pool.",
"quota_cancel": "Cancel",
"confirm_increase": "Confirm increase",
"quota_increased": "Weekly quota increased",
"quota_increase_failed": "Failed to increase weekly quota",
"create": "Create access",
"rotate": "Rotate key",
"revoke": "Revoke",
Expand Down
14 changes: 14 additions & 0 deletions packages/views/locales/ja/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -841,6 +841,20 @@
"enabled": "有効",
"not_enabled": "未設定",
"key_metadata": "{{prefix}} · {{date}} に発行",
"quota_summary": "今週 {{used}} / {{limit}} Token を使用",
"quota_unavailable": "クォータの状態を一時的に取得できません。更新してから再試行してください。",
"quota_resets": "{{date}} にリセット",
"upstream_remaining": "共有アップストリームプール残量: {{percent}}%",
"increase_quota": "上限を増やす",
"increase_quota_title": "週間上限を増やす",
"additional_tokens": "追加する Token 数",
"quota_preview": "{{current}} + {{additional}} = 週 {{next}} Token",
"quota_invalid": "正の整数の Token 数を入力してください。",
"quota_warning": "既存のキーを維持したまま上限を増やし、使用量はリセットしません。共有アップストリーム Pro アカウントプールの容量は増えません。",
"quota_cancel": "キャンセル",
"confirm_increase": "増加を確認",
"quota_increased": "週間上限を増やしました",
"quota_increase_failed": "週間上限を増やせませんでした",
"create": "アクセスを作成",
"rotate": "キーをローテーション",
"revoke": "無効化",
Expand Down
14 changes: 14 additions & 0 deletions packages/views/locales/ko/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,20 @@
"enabled": "활성",
"not_enabled": "미설정",
"key_metadata": "{{prefix}} · {{date}} 발급",
"quota_summary": "이번 주 {{used}} / {{limit}} Token 사용",
"quota_unavailable": "할당량 상태를 일시적으로 불러올 수 없습니다. 새로 고친 후 다시 시도하세요.",
"quota_resets": "{{date}} 초기화",
"upstream_remaining": "공유 업스트림 풀 잔여량: {{percent}}%",
"increase_quota": "한도 늘리기",
"increase_quota_title": "주간 한도 늘리기",
"additional_tokens": "추가 Token 수",
"quota_preview": "{{current}} + {{additional}} = 주당 {{next}} Token",
"quota_invalid": "양의 정수 Token 수를 입력하세요.",
"quota_warning": "기존 키를 교체하거나 사용량을 초기화하지 않고 한도만 늘립니다. 공유 업스트림 Pro 계정 풀의 용량은 늘어나지 않습니다.",
"quota_cancel": "취소",
"confirm_increase": "증가 확인",
"quota_increased": "주간 한도를 늘렸습니다",
"quota_increase_failed": "주간 한도를 늘리지 못했습니다",
"create": "액세스 생성",
"rotate": "키 교체",
"revoke": "해지",
Expand Down
14 changes: 14 additions & 0 deletions packages/views/locales/zh-Hans/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -841,6 +841,20 @@
"enabled": "已启用",
"not_enabled": "未配置",
"key_metadata": "{{prefix}} · 签发于 {{date}}",
"quota_summary": "本周已用 {{used}} / {{limit}} Token",
"quota_unavailable": "额度状态暂时不可用,请刷新后重试。",
"quota_resets": "{{date}} 重置",
"upstream_remaining": "上游共享号池剩余:{{percent}}%",
"increase_quota": "增加额度",
"increase_quota_title": "增加每周额度",
"additional_tokens": "追加 Token 数",
"quota_preview": "{{current}} + {{additional}} = 每周 {{next}} Token",
"quota_invalid": "请输入正整数 Token 数。",
"quota_warning": "额度会直接加到现有密钥,不会轮换密钥或清零用量。增加个人额度不会增加上游共享 Pro 号池容量。",
"quota_cancel": "取消",
"confirm_increase": "确认增加",
"quota_increased": "每周额度已增加",
"quota_increase_failed": "增加每周额度失败",
"create": "创建访问",
"rotate": "轮换密钥",
"revoke": "撤销",
Expand Down
54 changes: 54 additions & 0 deletions packages/views/settings/components/company-codex-section.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { renderWithI18n } from "../../test/i18n";
const apiMock = vi.hoisted(() => ({
createCompanyCodexKey: vi.fn(),
getCompanyCodexKey: vi.fn(),
increaseCompanyCodexQuota: vi.fn(),
listCompanyCodexSessions: vi.fn(),
revokeCompanyCodexKey: vi.fn(),
}));
Expand Down Expand Up @@ -57,4 +58,57 @@ describe("CompanyCodexSection", () => {
expect(screen.getByRole("link", { name: "Download CC Switch (official releases)" }))
.toHaveAttribute("href", "https://github.com/farion1231/cc-switch/releases");
});

it("increases the existing key quota without rotating it", async () => {
apiMock.getCompanyCodexKey.mockResolvedValue({
active: true,
key_prefix: "sk-clb-test",
created_at: "2026-08-21T00:00:00Z",
weekly_token_limit: 25_000_000,
current_tokens: 5_000_000,
reset_at: "2026-08-28T00:00:00Z",
upstream_remaining_percent: 74.25,
});
apiMock.increaseCompanyCodexQuota.mockResolvedValue({
active: true,
key_prefix: "sk-clb-test",
created_at: "2026-08-21T00:00:00Z",
weekly_token_limit: 32_000_000,
current_tokens: 5_000_000,
reset_at: "2026-08-28T00:00:00Z",
upstream_remaining_percent: 74.25,
});

renderWithI18n(<CompanyCodexSection />);

await waitFor(() => expect(screen.getByRole("button", { name: "Increase quota" })).toBeEnabled());
expect(screen.getByText(/5,000,000 \/ 25,000,000 tokens used this week/)).toBeInTheDocument();
expect(screen.getByText(/Shared upstream pool remaining: 74.25%/)).toBeInTheDocument();

fireEvent.click(screen.getByRole("button", { name: "Increase quota" }));
fireEvent.change(screen.getByLabelText("Additional tokens"), { target: { value: "7000000" } });
expect(screen.getByText("25,000,000 + 7,000,000 = 32,000,000 tokens per week"))
.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Confirm increase" }));

await waitFor(() => expect(apiMock.increaseCompanyCodexQuota).toHaveBeenCalledWith(7_000_000));
expect(apiMock.createCompanyCodexKey).not.toHaveBeenCalled();
await waitFor(() => expect(screen.getByText(/5,000,000 \/ 32,000,000 tokens used this week/))
.toBeInTheDocument());
});

it("disables quota increases when the gateway quota is unavailable", async () => {
apiMock.getCompanyCodexKey.mockResolvedValue({
active: true,
key_prefix: "sk-clb-test",
created_at: "2026-08-21T00:00:00Z",
});

renderWithI18n(<CompanyCodexSection />);

await waitFor(() => expect(screen.getByRole("button", { name: "Increase quota" }))
.toBeDisabled());
expect(screen.getByText("Quota status is temporarily unavailable. Refresh and try again."))
.toBeInTheDocument();
});
});
125 changes: 115 additions & 10 deletions packages/views/settings/components/company-codex-section.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import { useCallback, useEffect, useState } from "react";
import { Check, Copy, ExternalLink, KeyRound, RefreshCw, ShieldCheck, Trash2 } from "lucide-react";
import { Check, Copy, ExternalLink, KeyRound, Plus, RefreshCw, ShieldCheck, Trash2 } from "lucide-react";
import { api } from "@multica/core/api";
import type {
CompanyCodexKeyStatus,
Expand All @@ -20,6 +20,7 @@ import {
DialogHeader,
DialogTitle,
} from "@multica/ui/components/ui/dialog";
import { Input } from "@multica/ui/components/ui/input";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { copyText } from "@multica/ui/lib/clipboard";
import { toast } from "sonner";
Expand All @@ -37,6 +38,9 @@ export function CompanyCodexSection() {
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [revoking, setRevoking] = useState(false);
const [quotaOpen, setQuotaOpen] = useState(false);
const [quotaIncrease, setQuotaIncrease] = useState("5000000");
const [increasingQuota, setIncreasingQuota] = useState(false);
const [issued, setIssued] = useState<CreateCompanyCodexKeyResponse | null>(null);
const [copied, setCopied] = useState<CopiedField>(null);
const [sessionDetail, setSessionDetail] = useState<CompanyCodexSessionDetail | null>(null);
Expand Down Expand Up @@ -88,6 +92,25 @@ export function CompanyCodexSection() {
}
};

const additionalTokens = Number(quotaIncrease);
const quotaIncreaseValid = Number.isSafeInteger(additionalTokens) && additionalTokens > 0;

const increaseQuota = async () => {
if (!quotaIncreaseValid) return;
setIncreasingQuota(true);
try {
const result = await api.increaseCompanyCodexQuota(additionalTokens);
setKeyStatus(result);
setQuotaOpen(false);
setQuotaIncrease("5000000");
toast.success(t(($) => $.company_codex.quota_increased));
} catch (error) {
toast.error(error instanceof Error ? error.message : t(($) => $.company_codex.quota_increase_failed));
} finally {
setIncreasingQuota(false);
}
};

const copy = async (field: Exclude<CopiedField, null>, value: string) => {
if (await copyText(value)) {
setCopied(field);
Expand Down Expand Up @@ -133,17 +156,50 @@ export function CompanyCodexSection() {
</Badge>
</div>
{keyStatus?.active ? (
<p className="mt-1 text-caption text-muted-foreground">
{t(($) => $.company_codex.key_metadata, {
prefix: keyStatus.key_prefix ?? "",
date: keyStatus.created_at
? new Date(keyStatus.created_at).toLocaleDateString()
: "",
})}
</p>
<div className="mt-1 space-y-1 text-caption text-muted-foreground">
<p>
{t(($) => $.company_codex.key_metadata, {
prefix: keyStatus.key_prefix ?? "",
date: keyStatus.created_at
? new Date(keyStatus.created_at).toLocaleDateString()
: "",
})}
</p>
{keyStatus.weekly_token_limit ? (
<p>
{t(($) => $.company_codex.quota_summary, {
used: (keyStatus.current_tokens ?? 0).toLocaleString(),
limit: keyStatus.weekly_token_limit.toLocaleString(),
})}
{keyStatus.reset_at
? ` · ${t(($) => $.company_codex.quota_resets, {
date: new Date(keyStatus.reset_at).toLocaleString(),
})}`
: ""}
</p>
) : (
<p>{t(($) => $.company_codex.quota_unavailable)}</p>
)}
{keyStatus.upstream_remaining_percent !== null &&
keyStatus.upstream_remaining_percent !== undefined ? (
<p>{t(($) => $.company_codex.upstream_remaining, {
percent: keyStatus.upstream_remaining_percent.toLocaleString(),
})}</p>
) : null}
</div>
) : null}
</div>
<div className="flex shrink-0 gap-2">
<div className="flex shrink-0 flex-wrap gap-2">
{keyStatus?.active ? (
<Button
variant="outline"
onClick={() => setQuotaOpen(true)}
disabled={!keyStatus.weekly_token_limit}
>
<Plus className="h-4 w-4" />
{t(($) => $.company_codex.increase_quota)}
</Button>
) : null}
{keyStatus?.active ? (
<Button variant="outline" onClick={revokeKey} disabled={revoking}>
<Trash2 className="h-4 w-4" />
Expand Down Expand Up @@ -205,6 +261,55 @@ export function CompanyCodexSection() {
)}
</SettingsSection>

<Dialog open={quotaOpen} onOpenChange={setQuotaOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t(($) => $.company_codex.increase_quota_title)}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<label htmlFor="company-codex-quota-increase" className="text-caption font-medium">
{t(($) => $.company_codex.additional_tokens)}
</label>
<Input
id="company-codex-quota-increase"
type="number"
min={1}
step={10000}
value={quotaIncrease}
onChange={(event) => setQuotaIncrease(event.target.value)}
/>
<p className="text-caption text-muted-foreground">
{quotaIncreaseValid
? t(($) => $.company_codex.quota_preview, {
current: (keyStatus?.weekly_token_limit ?? 0).toLocaleString(),
additional: additionalTokens.toLocaleString(),
next: ((keyStatus?.weekly_token_limit ?? 0) + additionalTokens).toLocaleString(),
})
: t(($) => $.company_codex.quota_invalid)}
</p>
</div>
<Alert>
<ShieldCheck />
<AlertDescription>{t(($) => $.company_codex.quota_warning)}</AlertDescription>
</Alert>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setQuotaOpen(false)}>
{t(($) => $.company_codex.quota_cancel)}
</Button>
<Button onClick={() => void increaseQuota()} disabled={!quotaIncreaseValid || increasingQuota}>
{increasingQuota ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
<Plus className="h-4 w-4" />
)}
{t(($) => $.company_codex.confirm_increase)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

<Dialog open={!!issued} onOpenChange={(open) => { if (!open) setIssued(null); }}>
<DialogContent className="max-h-[calc(100dvh-2rem)] min-w-0 overflow-x-hidden overflow-y-auto sm:max-w-2xl">
<DialogHeader>
Expand Down
1 change: 1 addition & 0 deletions server/cmd/server/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -1334,6 +1334,7 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
r.Use(handler.RequireHumanActor)
r.Get("/key", h.GetCompanyCodexKey)
r.Post("/key", h.CreateCompanyCodexKey)
r.Patch("/key", h.IncreaseCompanyCodexKeyQuota)
r.Delete("/key", h.RevokeCompanyCodexKey)
r.Get("/sessions", h.ListCompanyCodexSessions)
r.Get("/sessions/{id}", h.GetCompanyCodexSession)
Expand Down
Loading
Loading