|
@@ -0,0 +1,512 @@
|
|
|
|
|
+import { access, readFile } from "node:fs/promises";
|
|
|
|
|
+import { join } from "node:path";
|
|
|
|
|
+import { ProjectConfigSchema, type LLMConfig, type ProjectConfig } from "../models/project.js";
|
|
|
|
|
+import { loadSecrets } from "../llm/secrets.js";
|
|
|
|
|
+import { getEndpoint } from "../llm/providers/index.js";
|
|
|
|
|
+import { guessServiceFromBaseUrl, resolveServicePreset, resolveServiceProviderFamily } from "../llm/service-presets.js";
|
|
|
|
|
+import { isApiKeyOptionalForEndpoint } from "./llm-endpoint-auth.js";
|
|
|
|
|
+import { cliOverlayEnv, legacyEnv, studioIgnoredEnv, type LLMEnvLayers, type LLMEnvMap } from "./llm-env.js";
|
|
|
|
|
+
|
|
|
|
|
+export type LLMConsumer = "studio" | "cli" | "daemon" | "deploy";
|
|
|
|
|
+export type LLMConfigMode = "studio-project" | "cli-project" | "legacy-env";
|
|
|
|
|
+export type LLMValueSource = "project" | "studio-secret" | "env" | "cli" | "default";
|
|
|
|
|
+
|
|
|
|
|
+export interface LLMConfigCliOverrides {
|
|
|
|
|
+ readonly service?: string;
|
|
|
|
|
+ readonly model?: string;
|
|
|
|
|
+ readonly apiKeyEnv?: string;
|
|
|
|
|
+ readonly baseUrl?: string;
|
|
|
|
|
+ readonly apiFormat?: "chat" | "responses";
|
|
|
|
|
+ readonly stream?: boolean;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export interface ResolveEffectiveLLMConfigInput {
|
|
|
|
|
+ readonly consumer: LLMConsumer;
|
|
|
|
|
+ readonly projectRoot: string;
|
|
|
|
|
+ readonly envLayers: LLMEnvLayers;
|
|
|
|
|
+ readonly cli?: LLMConfigCliOverrides;
|
|
|
|
|
+ readonly requireApiKey?: boolean;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export interface EffectiveLLMDiagnostics {
|
|
|
|
|
+ readonly configMode: LLMConfigMode;
|
|
|
|
|
+ readonly serviceSource: LLMValueSource;
|
|
|
|
|
+ readonly modelSource: LLMValueSource;
|
|
|
|
|
+ readonly apiKeySource: LLMValueSource;
|
|
|
|
|
+ readonly warnings: readonly string[];
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export interface EffectiveLLMConfigResult {
|
|
|
|
|
+ readonly config: ProjectConfig;
|
|
|
|
|
+ readonly llm: LLMConfig;
|
|
|
|
|
+ readonly diagnostics: EffectiveLLMDiagnostics;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface ServiceConfigEntry {
|
|
|
|
|
+ readonly service: string;
|
|
|
|
|
+ readonly name?: string;
|
|
|
|
|
+ readonly baseUrl?: string;
|
|
|
|
|
+ readonly temperature?: number;
|
|
|
|
|
+ readonly maxTokens?: number;
|
|
|
|
|
+ readonly apiFormat?: "chat" | "responses";
|
|
|
|
|
+ readonly stream?: boolean;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface MutableDiagnostics {
|
|
|
|
|
+ configMode: LLMConfigMode;
|
|
|
|
|
+ serviceSource: LLMValueSource;
|
|
|
|
|
+ modelSource: LLMValueSource;
|
|
|
|
|
+ apiKeySource: LLMValueSource;
|
|
|
|
|
+ warnings: string[];
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export async function resolveEffectiveLLMConfig(
|
|
|
|
|
+ input: ResolveEffectiveLLMConfigInput,
|
|
|
|
|
+): Promise<EffectiveLLMConfigResult> {
|
|
|
|
|
+ const config = await readProjectConfig(input.projectRoot);
|
|
|
|
|
+ const llm = { ...((config.llm ?? {}) as Record<string, unknown>) };
|
|
|
|
|
+ const services = normalizeServiceEntries(llm.services);
|
|
|
|
|
+ const configMode = resolveConfigMode(input.consumer, llm.configSource, services);
|
|
|
|
|
+ const diagnostics: MutableDiagnostics = {
|
|
|
|
|
+ configMode,
|
|
|
|
|
+ serviceSource: "project",
|
|
|
|
|
+ modelSource: "project",
|
|
|
|
|
+ apiKeySource: "project",
|
|
|
|
|
+ warnings: [],
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ if (services.length > 0) {
|
|
|
|
|
+ llm.services = services;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (configMode === "studio-project") {
|
|
|
|
|
+ warnIfStudioIgnoresEnv(input.envLayers, diagnostics);
|
|
|
|
|
+ warnIfStaleTopLevel(llm, services, diagnostics);
|
|
|
|
|
+ await applyProjectServiceConfig(config, llm, services, input.projectRoot, diagnostics, {
|
|
|
|
|
+ requireApiKey: input.requireApiKey,
|
|
|
|
|
+ ignoreTopLevelModel: services.length > 0,
|
|
|
|
|
+ });
|
|
|
|
|
+ } else if (configMode === "cli-project") {
|
|
|
|
|
+ await applyCliProjectConfig(config, llm, services, input, diagnostics);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ await applyLegacyEnvConfig(config, llm, input, diagnostics);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (input.requireApiKey === false) {
|
|
|
|
|
+ fillNoopLLMDefaults(llm);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const provider = typeof llm.provider === "string" ? llm.provider : undefined;
|
|
|
|
|
+ const baseUrl = typeof llm.baseUrl === "string" ? llm.baseUrl : undefined;
|
|
|
|
|
+ const apiKey = typeof llm.apiKey === "string" ? llm.apiKey : "";
|
|
|
|
|
+ if (!apiKey && input.requireApiKey !== false && !isApiKeyOptionalForEndpoint({ provider, baseUrl })) {
|
|
|
|
|
+ throw new Error(
|
|
|
|
|
+ configMode === "studio-project"
|
|
|
|
|
+ ? "Studio LLM API key not set. Open Studio services and save an API key for the selected service."
|
|
|
|
|
+ : "INKOS_LLM_API_KEY not set. Run 'inkos config set-global' or add it to project .env file.",
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ llm.apiKey = apiKey;
|
|
|
|
|
+ config.llm = llm;
|
|
|
|
|
+
|
|
|
|
|
+ const parsed = ProjectConfigSchema.parse(config);
|
|
|
|
|
+ return {
|
|
|
|
|
+ config: parsed,
|
|
|
|
|
+ llm: parsed.llm,
|
|
|
|
|
+ diagnostics,
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function readProjectConfig(root: string): Promise<Record<string, unknown>> {
|
|
|
|
|
+ const configPath = join(root, "inkos.json");
|
|
|
|
|
+ try {
|
|
|
|
|
+ await access(configPath);
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ throw new Error(
|
|
|
|
|
+ `inkos.json not found in ${root}.\nMake sure you are inside an InkOS project directory (cd into the project created by 'inkos init').`,
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const raw = await readFile(configPath, "utf-8");
|
|
|
|
|
+ try {
|
|
|
|
|
+ return JSON.parse(raw) as Record<string, unknown>;
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ throw new Error(`inkos.json in ${root} is not valid JSON. Check the file for syntax errors.`);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function resolveConfigMode(
|
|
|
|
|
+ consumer: LLMConsumer,
|
|
|
|
|
+ source: unknown,
|
|
|
|
|
+ services: readonly ServiceConfigEntry[],
|
|
|
|
|
+): LLMConfigMode {
|
|
|
|
|
+ if (consumer === "studio") return "studio-project";
|
|
|
|
|
+ if (source === "env") return "legacy-env";
|
|
|
|
|
+ if (source === "studio" || services.length > 0) return "cli-project";
|
|
|
|
|
+ return "legacy-env";
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function applyProjectServiceConfig(
|
|
|
|
|
+ config: Record<string, unknown>,
|
|
|
|
|
+ llm: Record<string, unknown>,
|
|
|
|
|
+ services: readonly ServiceConfigEntry[],
|
|
|
|
|
+ projectRoot: string,
|
|
|
|
|
+ diagnostics: MutableDiagnostics,
|
|
|
|
|
+ options: {
|
|
|
|
|
+ readonly requireApiKey?: boolean;
|
|
|
|
|
+ readonly ignoreTopLevelModel: boolean;
|
|
|
|
|
+ readonly requestedService?: string;
|
|
|
|
|
+ readonly requestedModel?: string;
|
|
|
|
|
+ readonly requestedModelSource?: LLMValueSource;
|
|
|
|
|
+ readonly envApiKey?: string;
|
|
|
|
|
+ readonly envBaseUrl?: string;
|
|
|
|
|
+ readonly envProvider?: string;
|
|
|
|
|
+ readonly cli?: LLMConfigCliOverrides;
|
|
|
|
|
+ readonly env?: LLMEnvMap;
|
|
|
|
|
+ },
|
|
|
|
|
+): Promise<void> {
|
|
|
|
|
+ llm.configSource = "studio";
|
|
|
|
|
+ const selectedEntry = selectServiceEntry(services, options.requestedService ?? llm.service)
|
|
|
|
|
+ ?? synthesizeServiceEntry(options.requestedService ?? llm.service);
|
|
|
|
|
+
|
|
|
|
|
+ if (selectedEntry) {
|
|
|
|
|
+ applyServiceEntry(llm, selectedEntry);
|
|
|
|
|
+ diagnostics.serviceSource = options.requestedService ? diagnostics.serviceSource : "project";
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const modelSource = options.requestedModel ? options.requestedModelSource ?? "env" : "project";
|
|
|
|
|
+ const model = options.requestedModel
|
|
|
|
|
+ ?? resolveServiceModel(
|
|
|
|
|
+ selectedEntry,
|
|
|
|
|
+ options.ignoreTopLevelModel ? undefined : stringValue(llm.model),
|
|
|
|
|
+ stringValue(llm.defaultModel),
|
|
|
|
|
+ );
|
|
|
|
|
+ if (model) {
|
|
|
|
|
+ assertModelBelongsToService(selectedEntry, model);
|
|
|
|
|
+ llm.model = model;
|
|
|
|
|
+ diagnostics.modelSource = modelSource;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (options.envProvider) llm.provider = options.envProvider;
|
|
|
|
|
+ if (options.envBaseUrl) llm.baseUrl = options.envBaseUrl;
|
|
|
|
|
+ if (options.env) {
|
|
|
|
|
+ applyCommonEnv(config, llm, options.env);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (options.cli?.baseUrl) llm.baseUrl = options.cli.baseUrl;
|
|
|
|
|
+ if (options.cli?.apiFormat) llm.apiFormat = options.cli.apiFormat;
|
|
|
|
|
+ if (options.cli?.stream !== undefined) llm.stream = options.cli.stream;
|
|
|
|
|
+
|
|
|
|
|
+ const serviceKey = selectedEntry ? serviceEntryKey(selectedEntry) : stringValue(llm.service);
|
|
|
|
|
+ const secretApiKey = serviceKey ? await getStudioServiceApiKey(projectRoot, serviceKey) : "";
|
|
|
|
|
+ const cliApiKey = options.cli?.apiKeyEnv ? options.env?.[options.cli.apiKeyEnv] ?? "" : "";
|
|
|
|
|
+ const apiKey = cliApiKey || options.envApiKey || secretApiKey || "";
|
|
|
|
|
+ llm.apiKey = apiKey;
|
|
|
|
|
+ diagnostics.apiKeySource = cliApiKey
|
|
|
|
|
+ ? "cli"
|
|
|
|
|
+ : options.envApiKey
|
|
|
|
|
+ ? "env"
|
|
|
|
|
+ : secretApiKey
|
|
|
|
|
+ ? "studio-secret"
|
|
|
|
|
+ : "project";
|
|
|
|
|
+
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function applyCliProjectConfig(
|
|
|
|
|
+ config: Record<string, unknown>,
|
|
|
|
|
+ llm: Record<string, unknown>,
|
|
|
|
|
+ services: readonly ServiceConfigEntry[],
|
|
|
|
|
+ input: ResolveEffectiveLLMConfigInput,
|
|
|
|
|
+ diagnostics: MutableDiagnostics,
|
|
|
|
|
+): Promise<void> {
|
|
|
|
|
+ const env = cliOverlayEnv(input.envLayers);
|
|
|
|
|
+ const envBaseUrl = stringValue(env.INKOS_LLM_BASE_URL);
|
|
|
|
|
+ const envService = stringValue(env.INKOS_LLM_SERVICE) ?? (envBaseUrl ? guessServiceFromBaseUrl(envBaseUrl) : undefined);
|
|
|
|
|
+ const envModel = stringValue(env.INKOS_LLM_MODEL);
|
|
|
|
|
+ const requestedService = input.cli?.service ?? envService;
|
|
|
|
|
+ if (input.cli?.service) diagnostics.serviceSource = "cli";
|
|
|
|
|
+ else if (envService) diagnostics.serviceSource = "env";
|
|
|
|
|
+
|
|
|
|
|
+ const requestedModel = input.cli?.model ?? (input.cli?.service ? undefined : envModel);
|
|
|
|
|
+ const requestedModelSource: LLMValueSource = input.cli?.model ? "cli" : !input.cli?.service && envModel ? "env" : "project";
|
|
|
|
|
+ const allowEnvEndpointOverlay = !input.cli?.service;
|
|
|
|
|
+
|
|
|
|
|
+ await applyProjectServiceConfig(config, llm, services, input.projectRoot, diagnostics, {
|
|
|
|
|
+ requireApiKey: input.requireApiKey,
|
|
|
|
|
+ ignoreTopLevelModel: true,
|
|
|
|
|
+ requestedService,
|
|
|
|
|
+ requestedModel,
|
|
|
|
|
+ requestedModelSource,
|
|
|
|
|
+ envApiKey: allowEnvEndpointOverlay ? stringValue(env.INKOS_LLM_API_KEY) : undefined,
|
|
|
|
|
+ envBaseUrl: allowEnvEndpointOverlay ? envBaseUrl : undefined,
|
|
|
|
|
+ envProvider: allowEnvEndpointOverlay ? stringValue(env.INKOS_LLM_PROVIDER) : undefined,
|
|
|
|
|
+ cli: input.cli,
|
|
|
|
|
+ env,
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function applyLegacyEnvConfig(
|
|
|
|
|
+ config: Record<string, unknown>,
|
|
|
|
|
+ llm: Record<string, unknown>,
|
|
|
|
|
+ input: ResolveEffectiveLLMConfigInput,
|
|
|
|
|
+ diagnostics: MutableDiagnostics,
|
|
|
|
|
+): Promise<void> {
|
|
|
|
|
+ const env = legacyEnv(input.envLayers);
|
|
|
|
|
+ llm.configSource = "env";
|
|
|
|
|
+
|
|
|
|
|
+ if (env.INKOS_LLM_SERVICE) {
|
|
|
|
|
+ llm.service = env.INKOS_LLM_SERVICE;
|
|
|
|
|
+ diagnostics.serviceSource = "env";
|
|
|
|
|
+ } else if (typeof llm.service !== "string" || llm.service.length === 0) {
|
|
|
|
|
+ llm.service = "custom";
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (env.INKOS_LLM_PROVIDER) llm.provider = env.INKOS_LLM_PROVIDER;
|
|
|
|
|
+ else if (typeof llm.provider !== "string" || llm.provider.length === 0) llm.provider = "custom";
|
|
|
|
|
+ if (env.INKOS_LLM_BASE_URL) llm.baseUrl = env.INKOS_LLM_BASE_URL;
|
|
|
|
|
+ if (env.INKOS_LLM_MODEL) {
|
|
|
|
|
+ llm.model = env.INKOS_LLM_MODEL;
|
|
|
|
|
+ diagnostics.modelSource = "env";
|
|
|
|
|
+ }
|
|
|
|
|
+ if (env.INKOS_LLM_API_KEY) {
|
|
|
|
|
+ llm.apiKey = env.INKOS_LLM_API_KEY;
|
|
|
|
|
+ diagnostics.apiKeySource = "env";
|
|
|
|
|
+ } else if (typeof llm.apiKey !== "string") {
|
|
|
|
|
+ llm.apiKey = "";
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (input.cli?.service) {
|
|
|
|
|
+ llm.service = input.cli.service;
|
|
|
|
|
+ diagnostics.serviceSource = "cli";
|
|
|
|
|
+ }
|
|
|
|
|
+ if (input.cli?.model) {
|
|
|
|
|
+ llm.model = input.cli.model;
|
|
|
|
|
+ diagnostics.modelSource = "cli";
|
|
|
|
|
+ }
|
|
|
|
|
+ if (input.cli?.baseUrl) llm.baseUrl = input.cli.baseUrl;
|
|
|
|
|
+ if (input.cli?.apiFormat) llm.apiFormat = input.cli.apiFormat;
|
|
|
|
|
+ if (input.cli?.stream !== undefined) llm.stream = input.cli.stream;
|
|
|
|
|
+ if (input.cli?.apiKeyEnv) {
|
|
|
|
|
+ llm.apiKey = env[input.cli.apiKeyEnv] ?? "";
|
|
|
|
|
+ diagnostics.apiKeySource = "cli";
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ applyCommonEnv(config, llm, env);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function applyServiceEntry(llm: Record<string, unknown>, entry: ServiceConfigEntry): void {
|
|
|
|
|
+ const endpoint = getEndpoint(entry.service);
|
|
|
|
|
+ const transportDefaults = endpoint?.transportDefaults;
|
|
|
|
|
+ llm.service = entry.service;
|
|
|
|
|
+ llm.provider = deriveProviderFromService(entry.service);
|
|
|
|
|
+ llm.baseUrl = entry.baseUrl ?? resolveServicePreset(entry.service)?.baseUrl ?? "";
|
|
|
|
|
+
|
|
|
|
|
+ if (entry.temperature !== undefined) llm.temperature = entry.temperature;
|
|
|
|
|
+ if (entry.apiFormat !== undefined) llm.apiFormat = entry.apiFormat;
|
|
|
|
|
+ else if (transportDefaults?.apiFormat !== undefined) llm.apiFormat = transportDefaults.apiFormat;
|
|
|
|
|
+ else llm.apiFormat = resolveServicePreset(entry.service)?.api.startsWith("openai-responses") ? "responses" : "chat";
|
|
|
|
|
+ if (entry.stream !== undefined) llm.stream = entry.stream;
|
|
|
|
|
+ else if (transportDefaults?.stream !== undefined) llm.stream = transportDefaults.stream;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function applyCommonEnv(
|
|
|
|
|
+ config: Record<string, unknown>,
|
|
|
|
|
+ llm: Record<string, unknown>,
|
|
|
|
|
+ env: LLMEnvMap,
|
|
|
|
|
+): void {
|
|
|
|
|
+ if (env.INKOS_LLM_TEMPERATURE) llm.temperature = Number.parseFloat(env.INKOS_LLM_TEMPERATURE);
|
|
|
|
|
+ if (env.INKOS_LLM_THINKING_BUDGET) llm.thinkingBudget = Number.parseInt(env.INKOS_LLM_THINKING_BUDGET, 10);
|
|
|
|
|
+ if (env.INKOS_LLM_API_FORMAT) llm.apiFormat = env.INKOS_LLM_API_FORMAT;
|
|
|
|
|
+ if (env.INKOS_LLM_STREAM) llm.stream = parseBoolean(env.INKOS_LLM_STREAM);
|
|
|
|
|
+ if (env.INKOS_DEFAULT_LANGUAGE) config.language = env.INKOS_DEFAULT_LANGUAGE;
|
|
|
|
|
+
|
|
|
|
|
+ const extraFromEnv: Record<string, unknown> = {};
|
|
|
|
|
+ for (const [key, value] of Object.entries(env)) {
|
|
|
|
|
+ if (key.startsWith("INKOS_LLM_EXTRA_") && value) {
|
|
|
|
|
+ extraFromEnv[key.slice("INKOS_LLM_EXTRA_".length)] = parseEnvValue(value);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (Object.keys(extraFromEnv).length > 0) {
|
|
|
|
|
+ llm.extra = { ...(objectValue(llm.extra)), ...extraFromEnv };
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function getStudioServiceApiKey(projectRoot: string, serviceKey: string): Promise<string> {
|
|
|
|
|
+ const secrets = await loadSecrets(projectRoot);
|
|
|
|
|
+ return secrets.services[serviceKey]?.apiKey ?? "";
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function normalizeServiceEntries(raw: unknown): ServiceConfigEntry[] {
|
|
|
|
|
+ if (Array.isArray(raw)) {
|
|
|
|
|
+ return raw
|
|
|
|
|
+ .filter((entry): entry is Record<string, unknown> => Boolean(entry) && typeof entry === "object")
|
|
|
|
|
+ .map((entry) => ({
|
|
|
|
|
+ service: typeof entry.service === "string" && entry.service.length > 0 ? entry.service : "custom",
|
|
|
|
|
+ ...(typeof entry.name === "string" && entry.name.length > 0 ? { name: entry.name } : {}),
|
|
|
|
|
+ ...(typeof entry.baseUrl === "string" && entry.baseUrl.length > 0 ? { baseUrl: entry.baseUrl } : {}),
|
|
|
|
|
+ ...(typeof entry.temperature === "number" ? { temperature: entry.temperature } : {}),
|
|
|
|
|
+ ...(typeof entry.maxTokens === "number" ? { maxTokens: entry.maxTokens } : {}),
|
|
|
|
|
+ ...(entry.apiFormat === "chat" || entry.apiFormat === "responses" ? { apiFormat: entry.apiFormat } : {}),
|
|
|
|
|
+ ...(typeof entry.stream === "boolean" ? { stream: entry.stream } : {}),
|
|
|
|
|
+ }));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (raw && typeof raw === "object") {
|
|
|
|
|
+ return Object.entries(raw as Record<string, unknown>)
|
|
|
|
|
+ .filter(([, value]) => value && typeof value === "object")
|
|
|
|
|
+ .map(([serviceId, value]) => normalizeServiceEntryFromPatch(serviceId, value as Record<string, unknown>));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return [];
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function normalizeServiceEntryFromPatch(serviceId: string, value: Record<string, unknown>): ServiceConfigEntry {
|
|
|
|
|
+ if (serviceId.startsWith("custom:")) {
|
|
|
|
|
+ return {
|
|
|
|
|
+ service: "custom",
|
|
|
|
|
+ name: decodeURIComponent(serviceId.slice("custom:".length)),
|
|
|
|
|
+ ...(typeof value.baseUrl === "string" && value.baseUrl.length > 0 ? { baseUrl: value.baseUrl } : {}),
|
|
|
|
|
+ ...(typeof value.temperature === "number" ? { temperature: value.temperature } : {}),
|
|
|
|
|
+ ...(typeof value.maxTokens === "number" ? { maxTokens: value.maxTokens } : {}),
|
|
|
|
|
+ ...(value.apiFormat === "chat" || value.apiFormat === "responses" ? { apiFormat: value.apiFormat } : {}),
|
|
|
|
|
+ ...(typeof value.stream === "boolean" ? { stream: value.stream } : {}),
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (serviceId === "custom") {
|
|
|
|
|
+ return {
|
|
|
|
|
+ service: "custom",
|
|
|
|
|
+ ...(typeof value.name === "string" && value.name.length > 0 ? { name: value.name } : {}),
|
|
|
|
|
+ ...(typeof value.baseUrl === "string" && value.baseUrl.length > 0 ? { baseUrl: value.baseUrl } : {}),
|
|
|
|
|
+ ...(typeof value.temperature === "number" ? { temperature: value.temperature } : {}),
|
|
|
|
|
+ ...(typeof value.maxTokens === "number" ? { maxTokens: value.maxTokens } : {}),
|
|
|
|
|
+ ...(value.apiFormat === "chat" || value.apiFormat === "responses" ? { apiFormat: value.apiFormat } : {}),
|
|
|
|
|
+ ...(typeof value.stream === "boolean" ? { stream: value.stream } : {}),
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ service: serviceId,
|
|
|
|
|
+ ...(typeof value.temperature === "number" ? { temperature: value.temperature } : {}),
|
|
|
|
|
+ ...(typeof value.maxTokens === "number" ? { maxTokens: value.maxTokens } : {}),
|
|
|
|
|
+ ...(value.apiFormat === "chat" || value.apiFormat === "responses" ? { apiFormat: value.apiFormat } : {}),
|
|
|
|
|
+ ...(typeof value.stream === "boolean" ? { stream: value.stream } : {}),
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function selectServiceEntry(
|
|
|
|
|
+ services: readonly ServiceConfigEntry[],
|
|
|
|
|
+ configuredService: unknown,
|
|
|
|
|
+): ServiceConfigEntry | undefined {
|
|
|
|
|
+ if (typeof configuredService === "string" && configuredService.length > 0) {
|
|
|
|
|
+ return services.find((entry) => entry.service === configuredService || serviceEntryKey(entry) === configuredService)
|
|
|
|
|
+ ?? synthesizeServiceEntry(configuredService);
|
|
|
|
|
+ }
|
|
|
|
|
+ return services[0];
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function synthesizeServiceEntry(service: unknown): ServiceConfigEntry | undefined {
|
|
|
|
|
+ if (typeof service !== "string" || service.length === 0) return undefined;
|
|
|
|
|
+ if (service.startsWith("custom:")) {
|
|
|
|
|
+ return { service: "custom", name: service.slice("custom:".length) || "Custom" };
|
|
|
|
|
+ }
|
|
|
|
|
+ if (service === "custom" || getEndpoint(service) || resolveServicePreset(service)) {
|
|
|
|
|
+ return { service };
|
|
|
|
|
+ }
|
|
|
|
|
+ return undefined;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function resolveServiceModel(
|
|
|
|
|
+ entry: ServiceConfigEntry | undefined,
|
|
|
|
|
+ currentModel: string | undefined,
|
|
|
|
|
+ defaultModel: string | undefined,
|
|
|
|
|
+): string {
|
|
|
|
|
+ if (!entry) return defaultModel || currentModel || "noop-model";
|
|
|
|
|
+ if (entry.service === "custom") return defaultModel || currentModel || "noop-model";
|
|
|
|
|
+
|
|
|
|
|
+ const endpoint = getEndpoint(entry.service);
|
|
|
|
|
+ const candidate = [defaultModel, currentModel]
|
|
|
|
|
+ .find((model): model is string => Boolean(model && modelBelongsToService(entry.service, model)));
|
|
|
|
|
+ if (candidate) return candidate;
|
|
|
|
|
+
|
|
|
|
|
+ return endpoint?.checkModel
|
|
|
|
|
+ ?? endpoint?.models.find((model) => model.enabled !== false)?.id
|
|
|
|
|
+ ?? defaultModel
|
|
|
|
|
+ ?? currentModel
|
|
|
|
|
+ ?? "noop-model";
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function assertModelBelongsToService(entry: ServiceConfigEntry | undefined, model: string): void {
|
|
|
|
|
+ if (!entry || entry.service === "custom") return;
|
|
|
|
|
+ const endpoint = getEndpoint(entry.service);
|
|
|
|
|
+ if (!endpoint) return;
|
|
|
|
|
+ if (!modelBelongsToService(entry.service, model)) {
|
|
|
|
|
+ throw new Error(`模型 ${model} 不属于 ${entry.service} 服务,请切换服务或选择该服务下的模型。`);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function modelBelongsToService(service: string, model: string): boolean {
|
|
|
|
|
+ const endpoint = getEndpoint(service);
|
|
|
|
|
+ if (!endpoint) return true;
|
|
|
|
|
+ return endpoint.models.some((knownModel) => knownModel.id.toLowerCase() === model.toLowerCase());
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function serviceEntryKey(entry: ServiceConfigEntry): string {
|
|
|
|
|
+ return entry.service === "custom" ? `custom:${entry.name ?? "Custom"}` : entry.service;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function deriveProviderFromService(service: string): "anthropic" | "openai" | "custom" {
|
|
|
|
|
+ if (service === "custom") return "custom";
|
|
|
|
|
+ return resolveServiceProviderFamily(service) ?? "openai";
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function warnIfStudioIgnoresEnv(layers: LLMEnvLayers, diagnostics: MutableDiagnostics): void {
|
|
|
|
|
+ const ignored = studioIgnoredEnv(layers);
|
|
|
|
|
+ if (Object.keys(ignored).some((key) => key.startsWith("INKOS_LLM_"))) {
|
|
|
|
|
+ diagnostics.warnings.push("Studio 运行时不会使用 env 中的 INKOS_LLM_* 配置;请在服务配置页保存 Studio 配置。");
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function warnIfStaleTopLevel(
|
|
|
|
|
+ llm: Record<string, unknown>,
|
|
|
|
|
+ services: readonly ServiceConfigEntry[],
|
|
|
|
|
+ diagnostics: MutableDiagnostics,
|
|
|
|
|
+): void {
|
|
|
|
|
+ if (services.length === 0) return;
|
|
|
|
|
+ if (["provider", "baseUrl", "model", "apiKey"].some((key) => typeof llm[key] === "string" && (llm[key] as string).length > 0)) {
|
|
|
|
|
+ diagnostics.warnings.push("检测到旧顶层 LLM 配置;Studio 模式以选中的 service/defaultModel/secrets 为准。");
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function fillNoopLLMDefaults(llm: Record<string, unknown>): void {
|
|
|
|
|
+ if (typeof llm.provider !== "string" || llm.provider.length === 0) llm.provider = "openai";
|
|
|
|
|
+ if (typeof llm.baseUrl !== "string" || llm.baseUrl.length === 0) llm.baseUrl = "https://example.invalid/v1";
|
|
|
|
|
+ if (typeof llm.model !== "string" || llm.model.length === 0) llm.model = "noop-model";
|
|
|
|
|
+ if (typeof llm.apiKey !== "string") llm.apiKey = "";
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function parseEnvValue(value: string): unknown {
|
|
|
|
|
+ if (/^\d+(\.\d+)?$/.test(value)) return Number.parseFloat(value);
|
|
|
|
|
+ if (value === "true") return true;
|
|
|
|
|
+ if (value === "false") return false;
|
|
|
|
|
+ if (value.startsWith("{") || value.startsWith("[")) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ return JSON.parse(value) as unknown;
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ return value;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return value;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function parseBoolean(value: string): boolean {
|
|
|
|
|
+ return value === "true" || value === "1" || value === "yes";
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function stringValue(value: unknown): string | undefined {
|
|
|
|
|
+ return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function objectValue(value: unknown): Record<string, unknown> {
|
|
|
|
|
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
|
|
|
+}
|