Files
wehub-resource-sync c48612c494
CI / E2E Tests (push) Has been cancelled
CI / Lint, Typecheck & Unit Tests (push) Has been cancelled
Docs Build / Build docs site (push) Has been cancelled
Publish @openmaic packages / Build, validate & publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:23 +08:00

174 lines
6.8 KiB
TypeScript

/**
* Shared model resolution utilities for API routes.
*
* Extracts the repeated parseModelString → resolveApiKey → resolveBaseUrl →
* resolveProxy → getModel boilerplate into a single call.
*/
import type { NextRequest } from 'next/server';
import { getModel, parseModelString, type ModelWithInfo } from '@/lib/ai/providers';
import type { ThinkingConfig } from '@/lib/types/provider';
import {
isServerConfiguredProvider,
resolveApiKey,
resolveBaseUrl,
resolveProxy,
} from '@/lib/server/provider-config';
import { validateUrlForSSRF } from '@/lib/server/ssrf-guard';
import { getStageRoute, type LlmStage } from '@/lib/server/model-routes';
export interface ResolvedModel extends ModelWithInfo {
/** Original model string (e.g. "openai/gpt-4o-mini") */
modelString: string;
/** Resolved provider ID (e.g. "openai", "ollama") */
providerId: string;
/** Resolved model ID (e.g. "gpt-4o-mini") */
modelId: string;
/** Effective API key after server-side fallback resolution */
apiKey: string;
/** Effective base URL after server/client resolution */
baseUrl?: string;
/** Optional per-request thinking configuration from the client. */
thinkingConfig?: ThinkingConfig;
}
/**
* Resolve a language model from explicit parameters.
*
* Use this when model config comes from the request body.
*/
export async function resolveModel(params: {
modelString?: string;
/**
* Optional generation stage (a `callLLM` source label, e.g. 'scene-content').
* When set and a route is configured via `MODEL_ROUTES`, the route wins for
* this call — even over a client-sent `modelString` (x-model). Unrouted
* stages fall back to `modelString` then `DEFAULT_MODEL`. See
* lib/server/model-routes.ts.
*/
stage?: LlmStage;
apiKey?: string;
baseUrl?: string;
providerType?: string;
thinkingConfig?: ThinkingConfig;
}): Promise<ResolvedModel> {
// Resolution order: stage route > x-model > DEFAULT_MODEL.
// A configured stage route is the operator's deliberate per-stage choice and
// wins even over a client-sent x-model (otherwise the browser UI, which always
// sends its saved model, would shadow every route). Unrouted stages fall back
// to the client x-model, then DEFAULT_MODEL. There is intentionally no hardcoded
// model fallback — if nothing resolves we fail loud rather than silently pick a
// vendor default.
const stageRoute = getStageRoute(params.stage);
const stageModel = stageRoute?.model;
const modelString = stageModel || params.modelString || process.env.DEFAULT_MODEL;
if (!modelString) {
throw new Error(
'No model could be resolved. Configure DEFAULT_MODEL (and/or a MODEL_ROUTES entry for this stage), or send a model via x-model.',
);
}
const { providerId, modelId } = parseModelString(modelString);
// When a stage route overrides the client's model, the client-sent connection
// params (apiKey/baseUrl/providerType) belong to the client's *other* model
// and must not bleed onto the routed provider — otherwise e.g. a routed
// Anthropic model would be built with the client's OpenAI providerType/key.
// A routed model resolves purely from server config, as if no x-model was sent.
const routed = Boolean(stageModel);
const clientApiKey = routed ? undefined : params.apiKey;
const clientProviderType = routed ? undefined : params.providerType;
const clientBaseUrlParam = routed ? undefined : params.baseUrl;
// Server-managed providers are admin-owned: the operator's key and base URL
// are authoritative and any client-sent override is ignored. SSRF validation
// therefore applies only to unmanaged providers, where the base URL really is
// client-supplied. (Server-configured URLs are trusted by the operator.)
const managed = isServerConfiguredProvider('providers', providerId);
const clientBaseUrl = managed ? undefined : clientBaseUrlParam || undefined;
if (clientBaseUrl && process.env.NODE_ENV === 'production') {
const ssrfError = await validateUrlForSSRF(clientBaseUrl);
if (ssrfError) {
throw new Error(ssrfError);
}
}
const apiKey = resolveApiKey(providerId, clientApiKey || '');
const baseUrl = resolveBaseUrl(providerId, clientBaseUrl);
const proxy = resolveProxy(providerId);
const { model, modelInfo } = getModel({
providerId,
modelId,
apiKey,
baseUrl,
proxy,
providerType: clientProviderType as 'openai' | 'anthropic' | 'google' | undefined,
});
// Thinking arbitration mirrors model routing — the route carries a full
// ThinkingConfig (mode/effort/level/enabled/budgetTokens/…) which callLLM
// normalizes against the model's capability:
// - routed + thinking set → the route's thinking wins (over client thinking).
// - routed + no thinking → routed model uses its own default; client thinking
// is dropped (it belonged to the client's other model).
// - unrouted → honor the client's thinking config.
const thinkingConfig: ThinkingConfig | undefined = routed
? stageRoute?.thinking
: params.thinkingConfig;
return {
model,
modelInfo,
modelString,
providerId,
modelId,
apiKey,
baseUrl,
thinkingConfig,
};
}
function getThinkingConfigFromBody(body: unknown): ThinkingConfig | undefined {
if (!body || typeof body !== 'object') return undefined;
const record = body as { thinkingConfig?: unknown; thinking?: unknown };
const config = record.thinkingConfig ?? record.thinking;
return config && typeof config === 'object' ? (config as ThinkingConfig) : undefined;
}
/**
* Resolve a language model from standard request headers.
*
* Reads: x-model, x-api-key, x-base-url, x-provider-type
* Note: requiresApiKey is derived server-side from the provider registry,
* never from client headers, to prevent auth bypass.
*/
export async function resolveModelFromHeaders(
req: NextRequest,
stage?: LlmStage,
thinkingConfig?: ThinkingConfig,
): Promise<ResolvedModel> {
return resolveModel({
modelString: req.headers.get('x-model') || undefined,
stage,
apiKey: req.headers.get('x-api-key') || undefined,
baseUrl: req.headers.get('x-base-url') || undefined,
providerType: req.headers.get('x-provider-type') || undefined,
thinkingConfig,
});
}
/**
* Resolve a language model from standard request headers plus body fields.
*
* Reads model credentials from headers and per-request thinking config from
* the JSON body field `thinkingConfig` (or legacy/eval field `thinking`).
*/
export async function resolveModelFromRequest(
req: NextRequest,
body: unknown,
stage?: LlmStage,
): Promise<ResolvedModel> {
// Pass the client's body thinking into resolveModel so the single arbiter
// there decides (a routed stage may override or drop it). See resolveModel.
return resolveModelFromHeaders(req, stage, getThinkingConfigFromBody(body));
}