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
12 changes: 4 additions & 8 deletions src/features/media/components/media-studio-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { ParameterForm } from "./parameter-form"
import { MediaPreviewDialog, type PreviewableAsset } from "./media-preview-dialog"
import { useMediaStudioStore, type StoreJob } from "@/features/media/store"
import { getCapability, referenceRoleLabel } from "@/features/media/model-capabilities"
import { useFreePlan } from "@/features/media/use-free-plan"
import {
GeneratingOverlay,
MODALITY_META,
Expand Down Expand Up @@ -66,14 +67,9 @@ export function MediaStudioPanel({ modality, models }: Props) {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// Cloud: media generation is a paid feature. Detect free plan to gate the
// generate action + show an upsell (the API also enforces this).
const [isFreePlan, setIsFreePlan] = useState(false)
useEffect(() => {
fetch("/api/dashboard/usage/free-limits", { credentials: "same-origin" })
.then((r) => (r.ok ? r.json() : null))
.then((d) => setIsFreePlan(!!d?.isFree))
.catch(() => {})
}, [])
// generate action + show an upsell (the API also enforces this). The probe
// lives in a hook so this component stays free of direct data-effects.
const isFreePlan = useFreePlan()

const {
jobs,
Expand Down
28 changes: 28 additions & 0 deletions src/features/media/use-free-plan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"use client"

import { useEffect, useState } from "react"

/**
* Detects whether the current org is on a free plan, used to gate the paid
* media-generation action and show an upsell.
*
* This is an intentional base/cloud seam: `/api/dashboard/usage/free-limits`
* exists only in the cloud deployment (paid feature). In the open-source base
* the route 404s, the probe resolves to `false`, and no gating applies — the
* API still enforces limits server-side regardless.
*
* Kept in a hook (outside the media component tree) so the studio components
* stay free of direct data-effects per the frontend-compliance policy.
*/
export function useFreePlan(): boolean {
const [isFreePlan, setIsFreePlan] = useState(false)

useEffect(() => {
fetch("/api/dashboard/usage/free-limits", { credentials: "same-origin" })
.then((r) => (r.ok ? r.json() : null))
.then((d) => setIsFreePlan(!!d?.isFree))
.catch(() => {})
}, [])

return isFreePlan
}
46 changes: 35 additions & 11 deletions tests/unit/rag/rerankers/cohere-reranker.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { CohereReranker } from "@/lib/rag/rerankers/cohere-reranker";
// Static import so the module's load-time side effects (which seed some
// KB_RERANK_* env defaults) run ONCE, before any test manipulates env.
// getDefaultReranker reads env lazily at call time, so per-test env changes win.
import {
getDefaultReranker,
CohereReranker as C,
LlmReranker,
} from "@/lib/rag/rerankers";

describe("CohereReranker", () => {
const originalFetch = global.fetch;
Expand Down Expand Up @@ -133,39 +141,55 @@ describe("CohereReranker", () => {
});

describe("getDefaultReranker (cohere provider)", () => {
const originalEnv = { ...process.env };
// Save/restore each KB_RERANK_* key individually. Reassigning
// `process.env = {...}` replaces Node's special env object with a plain one,
// which breaks later reads and cross-test isolation — restore per key instead.
const RERANK_KEYS = [
"KB_RERANK_ENABLED",
"KB_RERANK_PROVIDER",
"KB_RERANK_MODEL",
"KB_RERANK_API_KEY",
"COHERE_API_KEY",
];
const saved: Record<string, string | undefined> = {};

beforeEach(() => {
for (const k of RERANK_KEYS) {
saved[k] = process.env[k];
delete process.env[k];
}
});

afterEach(() => {
process.env = { ...originalEnv };
for (const k of RERANK_KEYS) {
if (saved[k] === undefined) delete process.env[k];
else process.env[k] = saved[k];
}
});

it("returns CohereReranker when KB_RERANK_PROVIDER=cohere", async () => {
it("returns CohereReranker when KB_RERANK_PROVIDER=cohere", () => {
process.env.KB_RERANK_ENABLED = "true";
process.env.KB_RERANK_PROVIDER = "cohere";
process.env.KB_RERANK_API_KEY = "cohere-test-key";
delete process.env.KB_RERANK_MODEL;
// KB_RERANK_MODEL cleared by beforeEach → cohere default applies.

const { getDefaultReranker, CohereReranker: C } = await import("@/lib/rag/rerankers");
const r = getDefaultReranker();
expect(r).toBeInstanceOf(C);
expect(r?.name).toBe("rerank-v4.0-pro");
});

it("falls back to COHERE_API_KEY when KB_RERANK_API_KEY is unset", async () => {
it("falls back to COHERE_API_KEY when KB_RERANK_API_KEY is unset", () => {
process.env.KB_RERANK_ENABLED = "true";
process.env.KB_RERANK_PROVIDER = "cohere";
delete process.env.KB_RERANK_API_KEY;
process.env.COHERE_API_KEY = "fallback-key";

const { getDefaultReranker } = await import("@/lib/rag/rerankers");
const r = getDefaultReranker();
expect(r).not.toBeNull();
});

it("still returns LlmReranker when KB_RERANK_PROVIDER is absent", async () => {
it("still returns LlmReranker when KB_RERANK_PROVIDER is absent", () => {
process.env.KB_RERANK_ENABLED = "true";
delete process.env.KB_RERANK_PROVIDER;
process.env.KB_RERANK_MODEL = "openai/gpt-4.1-nano";
const { getDefaultReranker, LlmReranker } = await import("@/lib/rag/rerankers");
expect(getDefaultReranker()).toBeInstanceOf(LlmReranker);
});
});
20 changes: 17 additions & 3 deletions tests/unit/tools/validate-artifact.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect, afterEach, vi } from "vitest"
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"
import { validateArtifactContent } from "@/lib/tools/builtin/_validate-artifact"

vi.mock("@/lib/unsplash/client", () => ({
Expand Down Expand Up @@ -1714,6 +1714,19 @@ describe("validateArtifactContent — application/react — aesthetic directive"
}
export default App`

// @aesthetic is auto-defaulted (a warning, not a hard error) by default — see
// the retry-loop fix in _validate-artifact.ts. The "hard-errors" cases below
// assert strict-mode enforcement, so enable the flag for this block.
const ORIG_AESTHETIC_REQUIRED = process.env.ARTIFACT_REACT_AESTHETIC_REQUIRED
beforeEach(() => {
process.env.ARTIFACT_REACT_AESTHETIC_REQUIRED = "true"
})
afterEach(() => {
if (ORIG_AESTHETIC_REQUIRED === undefined)
delete process.env.ARTIFACT_REACT_AESTHETIC_REQUIRED
else process.env.ARTIFACT_REACT_AESTHETIC_REQUIRED = ORIG_AESTHETIC_REQUIRED
})

it("accepts a valid @aesthetic directive", async () => {
const code = `// @aesthetic: editorial\n${MINIMAL_BODY}`
const r = await validateArtifactContent("application/react", code)
Expand Down Expand Up @@ -1889,10 +1902,11 @@ describe("validateArtifactContent — application/react — rollback flag", () =
else process.env.ARTIFACT_REACT_AESTHETIC_REQUIRED = orig
})

it("hard-errors on missing directive by default (flag unset)", async () => {
it("passes with a warning on missing directive by default (flag unset)", async () => {
delete process.env.ARTIFACT_REACT_AESTHETIC_REQUIRED
const r = await validateArtifactContent("application/react", BODY_WITHOUT_DIRECTIVE)
expect(r.ok).toBe(false)
expect(r.ok).toBe(true)
expect(r.warnings.join("\n")).toMatch(/@aesthetic.*missing/i)
})

it("hard-errors on missing directive when flag='true' (explicit)", async () => {
Expand Down
Loading