) => {
if (event.key === 'Enter' && !event.shiftKey) {
@@ -143,6 +147,7 @@ export function ChatComposer({
)}
+ {footer}
{value.length >= COUNTER_THRESHOLD && (
{value.length.toLocaleString()} / {MAX_CHAT_MESSAGE_LENGTH.toLocaleString()}
diff --git a/components/Notebook/AgentChat/CreditMeter.tsx b/components/Notebook/AgentChat/CreditMeter.tsx
new file mode 100644
index 000000000..964b020b5
--- /dev/null
+++ b/components/Notebook/AgentChat/CreditMeter.tsx
@@ -0,0 +1,65 @@
+'use client';
+
+import type { ResearchAIState } from '@/store/researchAI';
+import { formatBudgetReset, formatCredits, isBudgetExhausted } from '@/types/researchAI';
+
+export function CreditMeter({
+ budget,
+ budgetStatus,
+ limitResetAt,
+ onRefresh,
+}: Pick & { onRefresh: () => void }) {
+ if (budget?.tier === 'blocked') {
+ return (
+
+ Research AI is unavailable for this account.
+
+ );
+ }
+ if (!budget) {
+ return (
+
+ {budgetStatus === 'loading' ? 'Loading AI credits…' : 'Couldn’t load AI credits.'}
+ {budgetStatus === 'unavailable' && (
+
+ Retry
+
+ )}
+
+ );
+ }
+ const exhausted = isBudgetExhausted(budget) || limitResetAt !== null;
+ const { remaining, daily_limit: limit } = budget.credits;
+ const reset = formatBudgetReset(budget.resets_at);
+ return (
+
+
+
+ {limit === null
+ ? 'Unlimited credits'
+ : remaining === null
+ ? 'Credits unavailable'
+ : `${formatCredits(remaining)} credits remaining`}
+
+
+ Resets at {reset}
+
+
+ {exhausted && (
+
+ Daily AI usage limit reached. Available again at {reset}.
+
+ )}
+ {budgetStatus === 'unavailable' && (
+
+ Credits may be out of date.{' '}
+
+ Refresh
+
+
+ )}
+
+ );
+}
diff --git a/components/Notebook/AgentChat/ModelControls.tsx b/components/Notebook/AgentChat/ModelControls.tsx
index 8876efeb0..10f5c4e57 100644
--- a/components/Notebook/AgentChat/ModelControls.tsx
+++ b/components/Notebook/AgentChat/ModelControls.tsx
@@ -10,6 +10,7 @@ import {
clampTemperature,
EFFORT_LABELS,
formatTemperature,
+ formatModelMultiplier,
summarizeGenerationOptions,
TEMPERATURE_MAX,
TEMPERATURE_MIN,
@@ -33,6 +34,7 @@ interface ModelControlsProps {
readonly onSelectModel: (ref: string) => void;
readonly onChangeOptions: (options: GenerationOptions) => void;
readonly disabled: boolean;
+ readonly multiplierExplanation: string;
}
type OpenMenu = 'model' | 'effort' | null;
@@ -59,6 +61,7 @@ export function ModelControls({
onSelectModel,
onChangeOptions,
disabled,
+ multiplierExplanation,
}: ModelControlsProps) {
const [openMenu, setOpenMenu] = useState(null);
const containerRef = useRef(null);
@@ -119,7 +122,7 @@ export function ModelControls({
{model.label}
- {hasEffortMenu && (
+ {hasEffortMenu && model.allowed && (
toggle('effort')}
open={openMenu === 'effort'}
@@ -133,20 +136,23 @@ export function ModelControls({
)}
- {openMenu === 'model' && (
+ {openMenu === 'model' && !disabled && !pinned && (
- {models.map((option) => (
-
{
- setOpenMenu(null);
- onSelectModel(option.ref);
- }}
- />
- ))}
+ {models
+ .filter((option) => option.allowed)
+ .map((option) => (
+ {
+ setOpenMenu(null);
+ onSelectModel(option.ref);
+ }}
+ />
+ ))}
{models.length === 0 && (
No models are available.
)}
@@ -154,7 +160,7 @@ export function ModelControls({
)}
- {openMenu === 'effort' && (
+ {openMenu === 'effort' && !disabled && model.allowed && (
{effortLevels.length > 0 && (
@@ -291,7 +297,9 @@ function ModelRow({
model,
selected,
onSelect,
+ multiplierExplanation,
}: {
+ readonly multiplierExplanation: string;
readonly model: AgentModel;
readonly selected: boolean;
readonly onSelect: () => void;
@@ -299,6 +307,7 @@ function ModelRow({
return (
)}
+
+ {formatModelMultiplier(model.multiplier)}
+
);
}
diff --git a/hooks/useAgentModelSelection.ts b/hooks/useAgentModelSelection.ts
index 46acf98c8..8709ed9c8 100644
--- a/hooks/useAgentModelSelection.ts
+++ b/hooks/useAgentModelSelection.ts
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { useAgentModels, type AgentModelsStatus } from '@/hooks/useAgentModels';
import {
findModel,
+ modelMultiplierExplanation,
normalizeGenerationOptions,
unknownModel,
type AgentModel,
@@ -11,43 +12,26 @@ import {
type GenerationRequest,
} from '@/types/notebookModels';
-const STORAGE_KEY = 'notebook:agent-model';
-
-/** Stable empty list so consumers can depend on `models` by identity. */
const NO_MODELS: AgentModel[] = [];
-
-/**
- * The user's raw choices, kept exactly as they made them. Values a given
- * model can't take are dropped on the way out rather than on the way in, so
- * an effort survives a detour through a model that doesn't offer it.
- */
interface StoredPreference extends GenerationOptions {
ref?: string;
}
-function readPreference(): StoredPreference {
- try {
- const raw = window.localStorage.getItem(STORAGE_KEY);
- if (!raw) return {};
- const parsed: unknown = JSON.parse(raw);
- return parsed != null && typeof parsed === 'object' ? (parsed as StoredPreference) : {};
- } catch {
- // Unparseable, or storage denied — fall back to the server's defaults.
- return {};
- }
-}
-
export interface UseAgentModelSelectionOptions {
readonly enabled: boolean;
+ readonly canSelect: boolean;
+ readonly conversationKey: string;
+ readonly locked: boolean;
/**
* The model the open chat's first turn ran on. A conversation keeps its
- * model for life, so this — when set — outranks the user's standing choice.
+ * model for life, so this — when set — outranks the new-chat default.
*/
readonly pinnedRef: string | null;
}
export interface AgentModelSelection {
readonly status: AgentModelsStatus;
+ readonly multiplierExplanation: string;
readonly models: AgentModel[];
/** The model the next turn runs on, or null while there is no catalog. */
readonly model: AgentModel | null;
@@ -62,46 +46,33 @@ export interface AgentModelSelection {
readonly request: GenerationRequest;
}
-/**
- * Which model the next turn runs on, and how.
- *
- * The model choice is a browser-level preference — the last one picked is the
- * one a new chat starts on — while a chat already under way reports its own
- * pin, which wins. The generation controls stay per-turn either way: the
- * server re-reads them on every message, so effort and thinking remain live
- * even on a chat whose model is settled.
- */
+/** New conversations start with the API default; choices live only in this chat. */
export function useAgentModelSelection({
enabled,
+ canSelect,
+ conversationKey,
+ locked,
pinnedRef,
}: UseAgentModelSelectionOptions): AgentModelSelection {
const { status, catalog } = useAgentModels(enabled);
- const [preference, setPreference] = useState
({});
- const [hydrated, setHydrated] = useState(false);
-
- // Read after mount, never during initialization: localStorage is unavailable
- // on the server and a differing first client render would hydrate-mismatch.
+ const [choice, setChoice] = useState<{ key: string; preference: StoredPreference }>({
+ key: conversationKey,
+ preference: {},
+ });
+ const preference = choice.key === conversationKey ? choice.preference : {};
useEffect(() => {
- setPreference(readPreference());
- setHydrated(true);
- }, []);
-
- useEffect(() => {
- if (!hydrated) return;
- try {
- window.localStorage.setItem(STORAGE_KEY, JSON.stringify(preference));
- } catch {
- // A blocked or full store just means the choice lasts this session.
- }
- }, [hydrated, preference]);
-
- const models = catalog?.models ?? NO_MODELS;
+ setChoice({ key: conversationKey, preference: {} });
+ }, [conversationKey]);
+ const models = useMemo(
+ () => catalog?.models.filter((model) => model.allowed) ?? NO_MODELS,
+ [catalog]
+ );
const model = useMemo(() => {
if (catalog == null) return null;
// A pinned ref is named even when the catalog no longer carries it, so a
// chat on a retired model still says what it is running.
- if (pinnedRef) return findModel(models, pinnedRef) ?? unknownModel(pinnedRef);
+ if (pinnedRef) return findModel(catalog.models, pinnedRef) ?? unknownModel(pinnedRef);
return (
findModel(models, preference.ref ?? null) ??
findModel(models, catalog.default) ??
@@ -115,26 +86,39 @@ export function useAgentModelSelection({
[model, preference]
);
- const selectModel = useCallback((ref: string) => {
- setPreference((current) => ({ ...current, ref }));
- }, []);
+ const selectModel = useCallback(
+ (ref: string) => {
+ if (!canSelect || locked || !models.some((model) => model.ref === ref)) return;
+ setChoice((current) => ({
+ key: conversationKey,
+ preference: { ...(current.key === conversationKey ? current.preference : {}), ref },
+ }));
+ },
+ [canSelect, locked, models, conversationKey]
+ );
- const setOptions = useCallback((next: GenerationOptions) => {
- setPreference((current) => ({ ...current, ...next }));
- }, []);
+ const setOptions = useCallback(
+ (next: GenerationOptions) => {
+ if (!canSelect) return;
+ setChoice((current) => ({
+ key: conversationKey,
+ preference: { ...(current.key === conversationKey ? current.preference : {}), ...next },
+ }));
+ },
+ [canSelect, conversationKey]
+ );
const request = useMemo(() => {
- if (model == null) return {};
- // Naming the pinned model again would be accepted, but only while nothing
- // raced us. Leaving it out lets the server answer from its own record.
- return { ...(pinnedRef == null && { model: model.ref }), ...options };
- }, [model, options, pinnedRef]);
+ if (!canSelect || model == null || !model.allowed) return {};
+ return { ...(!locked && { model: model.ref }), ...options };
+ }, [canSelect, model, options, locked]);
return {
status,
+ multiplierExplanation: modelMultiplierExplanation(catalog),
models,
model,
- pinned: pinnedRef != null,
+ pinned: locked,
options,
selectModel,
setOptions,
diff --git a/hooks/useAgentModels.ts b/hooks/useAgentModels.ts
index ed4d376bc..99912a649 100644
--- a/hooks/useAgentModels.ts
+++ b/hooks/useAgentModels.ts
@@ -1,64 +1,16 @@
'use client';
-import { useEffect, useState } from 'react';
-import { AgentModelService } from '@/services/agentModel.service';
+import { useResearchAI } from '@/hooks/useResearchAI';
import type { AgentModelCatalog } from '@/types/notebookModels';
-/**
- * `loading` until the first fetch settles; `unavailable` for every failure —
- * the gate, a network blip, a backend without the endpoint. All of them mean
- * the same thing to the UI: no picker, and turns run on the server's default.
- */
export type AgentModelsStatus = 'loading' | 'ok' | 'unavailable';
-
export interface UseAgentModelsResult {
readonly status: AgentModelsStatus;
readonly catalog: AgentModelCatalog | null;
}
-// The catalog is per-deployment, not per-note or per-user-action: one fetch
-// serves every panel this session. Only successes are cached, so a transient
-// failure is retried by the next mount rather than disabling the picker for
-// the rest of the session.
-let cachedCatalog: AgentModelCatalog | null = null;
-let inFlight: Promise | null = null;
-
-function loadCatalog(): Promise {
- if (cachedCatalog) return Promise.resolve(cachedCatalog);
- inFlight ??= AgentModelService.listModels()
- .then((catalog) => {
- cachedCatalog = catalog;
- return catalog;
- })
- .finally(() => {
- inFlight = null;
- });
- return inFlight;
-}
-
-/** The models this user may select. Fetched once, when something needs it. */
+/** Availability is user-specific and refreshed with the shared AI budget. */
export function useAgentModels(enabled: boolean): UseAgentModelsResult {
- const [result, setResult] = useState(() =>
- cachedCatalog ? { status: 'ok', catalog: cachedCatalog } : { status: 'loading', catalog: null }
- );
-
- useEffect(() => {
- if (!enabled || result.catalog != null) return;
- let cancelled = false;
- loadCatalog().then(
- (catalog) => {
- if (!cancelled) setResult({ status: 'ok', catalog });
- },
- () => {
- // Deliberately terminal for this mount: the deps can't change back, so
- // a failing endpoint is asked once, not once per render.
- if (!cancelled) setResult({ status: 'unavailable', catalog: null });
- }
- );
- return () => {
- cancelled = true;
- };
- }, [enabled, result.catalog]);
-
- return result;
+ const { catalog, catalogStatus } = useResearchAI(enabled);
+ return { catalog, status: catalogStatus };
}
diff --git a/hooks/useNotebookChat.ts b/hooks/useNotebookChat.ts
index e6fd132f4..51a08cc38 100644
--- a/hooks/useNotebookChat.ts
+++ b/hooks/useNotebookChat.ts
@@ -4,7 +4,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { debounce, type DebouncedFunc } from 'lodash-es';
import {
NotebookChatService,
- chatErrorDetail,
+ chatErrorBody,
+ sendFailureOutcome,
+ type SendOutcome,
chatErrorStatus,
} from '@/services/notebookChat.service';
import { useNotebookChatSocket, type ChatSocketStatus } from '@/hooks/useNotebookChatSocket';
@@ -21,6 +23,8 @@ import {
type NotebookChat,
type NotebookChatListItem,
} from '@/types/notebookChat';
+import { useResearchAI } from '@/hooks/useResearchAI';
+import { canSelectAIModel } from '@/types/researchAI';
import type { GenerationRequest } from '@/types/notebookModels';
/** Fallback poll cadence while a turn runs; the socket nudge usually wins. */
@@ -40,31 +44,7 @@ const STREAM_CHAR_CAPS: Record = {
export type ChatAccess = 'loading' | 'ok' | 'not_found' | 'unauthorized' | 'error';
-export type SendOutcome =
- | { ok: true }
- | {
- ok: false;
- reason: 'busy' | 'invalid' | 'not_found' | 'unauthorized' | 'error';
- detail?: string;
- };
-
-/** Maps a failed send POST to its outcome; the state side-effects stay in `send`. */
-function sendFailureOutcome(err: unknown): Extract {
- const detail = chatErrorDetail(err);
- switch (chatErrorStatus(err)) {
- case 409:
- return { ok: false, reason: 'busy', detail };
- case 400:
- return { ok: false, reason: 'invalid', detail };
- case 401:
- case 403:
- return { ok: false, reason: 'unauthorized', detail };
- case 404:
- return { ok: false, reason: 'not_found', detail };
- default:
- return { ok: false, reason: 'error', detail };
- }
-}
+export type { SendOutcome } from '@/services/notebookChat.service';
export interface PendingSend {
text: string;
@@ -281,6 +261,8 @@ export function useNotebookChat({
enabled,
initialChat = null,
}: UseNotebookChatOptions): UseNotebookChatResult {
+ const { refreshBudget, refreshCatalog, recordLimit, getSnapshot, isSubmissionBlocked } =
+ useResearchAI();
const [chat, setChat] = useState(null);
const [access, setAccess] = useState('loading');
const [pendingSend, setPendingSend] = useState(null);
@@ -316,6 +298,19 @@ export function useNotebookChat({
try {
const data = await NotebookChatService.getChat(noteId, chatId, { live });
if (seq !== seqRef.current) return;
+ const previous = chatRef.current;
+ const settled = data.executions.filter(
+ (execution) =>
+ !isActiveExecutionStatus(execution.status) &&
+ previous?.executions.find((cached) => cached.id === execution.id)?.status !==
+ execution.status
+ );
+ for (const execution of settled) {
+ if (execution.error?.code === 'usage_limit_exceeded') {
+ recordLimit(undefined, execution.finished_at ?? execution.started_at);
+ }
+ }
+ if (settled.length > 0) void refreshBudget(true);
setChat((prev) => {
const merged = mergeLiveChat(live ? prev : null, data);
chatRef.current = merged;
@@ -337,7 +332,7 @@ export function useNotebookChat({
}
}
},
- [noteId, chatId]
+ [noteId, chatId, refreshBudget, recordLimit]
);
// Reset + initial load whenever the target chat changes or the panel opens.
@@ -408,12 +403,13 @@ export function useNotebookChat({
const timer = setInterval(() => {
if (inFlight) return;
inFlight = true;
+ void refreshBudget();
fetchChat('live').finally(() => {
inFlight = false;
});
}, POLL_INTERVAL_MS);
return () => clearInterval(timer);
- }, [enabled, access, isBusy, isFinishing, fetchChat]);
+ }, [enabled, access, isBusy, isFinishing, fetchChat, refreshBudget]);
// Debounced lifecycle nudge / stream-gap repair → live refetch.
const nudgeRef = useRef void> | null>(null);
@@ -447,6 +443,7 @@ export function useNotebookChat({
(event: ChatSocketEvent) => {
if (event.conversation_id !== chatId) return;
if (!isChatStreamSocketEvent(event)) {
+ void refreshBudget(['turn_finished', 'turn_failed', 'turn_cancelled'].includes(event.kind));
nudgeRef.current?.();
return;
}
@@ -463,7 +460,7 @@ export function useNotebookChat({
setChat(applied.chat);
}
},
- [chatId, repairStream]
+ [chatId, repairStream, refreshBudget]
);
const handleSocketReconnect = useCallback(() => {
@@ -482,10 +479,17 @@ export function useNotebookChat({
const send = useCallback(
async (text: string, generation?: GenerationRequest): Promise => {
if (noteId == null || chatId == null) return { ok: false, reason: 'error' };
+ if (getSnapshot().budget?.tier === 'blocked') return { ok: false, reason: 'unauthorized' };
+ if (isSubmissionBlocked()) return { ok: false, reason: 'usage_limit' };
const epoch = epochRef.current;
setPendingSend({ text, executionId: null });
try {
- const response = await NotebookChatService.sendMessage(noteId, chatId, text, generation);
+ const response = await NotebookChatService.sendMessage(
+ noteId,
+ chatId,
+ text,
+ canSelectAIModel(getSnapshot().budget?.tier) ? generation : undefined
+ );
if (epoch === epochRef.current) {
setPendingSend({ text, executionId: response.execution_id });
fetchChat('live');
@@ -493,12 +497,15 @@ export function useNotebookChat({
return { ok: true };
} catch (err) {
const outcome = sendFailureOutcome(err);
+ if (outcome.reason === 'usage_limit') recordLimit(chatErrorBody(err));
+ else void refreshBudget(true);
+ if (outcome.reason === 'model_not_allowed') void refreshCatalog();
// The outcome is still reported either way, but a continuation for a
// chat that is no longer selected must not mutate the current one.
if (epoch === epochRef.current) {
setPendingSend(null);
// Raced an active turn — refetch so the busy state renders truthfully.
- if (outcome.reason === 'busy') fetchChat('live');
+ if (outcome.reason === 'busy' || outcome.reason === 'account_busy') fetchChat('live');
if (outcome.reason === 'not_found') setAccess('not_found');
// Session expired or permission revoked mid-chat: mirror what a
// failed GET does so the access gate reacts instead of the composer
@@ -511,7 +518,16 @@ export function useNotebookChat({
return outcome;
}
},
- [noteId, chatId, fetchChat]
+ [
+ noteId,
+ chatId,
+ fetchChat,
+ refreshBudget,
+ refreshCatalog,
+ recordLimit,
+ getSnapshot,
+ isSubmissionBlocked,
+ ]
);
const cancel = useCallback(async () => {
@@ -523,8 +539,9 @@ export function useNotebookChat({
} catch {
// Fall through: the refetch below renders whatever actually happened.
}
+ void refreshBudget(true);
if (epoch === epochRef.current) fetchChat('live');
- }, [noteId, chatId, fetchChat]);
+ }, [noteId, chatId, fetchChat, refreshBudget]);
const rename = useCallback(
async (title: string): Promise => {
diff --git a/hooks/useResearchAI.ts b/hooks/useResearchAI.ts
new file mode 100644
index 000000000..5c8d57d3f
--- /dev/null
+++ b/hooks/useResearchAI.ts
@@ -0,0 +1,77 @@
+'use client';
+
+import { useEffect, useMemo, useSyncExternalStore } from 'react';
+import { useSession } from 'next-auth/react';
+import { ApiClient } from '@/services/client';
+import { AgentModelService } from '@/services/agentModel.service';
+import { createResearchAIStore, INITIAL_RESEARCH_AI_STATE } from '@/store/researchAI';
+
+const stores = new Map>();
+const createStore = () =>
+ createResearchAIStore({
+ budget: () => ApiClient.get('/api/research_ai/usage-budget/'),
+ catalog: () => AgentModelService.listModels(),
+ });
+
+/** Session-scoped memory only: never persist allowances or share them between accounts. */
+export function useResearchAI(enabled = false) {
+ const { data: session } = useSession();
+ const token = session?.authToken;
+ const store = useMemo(() => {
+ if (!token || typeof window === 'undefined') return createStore();
+ let current = stores.get(token);
+ if (!current) {
+ current = createStore();
+ stores.set(token, current);
+ }
+ return current;
+ }, [token]);
+ const state = useSyncExternalStore(
+ store.subscribe,
+ store.getSnapshot,
+ () => INITIAL_RESEARCH_AI_STATE
+ );
+
+ useEffect(() => {
+ if (!enabled || !token) return;
+ const refresh = () => {
+ void store.refreshBudget(true);
+ void store.refreshCatalog();
+ };
+ refresh();
+ window.addEventListener('focus', refresh);
+ return () => window.removeEventListener('focus', refresh);
+ }, [enabled, token, store]);
+
+ // Refresh at reset even when the composer is idle. Failed/stale reset reads
+ // retry with a bounded delay rather than leaving Send disabled all day.
+ useEffect(() => {
+ if (!enabled || !token) return;
+ const resetsAt = state.budget?.resets_at ?? state.limitResetAt;
+ if (!resetsAt) return;
+ let timer: ReturnType;
+ let cancelled = false;
+ const schedule = () => {
+ timer = setTimeout(
+ async () => {
+ await store.refreshBudget(true);
+ if (!cancelled) schedule();
+ },
+ Math.min(
+ 2_147_483_647,
+ Math.max(
+ 5_000,
+ Date.parse(store.getSnapshot().budget?.resets_at ?? resetsAt) - Date.now() + 1000
+ )
+ )
+ );
+ };
+ schedule();
+ return () => {
+ cancelled = true;
+ clearTimeout(timer);
+ };
+ }, [enabled, token, store, state.budget?.resets_at, state.limitResetAt]);
+
+ return { ...state, ...store };
+}
diff --git a/services/notebookChat.service.ts b/services/notebookChat.service.ts
index 36bf24a64..8c92632f3 100644
--- a/services/notebookChat.service.ts
+++ b/services/notebookChat.service.ts
@@ -93,9 +93,77 @@ export function chatErrorStatus(error: unknown): number | undefined {
*/
export function chatErrorDetail(error: unknown): string | undefined {
if (error instanceof ApiError) {
- const detail = (error.errors as Record | undefined)?.detail;
+ const fields = error.errors as Record | undefined;
+ const detail = fields?.detail;
if (typeof detail === 'string' && detail.length > 0) return detail;
+ if (fields) {
+ const messages = Object.entries(fields).flatMap(([field, value]) =>
+ Array.isArray(value)
+ ? value
+ .filter((entry): entry is string => typeof entry === 'string')
+ .map((entry) => `${field}: ${entry}`)
+ : []
+ );
+ if (messages.length) return messages.join(' ');
+ }
return error.message;
}
return error instanceof Error ? error.message : undefined;
}
+
+export function chatErrorCode(error: unknown): string | undefined {
+ const code = chatErrorBody(error)?.code;
+ return typeof code === 'string' ? code : undefined;
+}
+
+export function chatErrorBody(error: unknown): Record | undefined {
+ return error instanceof ApiError
+ ? (error.errors as Record | undefined)
+ : undefined;
+}
+
+export type SendOutcome =
+ | { ok: true }
+ | {
+ ok: false;
+ reason:
+ | 'usage_limit'
+ | 'account_busy'
+ | 'model_not_allowed'
+ | 'busy'
+ | 'invalid'
+ | 'not_found'
+ | 'unauthorized'
+ | 'error';
+ detail?: string;
+ };
+
+/** Maps a failed send POST to its outcome; the state side-effects stay in `send`. */
+export function sendFailureOutcome(err: unknown): Extract {
+ const detail = chatErrorDetail(err);
+ const code = chatErrorCode(err);
+ switch (chatErrorStatus(err)) {
+ case 429:
+ return code === 'usage_limit_exceeded'
+ ? { ok: false, reason: 'usage_limit', detail: 'Daily AI usage limit reached.' }
+ : { ok: false, reason: 'error', detail };
+
+ case 409:
+ return code === 'usage_work_in_progress'
+ ? { ok: false, reason: 'account_busy', detail: 'Another AI request is still running.' }
+ : { ok: false, reason: 'busy', detail: 'This conversation already has an active turn.' };
+ case 400:
+ return {
+ ok: false,
+ reason: code === 'model_not_allowed' ? 'model_not_allowed' : 'invalid',
+ detail,
+ };
+ case 401:
+ case 403:
+ return { ok: false, reason: 'unauthorized', detail };
+ case 404:
+ return { ok: false, reason: 'not_found', detail };
+ default:
+ return { ok: false, reason: 'error', detail };
+ }
+}
diff --git a/store/researchAI.ts b/store/researchAI.ts
new file mode 100644
index 000000000..d8d215b47
--- /dev/null
+++ b/store/researchAI.ts
@@ -0,0 +1,125 @@
+import type { AgentModelCatalog } from '@/types/notebookModels';
+import { isBudgetExhausted, isResearchAIBudget, type ResearchAIBudget } from '@/types/researchAI';
+
+export interface ResearchAIState {
+ budget: ResearchAIBudget | null;
+ budgetStatus: 'loading' | 'ok' | 'unavailable';
+ catalog: AgentModelCatalog | null;
+ catalogStatus: 'loading' | 'ok' | 'unavailable';
+ /** A provider may reject its next call while some recorded credits remain. */
+ limitResetAt: string | null;
+}
+
+export const INITIAL_RESEARCH_AI_STATE: ResearchAIState = {
+ budget: null,
+ budgetStatus: 'loading',
+ catalog: null,
+ catalogStatus: 'loading',
+ limitResetAt: null,
+};
+
+/** One store per authenticated session, shared across notes and AI workflows. */
+export function createResearchAIStore(loaders: {
+ budget: () => Promise;
+ catalog: () => Promise;
+}) {
+ let state = INITIAL_RESEARCH_AI_STATE;
+ const listeners = new Set<() => void>();
+ let budgetFlight: Promise | null = null;
+ let catalogFlight: Promise | null = null;
+ let budgetQueued = false;
+ let budgetRevision = 0;
+ let lastBudgetFetch = 0;
+
+ const update = (patch: Partial) => {
+ state = { ...state, ...patch };
+ listeners.forEach((listener) => listener());
+ };
+ const acceptBudget = (budget: ResearchAIBudget) => {
+ update({
+ budget,
+ budgetStatus: 'ok',
+ limitResetAt:
+ state.limitResetAt && Date.parse(budget.resets_at) <= Date.parse(state.limitResetAt)
+ ? state.limitResetAt
+ : null,
+ });
+ };
+
+ const refreshBudget = (force = false): Promise => {
+ if (budgetFlight) {
+ // A terminal event may follow the snapshot of the currently running GET.
+ if (force) budgetQueued = true;
+ return budgetFlight;
+ }
+ if (!force && Date.now() - lastBudgetFetch < 15_000) return Promise.resolve();
+ lastBudgetFetch = Date.now();
+ const revision = budgetRevision;
+ budgetFlight = loaders
+ .budget()
+ .then((value) => {
+ if (revision !== budgetRevision) return;
+ if (!isResearchAIBudget(value)) throw new Error('Credit budget unavailable');
+ acceptBudget(value);
+ })
+ .catch(() => {
+ if (revision === budgetRevision) update({ budgetStatus: 'unavailable' });
+ })
+ .finally(() => {
+ budgetFlight = null;
+ if (budgetQueued) {
+ budgetQueued = false;
+ void refreshBudget(true);
+ }
+ });
+ return budgetFlight;
+ };
+
+ const refreshCatalog = (): Promise => {
+ if (catalogFlight) return catalogFlight;
+ catalogFlight = loaders
+ .catalog()
+ .then((catalog) => update({ catalog, catalogStatus: 'ok' }))
+ .catch(() => update({ catalog: null, catalogStatus: 'unavailable' }))
+ .finally(() => {
+ catalogFlight = null;
+ });
+ return catalogFlight;
+ };
+
+ return {
+ getSnapshot: () => state,
+ subscribe: (listener: () => void) => {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+ },
+ refreshBudget,
+ refreshCatalog,
+ /** 429 budget is top-level, and must outrank an older in-flight GET. */
+ recordLimit: (value?: unknown, occurredAt?: string | null) => {
+ // Reopening yesterday's failed conversation must not exhaust today's allowance.
+ if (
+ occurredAt &&
+ Number.isFinite(Date.parse(occurredAt)) &&
+ new Date(occurredAt).toISOString().slice(0, 10) !== new Date().toISOString().slice(0, 10)
+ )
+ return;
+ budgetRevision += 1;
+ if (isResearchAIBudget(value)) acceptBudget(value);
+ const nextReset = new Date();
+ nextReset.setUTCHours(24, 0, 0, 0);
+ const knownReset = state.budget?.resets_at;
+ update({
+ limitResetAt:
+ knownReset && Date.parse(knownReset) > Date.now() ? knownReset : nextReset.toISOString(),
+ });
+ void refreshBudget(true);
+ },
+ isSubmissionBlocked: () =>
+ state.budget?.tier === 'blocked' ||
+ isBudgetExhausted(state.budget) ||
+ state.limitResetAt !== null,
+ };
+}
diff --git a/tests/notebook-ai.test.cjs b/tests/notebook-ai.test.cjs
new file mode 100644
index 000000000..3a923f44e
--- /dev/null
+++ b/tests/notebook-ai.test.cjs
@@ -0,0 +1,367 @@
+/* eslint-disable @typescript-eslint/no-require-imports -- Standalone CommonJS Node test runner. */
+// Run with: node --test tests/notebook-ai.test.cjs
+// Uses the repository's TypeScript compiler and Node's test runner; no extra test dependencies.
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const { readFileSync, existsSync } = require('node:fs');
+const path = require('node:path');
+const ts = require('typescript');
+const root = path.resolve(__dirname, '..');
+const cache = new Map();
+const api = {};
+function load(relative) {
+ const filename = path.resolve(root, relative);
+ if (cache.has(filename)) return cache.get(filename).exports;
+ const compiledModule = { exports: {} };
+ cache.set(filename, compiledModule);
+ const source = ts.transpileModule(readFileSync(filename, 'utf8'), {
+ compilerOptions: {
+ module: ts.ModuleKind.CommonJS,
+ target: ts.ScriptTarget.ES2022,
+ jsx: ts.JsxEmit.ReactJSX,
+ },
+ }).outputText;
+ const localRequire = (specifier) => {
+ if (
+ specifier === '@/services/client' ||
+ (specifier === './client' && relative.startsWith('services/'))
+ )
+ return { ApiClient: api };
+ if (specifier === '@/hooks/useAgentModels')
+ return { useAgentModels: () => ({ status: 'ok', catalog }) };
+ if (!specifier.startsWith('@/') && !specifier.startsWith('.')) return require(specifier);
+ const base = specifier.startsWith('@/')
+ ? path.join(root, specifier.slice(2))
+ : path.resolve(path.dirname(filename), specifier);
+ const target = [base + '.ts', base + '.tsx', path.join(base, 'index.ts')].find(existsSync);
+ return load(path.relative(root, target));
+ };
+ new Function('require', 'module', 'exports', source)(
+ localRequire,
+ compiledModule,
+ compiledModule.exports
+ );
+ return compiledModule.exports;
+}
+const budgetTypes = load('types/researchAI.ts');
+const models = load('types/notebookModels.ts');
+const { createResearchAIStore } = load('store/researchAI.ts');
+const { ApiError } = load('services/types/api.ts');
+const service = load('services/notebookChat.service.ts');
+const { renderToStaticMarkup } = require('react-dom/server');
+const { createElement } = require('react');
+const { CreditMeter } = load('components/Notebook/AgentChat/CreditMeter.tsx');
+const { ChatComposer } = load('components/Notebook/AgentChat/ChatComposer.tsx');
+const { ModelControls } = load('components/Notebook/AgentChat/ModelControls.tsx');
+const tomorrow = new Date();
+tomorrow.setUTCHours(24, 0, 0, 0);
+const budget = (overrides = {}) => ({
+ tier: 'default',
+ credits: { daily_limit: '250', used: '1.65', remaining: '248.35' },
+ turns_used: 2,
+ turn_cap: 10,
+ resets_at: tomorrow.toISOString(),
+ ...overrides,
+});
+const catalog = models.toAgentModelCatalog({
+ default: 'openrouter:test',
+ credit_pricing: {
+ multiplier_base_model: 'openrouter:base',
+ multiplier_basis: 'equal_input_output_tokens',
+ multiplier_is_estimate: true,
+ },
+ models: [
+ {
+ ref: 'openrouter:test',
+ label: 'Flash',
+ allowed: true,
+ multiplier: '0.03',
+ capabilities: { effort: ['low', 'high'], thinking: [], temperature: false },
+ },
+ { ref: 'openrouter:base', label: 'Baseline', allowed: true, multiplier: '1' },
+ { ref: 'openrouter:unpriced', allowed: false, multiplier: null },
+ ],
+});
+const storeWith = (getBudget = async () => budget()) =>
+ createResearchAIStore({ budget: getBudget, catalog: async () => catalog });
+const flush = () => new Promise((resolve) => setImmediate(resolve));
+
+test('credit exhaustion and provider-call cap are independent; null means unlimited', () => {
+ assert.equal(budgetTypes.isBudgetExhausted(budget()), false);
+ assert.equal(
+ budgetTypes.isBudgetExhausted(
+ budget({ credits: { daily_limit: '250', used: '250', remaining: '0.00' } })
+ ),
+ true
+ );
+ assert.equal(budgetTypes.isBudgetExhausted(budget({ turns_used: 10 })), true);
+ assert.equal(
+ budgetTypes.isBudgetExhausted(
+ budget({
+ credits: { daily_limit: null, used: '999', remaining: null },
+ turn_cap: null,
+ turns_used: 999,
+ })
+ ),
+ false
+ );
+ assert.equal(budgetTypes.isResearchAIBudget({ tier: 'default', remaining: '100' }), false);
+});
+
+test('tiers and catalog permissions are authoritative; fractions are not rounded to zero', () => {
+ assert.equal(budgetTypes.canSelectAIModel('default'), false);
+ assert.equal(budgetTypes.canSelectAIModel('blocked'), false);
+ assert.equal(budgetTypes.canSelectAIModel('invited'), true);
+ assert.equal(budgetTypes.canSelectAIModel('privileged'), true);
+ assert.equal(catalog.models[2].allowed, false);
+ assert.equal(models.formatModelMultiplier('0.03'), '0.03×');
+ assert.equal(models.formatModelMultiplier('3.75'), '3.75×');
+ assert.equal(models.formatModelMultiplier('0.001'), '<0.01×');
+ assert.equal(models.formatModelMultiplier(null), 'Pricing unavailable');
+ assert.equal(budgetTypes.formatCredits('0.00010'), '0.00');
+ assert.equal(budgetTypes.formatCredits('12345.6'), '12,345.60');
+ assert.equal(budgetTypes.formatCredits('250'), '250.00');
+ assert.match(models.modelMultiplierExplanation(catalog), /relative to Baseline/);
+ assert.deepEqual(
+ models.normalizeGenerationOptions(catalog.models[0], {
+ effort: 'high',
+ thinking: 'adaptive',
+ temperature: 1,
+ }),
+ { effort: 'high' }
+ );
+});
+
+test('meter shows fractional credits, daily cap exhaustion, local reset and unlimited balances', () => {
+ const render = (b) =>
+ renderToStaticMarkup(
+ createElement(CreditMeter, {
+ budget: b,
+ budgetStatus: 'ok',
+ limitResetAt: null,
+ onRefresh() {},
+ })
+ );
+ assert.match(render(budget()), /248.35 credits remaining/);
+ assert.match(render(budget()), /250\.00 daily credits/);
+ assert.match(render(budget()), /Resets at/);
+ const capped = render(budget({ turns_used: 10 }));
+ assert.match(capped, /Daily AI usage limit reached/);
+ assert.doesNotMatch(capped, /Out of credits|messages remaining/);
+ assert.match(
+ render(budget({ credits: { daily_limit: null, remaining: null, used: '1' } })),
+ /Unlimited credits/
+ );
+ const previousTZ = process.env.TZ;
+ process.env.TZ = 'America/New_York';
+ assert.match(budgetTypes.formatBudgetReset('2026-09-05T00:00:00Z'), /8:00/);
+ if (previousTZ === undefined) delete process.env.TZ;
+ else process.env.TZ = previousTZ;
+});
+
+test('exhaustion disables Send but retains editable draft and Stop', () => {
+ const props = {
+ value: 'Keep my unsent question',
+ onChange() {},
+ onSend() {},
+ onStop() {},
+ busy: false,
+ canStop: false,
+ disabled: false,
+ sendDisabled: true,
+ notice: null,
+ textareaRef: { current: null },
+ };
+ const html = renderToStaticMarkup(createElement(ChatComposer, props));
+ assert.match(html, /Keep my unsent question/);
+ assert.doesNotMatch(html, /