Files
openmaic/OpenMAIC/lib/config/apply-token-plan.ts
2026-08-16 14:58:47 +08:00

322 lines
11 KiB
TypeScript

import type { ProviderId } from '@/lib/types/provider';
import type { ImageProviderId, VideoProviderId } from '@/lib/media/types';
import type { TTSProviderId } from '@/lib/audio/types';
import type { WebSearchProviderId } from '@/lib/web-search/types';
import type {
TokenPlanModality,
TokenPlanModalityTarget,
TokenPlanPreset,
} from './token-plan-presets';
import { MODALITY_ORDER } from './token-plan-presets';
import { getCatalogThinkingCapability } from '@/lib/ai/model-metadata';
import { PROVIDERS } from '@/lib/ai/providers';
import { findModelById } from '@/lib/ai/model-aliases';
import type { ModelInfo } from '@/lib/types/provider';
/**
* The subset of settings-store setters needed to fill a token plan across
* modalities. Injected so the orchestration is a pure, testable function.
* Signatures mirror the store actions so they can be passed verbatim.
*/
export interface TokenPlanActions {
setProviderConfig: (id: ProviderId, config: Record<string, unknown>) => void;
setImageProviderConfig: (
id: ImageProviderId,
config: Partial<{
apiKey: string;
baseUrl: string;
enabled: boolean;
customModels: Array<{ id: string; name: string }>;
replaceBuiltInModels: boolean;
}>,
) => void;
setVideoProviderConfig: (
id: VideoProviderId,
config: Partial<{
apiKey: string;
baseUrl: string;
enabled: boolean;
customModels: Array<{ id: string; name: string }>;
replaceBuiltInModels: boolean;
}>,
) => void;
setTTSProviderConfig: (
id: TTSProviderId,
config: Partial<{ apiKey: string; baseUrl: string; enabled: boolean; modelId: string }>,
) => void;
setWebSearchProviderConfig: (
id: WebSearchProviderId,
config: Partial<{ apiKey: string; baseUrl: string; enabled: boolean }>,
) => void;
/**
* Optional model-selection setters. When provided, applying an image/video
* modality that declares `defaultModels` also makes that provider+model the
* active selection, so the plan's model is used out of the box. Optional so
* `applyTokenPlan` stays usable in tests/headless callers without them.
*/
setImageProvider?: (id: ImageProviderId) => void;
setImageModelId?: (modelId: string) => void;
setVideoProvider?: (id: VideoProviderId) => void;
setVideoModelId?: (modelId: string) => void;
}
export interface ApplyResult {
modality: TokenPlanModality;
// 'pending' is a UI-only state the settings page sets while a live probe is in
// flight; applyTokenPlan itself only ever returns 'lit' or 'failed'.
status: 'pending' | 'lit' | 'failed';
providerId: string;
detail?: string;
}
/**
* Fills the API key into every modality the preset declares and enables it.
* Each modality is isolated: a thrown setter doesn't abort the others.
*
* Note: this fills config synchronously. For LLM, the caller may additionally
* trigger model probing (via /api/provider/probe-models) to populate the model
* list — that's async and lives in the UI, not here.
*/
export function applyTokenPlan(
preset: TokenPlanPreset,
apiKey: string,
actions: TokenPlanActions,
): ApplyResult[] {
const results: ApplyResult[] = [];
for (const modality of MODALITY_ORDER) {
const target = preset.modalities[modality];
if (!target) continue;
try {
applyModality(modality, target, preset, apiKey, actions);
results.push({ modality, status: 'lit', providerId: target.providerId });
} catch (err) {
results.push({
modality,
status: 'failed',
providerId: target.providerId,
detail: err instanceof Error ? err.message : String(err),
});
}
}
return results;
}
function catalogModelFor(target: TokenPlanModalityTarget, id: string): ModelInfo | undefined {
const direct = findModelById(
target.providerId,
PROVIDERS[target.providerId as ProviderId]?.models,
id,
);
if (direct) return direct;
const allModels = Object.values(PROVIDERS).flatMap((provider) => provider.models);
return (
allModels.find((m) => m.id === id) ??
allModels.find((m) => m.id.toLowerCase() === id.toLowerCase())
);
}
function tokenPlanModelInfo(target: TokenPlanModalityTarget, id: string): ModelInfo {
const catalog = catalogModelFor(target, id);
const thinking = getCatalogThinkingCapability(target.providerId, id);
if (catalog) {
return {
...catalog,
id,
name: catalog.name || id,
capabilities: {
...catalog.capabilities,
...(thinking ? { thinking } : {}),
},
};
}
return {
id,
name: id,
capabilities: {
streaming: true,
tools: true,
vision: false,
...(thinking ? { thinking } : {}),
},
};
}
function applyModality(
modality: TokenPlanModality,
target: TokenPlanModalityTarget,
preset: TokenPlanPreset,
apiKey: string,
actions: TokenPlanActions,
): void {
switch (modality) {
case 'llm':
actions.setProviderConfig(target.providerId as ProviderId, {
apiKey,
baseUrl: target.baseUrl,
type: target.apiFormat ?? 'openai',
name: preset.name,
icon: preset.icon,
requiresApiKey: true,
isBuiltIn: false,
// Seed the model list from the preset's curated `defaultModels`.
// Token-plan apply never probes individual models; unsupported tier
// picks surface at generation time instead of being silently pruned.
// Overlay the built-in thinking capability so seeded models keep their
// thinking control instead of silently losing it (the probe step does
// the same when it replaces this list).
models: (target.defaultModels ?? []).map((id) => tokenPlanModelInfo(target, id)),
...(target.modelsUrl ? { modelsUrl: target.modelsUrl } : {}),
});
break;
case 'image': {
// Inject the plan's model ids as custom models (the registry defaults may
// not be valid on this plan's endpoint), and make the first the active
// selection so image generation works out of the box.
const customModels = (target.defaultModels ?? []).map((id) => ({ id, name: id }));
actions.setImageProviderConfig(target.providerId as ImageProviderId, {
apiKey,
baseUrl: target.baseUrl,
enabled: true,
...(customModels.length ? { customModels, replaceBuiltInModels: true } : {}),
});
if (customModels.length) {
actions.setImageProvider?.(target.providerId as ImageProviderId);
actions.setImageModelId?.(customModels[0].id);
}
break;
}
case 'video': {
const customModels = (target.defaultModels ?? []).map((id) => ({ id, name: id }));
actions.setVideoProviderConfig(target.providerId as VideoProviderId, {
apiKey,
baseUrl: target.baseUrl,
enabled: true,
...(customModels.length ? { customModels, replaceBuiltInModels: true } : {}),
});
if (customModels.length) {
actions.setVideoProvider?.(target.providerId as VideoProviderId);
actions.setVideoModelId?.(customModels[0].id);
}
break;
}
case 'tts':
actions.setTTSProviderConfig(target.providerId as TTSProviderId, {
apiKey,
baseUrl: target.baseUrl,
enabled: true,
...(target.defaultModelId ? { modelId: target.defaultModelId } : {}),
});
break;
case 'webSearch':
actions.setWebSearchProviderConfig(target.providerId as WebSearchProviderId, {
apiKey,
baseUrl: target.baseUrl,
enabled: true,
});
break;
}
}
/**
* Removes a token plan: clears the API key and disables every modality it
* declared. For LLM, applying overwrote the shared built-in provider in place
* (baseUrl, models, name, icon, isBuiltIn: false, …), so removal must restore
* those built-in defaults — not just clear the key — or the provider stays
* pointed at the plan endpoint with plan-specific model ids. For image/video,
* the store switches the active selection away when the provider is disabled
* (see setImageProviderConfig/setVideoProviderConfig). Each modality is isolated
* — a thrown setter doesn't abort the rest.
*/
export function removeTokenPlan(preset: TokenPlanPreset, actions: TokenPlanActions): ApplyResult[] {
const results: ApplyResult[] = [];
for (const modality of MODALITY_ORDER) {
const target = preset.modalities[modality];
if (!target) continue;
try {
removeModality(modality, target, actions);
results.push({ modality, status: 'lit', providerId: target.providerId });
} catch (err) {
results.push({
modality,
status: 'failed',
providerId: target.providerId,
detail: err instanceof Error ? err.message : String(err),
});
}
}
return results;
}
function removeModality(
modality: TokenPlanModality,
target: TokenPlanModalityTarget,
actions: TokenPlanActions,
): void {
switch (modality) {
case 'llm': {
// Applying overwrote the built-in provider in place. Restore its built-in
// defaults so it doesn't linger on the plan endpoint with plan model ids
// and isBuiltIn:false. The store's LLM resolver then switches the active
// selection away since the restored config has no key. Custom (non-built-
// in) providers aren't in the registry — just clear the key for those.
const builtIn = PROVIDERS[target.providerId as ProviderId];
if (builtIn) {
actions.setProviderConfig(target.providerId as ProviderId, {
apiKey: '',
baseUrl: '',
models: builtIn.models,
name: builtIn.name,
type: builtIn.type,
defaultBaseUrl: builtIn.defaultBaseUrl,
icon: builtIn.icon,
requiresApiKey: builtIn.requiresApiKey,
isBuiltIn: true,
modelsUrl: undefined,
});
} else {
actions.setProviderConfig(target.providerId as ProviderId, { apiKey: '' });
}
break;
}
case 'image':
actions.setImageProviderConfig(target.providerId as ImageProviderId, {
apiKey: '',
baseUrl: '',
enabled: false,
customModels: [],
replaceBuiltInModels: false,
});
break;
case 'video':
actions.setVideoProviderConfig(target.providerId as VideoProviderId, {
apiKey: '',
baseUrl: '',
enabled: false,
customModels: [],
replaceBuiltInModels: false,
});
break;
case 'tts':
actions.setTTSProviderConfig(target.providerId as TTSProviderId, {
apiKey: '',
baseUrl: '',
enabled: false,
});
break;
case 'webSearch':
actions.setWebSearchProviderConfig(target.providerId as WebSearchProviderId, {
apiKey: '',
baseUrl: '',
enabled: false,
});
break;
}
}