diff --git a/server/src/llm/capabilities.ts b/server/src/llm/capabilities.ts index fc6e718f2..c7e292d54 100644 --- a/server/src/llm/capabilities.ts +++ b/server/src/llm/capabilities.ts @@ -160,6 +160,10 @@ export function getJsonCapability(provider: LLMProvider, model?: string, baseURL supportsJsonObject: false, supportsJsonSchema: false, }, + atlascloud: { + supportsJsonObject: false, + supportsJsonSchema: false, + }, }; const cap = isBuiltinLLMProvider(provider) ? jsonCapabilities[provider] : undefined; diff --git a/server/src/llm/providers.ts b/server/src/llm/providers.ts index 6e6ee85fb..6d628eb4d 100644 --- a/server/src/llm/providers.ts +++ b/server/src/llm/providers.ts @@ -11,8 +11,11 @@ export interface ProviderConfig { defaultModel: string; models: string[]; envKey: string; + envKeyAliases?: string[]; envBaseURLKey?: string; + envBaseURLKeyAliases?: string[]; envModelKey?: string; + envModelKeyAliases?: string[]; maxTokens?: number; requiresApiKey?: boolean; } @@ -140,6 +143,18 @@ export const PROVIDERS: Record = { envModelKey: "OLLAMA_MODEL", requiresApiKey: false, }, + atlascloud: { + name: "Atlas Cloud", + baseURL: "https://api.atlascloud.ai/v1", + defaultModel: "qwen/qwen3.5-flash", + models: ["qwen/qwen3.5-flash", "deepseek-ai/deepseek-v4-pro"], + envKey: "ATLASCLOUD_API_KEY", + envKeyAliases: ["ATLAS_CLOUD_API_KEY"], + envBaseURLKey: "ATLASCLOUD_BASE_URL", + envBaseURLKeyAliases: ["ATLAS_CLOUD_BASE_URL"], + envModelKey: "ATLASCLOUD_MODEL", + envModelKeyAliases: ["ATLAS_CLOUD_MODEL"], + }, }; export const SUPPORTED_PROVIDERS: BuiltinLLMProvider[] = [...LLM_PROVIDERS]; @@ -152,37 +167,42 @@ export function normalizeBaseURL(baseURL: string): string { return baseURL.endsWith("/") ? baseURL.slice(0, -1) : baseURL; } +function readProviderEnv(keys: Array, normalize?: (value: string) => string): string | undefined { + for (const key of keys) { + if (!key) { + continue; + } + const value = process.env[key]; + if (typeof value === "string" && value.trim()) { + const trimmed = value.trim(); + return normalize ? normalize(trimmed) : trimmed; + } + } + return undefined; +} + export function getProviderEnvApiKey(provider: LLMProvider): string | undefined { if (!isBuiltInProvider(provider)) { return undefined; } - const envKey = PROVIDERS[provider].envKey; - const value = process.env[envKey]; - return typeof value === "string" && value.trim() ? value.trim() : undefined; + const config = PROVIDERS[provider]; + return readProviderEnv([config.envKey, ...(config.envKeyAliases ?? [])]); } export function getProviderEnvBaseUrl(provider: LLMProvider): string | undefined { if (!isBuiltInProvider(provider)) { return undefined; } - const envKey = PROVIDERS[provider].envBaseURLKey; - if (!envKey) { - return undefined; - } - const value = process.env[envKey]; - return typeof value === "string" && value.trim() ? normalizeBaseURL(value.trim()) : undefined; + const config = PROVIDERS[provider]; + return readProviderEnv([config.envBaseURLKey, ...(config.envBaseURLKeyAliases ?? [])], normalizeBaseURL); } export function getProviderEnvModel(provider: LLMProvider): string | undefined { if (!isBuiltInProvider(provider)) { return undefined; } - const envKey = PROVIDERS[provider].envModelKey; - if (!envKey) { - return undefined; - } - const value = process.env[envKey]; - return typeof value === "string" && value.trim() ? value.trim() : undefined; + const config = PROVIDERS[provider]; + return readProviderEnv([config.envModelKey, ...(config.envModelKeyAliases ?? [])]); } export function getProviderDefaultBaseUrl(provider: LLMProvider): string | undefined { diff --git a/server/src/services/settings/RagSettingsService.ts b/server/src/services/settings/RagSettingsService.ts index 3672cfc1b..b2bceee01 100644 --- a/server/src/services/settings/RagSettingsService.ts +++ b/server/src/services/settings/RagSettingsService.ts @@ -1,3 +1,4 @@ +import type { BuiltinLLMProvider } from "@ai-novel/shared/types/llm"; import { prisma } from "../../db/prisma"; import { ragConfig, asEmbeddingProvider, type EmbeddingProvider } from "../../config/rag"; import { @@ -76,6 +77,12 @@ export interface SaveRagEmbeddingSettingsResult { shouldReindex: boolean; } +const NON_EMBEDDING_BUILT_IN_PROVIDERS = new Set(["atlascloud"]); + +export function getBuiltInRagEmbeddingProviders(): BuiltinLLMProvider[] { + return SUPPORTED_PROVIDERS.filter((provider) => !NON_EMBEDDING_BUILT_IN_PROVIDERS.has(provider)); +} + function normalizeEmbeddingModel(value: string | undefined, provider: EmbeddingProvider = ragConfig.embeddingProvider): string { const normalized = value?.trim(); if (normalized && normalized.length > 0) { @@ -394,7 +401,7 @@ export async function saveRagEmbeddingSettings(input: RagEmbeddingSettingsInput) } export async function getRagEmbeddingProviders(): Promise { - const builtInProviders = [...SUPPORTED_PROVIDERS]; + const builtInProviders = getBuiltInRagEmbeddingProviders(); try { const items = await prisma.aPIKey.findMany({ select: { diff --git a/server/tests/llmProviders.test.js b/server/tests/llmProviders.test.js index 14c21e9f2..95f74bd6e 100644 --- a/server/tests/llmProviders.test.js +++ b/server/tests/llmProviders.test.js @@ -1,7 +1,14 @@ const test = require("node:test"); const assert = require("node:assert/strict"); const { z } = require("zod"); -const { PROVIDERS, SUPPORTED_PROVIDERS } = require("../dist/llm/providers.js"); +const { + getProviderEnvApiKey, + getProviderEnvBaseUrl, + getProviderEnvModel, + PROVIDERS, + resolveProviderBaseUrl, + SUPPORTED_PROVIDERS, +} = require("../dist/llm/providers.js"); const { getJsonCapability, getModelParameterCompatibility, @@ -14,14 +21,14 @@ const { selectStructuredOutputStrategy, } = require("../dist/llm/structuredOutput.js"); -test("supported providers include kimi, minimax, glm, qwen, gemini and ollama", () => { - for (const provider of ["kimi", "minimax", "glm", "qwen", "gemini", "ollama"]) { +test("supported providers include kimi, minimax, glm, qwen, gemini, ollama and atlascloud", () => { + for (const provider of ["kimi", "minimax", "glm", "qwen", "gemini", "ollama", "atlascloud"]) { assert.ok(SUPPORTED_PROVIDERS.includes(provider), `${provider} should be available`); } }); test("new provider defaults are present in their model fallback lists", () => { - for (const provider of ["kimi", "minimax", "glm", "qwen", "gemini", "ollama"]) { + for (const provider of ["kimi", "minimax", "glm", "qwen", "gemini", "ollama", "atlascloud"]) { assert.ok( PROVIDERS[provider].models.includes(PROVIDERS[provider].defaultModel), `${provider} default model should exist in fallback models`, @@ -29,6 +36,50 @@ test("new provider defaults are present in their model fallback lists", () => { } }); +test("atlascloud defaults and environment aliases use the OpenAI-compatible endpoint", () => { + const envKeys = [ + "ATLASCLOUD_API_KEY", + "ATLAS_CLOUD_API_KEY", + "ATLASCLOUD_BASE_URL", + "ATLAS_CLOUD_BASE_URL", + "ATLASCLOUD_MODEL", + "ATLAS_CLOUD_MODEL", + ]; + const previous = Object.fromEntries(envKeys.map((key) => [key, process.env[key]])); + + try { + for (const key of envKeys) { + delete process.env[key]; + } + + assert.equal(PROVIDERS.atlascloud.name, "Atlas Cloud"); + assert.equal(PROVIDERS.atlascloud.baseURL, "https://api.atlascloud.ai/v1"); + assert.equal(PROVIDERS.atlascloud.defaultModel, "qwen/qwen3.5-flash"); + assert.ok(PROVIDERS.atlascloud.models.includes("deepseek-ai/deepseek-v4-pro")); + assert.equal(resolveProviderBaseUrl("atlascloud"), "https://api.atlascloud.ai/v1"); + + process.env.ATLAS_CLOUD_API_KEY = " alias-key "; + assert.equal(getProviderEnvApiKey("atlascloud"), "alias-key"); + process.env.ATLASCLOUD_API_KEY = " primary-key "; + assert.equal(getProviderEnvApiKey("atlascloud"), "primary-key"); + + process.env.ATLAS_CLOUD_BASE_URL = "https://proxy.example/v1/"; + assert.equal(getProviderEnvBaseUrl("atlascloud"), "https://proxy.example/v1"); + assert.equal(resolveProviderBaseUrl("atlascloud"), "https://proxy.example/v1"); + + process.env.ATLAS_CLOUD_MODEL = " deepseek-ai/deepseek-v4-pro "; + assert.equal(getProviderEnvModel("atlascloud"), "deepseek-ai/deepseek-v4-pro"); + } finally { + for (const key of envKeys) { + if (previous[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = previous[key]; + } + } + } +}); + test("kimi thinking models do not enable forced json mode", () => { const stableCapability = getJsonCapability("kimi", "moonshot-v1-32k"); assert.equal(stableCapability.supportsJsonObject, true); @@ -118,6 +169,16 @@ test("structured output profiles distinguish official, ModelScope Qwen and unkno assert.equal(qwenBehindProxyProfile.nativeJsonSchema, false); assert.equal(selectStructuredOutputStrategy(qwenBehindProxyProfile, schema), "prompt_json"); + const atlasCloudProfile = resolveStructuredOutputProfile({ + provider: "atlascloud", + model: "qwen/qwen3.5-flash", + baseURL: "https://api.atlascloud.ai/v1", + executionMode: "structured", + }); + assert.equal(atlasCloudProfile.family, "custom_openai_compatible_qwen"); + assert.equal(atlasCloudProfile.nativeJsonSchema, false); + assert.equal(selectStructuredOutputStrategy(atlasCloudProfile, schema), "prompt_json"); + const deepseekBehindProxyProfile = resolveStructuredOutputProfile({ provider: "openai", model: "deepseek-chat", @@ -260,6 +321,19 @@ test("resolveLLMClientOptions applies structured reasoning and token guardrails" assert.equal(qwenThinking.maxTokens, 8192); assert.equal(qwenThinking.requestProtocol, "openai_compatible"); + const atlasCloud = await resolveLLMClientOptions("atlascloud", { + apiKey: "test-key", + executionMode: "structured", + structuredStrategy: "prompt_json", + maxTokens: 20000, + }); + assert.equal(atlasCloud.providerName, "Atlas Cloud"); + assert.equal(atlasCloud.model, "qwen/qwen3.5-flash"); + assert.equal(atlasCloud.baseURL, "https://api.atlascloud.ai/v1"); + assert.equal(atlasCloud.structuredProfile?.family, "custom_openai_compatible_qwen"); + assert.equal(atlasCloud.maxTokens, 8192); + assert.equal(atlasCloud.requestProtocol, "openai_compatible"); + const anthropicProtocol = await resolveLLMClientOptions("openai", { apiKey: "test-key", model: "claude-sonnet-4-5", diff --git a/shared/types/llm.ts b/shared/types/llm.ts index a15eeb6be..5c240dac7 100644 --- a/shared/types/llm.ts +++ b/shared/types/llm.ts @@ -10,6 +10,7 @@ export const LLM_PROVIDERS = [ "qwen", "gemini", "ollama", + "atlascloud", ] as const; export type BuiltinLLMProvider = typeof LLM_PROVIDERS[number];