Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions server/src/llm/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
50 changes: 35 additions & 15 deletions server/src/llm/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -140,6 +143,18 @@ export const PROVIDERS: Record<BuiltinLLMProvider, ProviderConfig> = {
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];
Expand All @@ -152,37 +167,42 @@ export function normalizeBaseURL(baseURL: string): string {
return baseURL.endsWith("/") ? baseURL.slice(0, -1) : baseURL;
}

function readProviderEnv(keys: Array<string | undefined>, 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 {
Expand Down
9 changes: 8 additions & 1 deletion server/src/services/settings/RagSettingsService.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -76,6 +77,12 @@ export interface SaveRagEmbeddingSettingsResult {
shouldReindex: boolean;
}

const NON_EMBEDDING_BUILT_IN_PROVIDERS = new Set<BuiltinLLMProvider>(["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) {
Expand Down Expand Up @@ -394,7 +401,7 @@ export async function saveRagEmbeddingSettings(input: RagEmbeddingSettingsInput)
}

export async function getRagEmbeddingProviders(): Promise<RagEmbeddingProviderStatus[]> {
const builtInProviders = [...SUPPORTED_PROVIDERS];
const builtInProviders = getBuiltInRagEmbeddingProviders();
try {
const items = await prisma.aPIKey.findMany({
select: {
Expand Down
82 changes: 78 additions & 4 deletions server/tests/llmProviders.test.js
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -14,21 +21,65 @@ 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`,
);
}
});

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);
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions shared/types/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const LLM_PROVIDERS = [
"qwen",
"gemini",
"ollama",
"atlascloud",
] as const;

export type BuiltinLLMProvider = typeof LLM_PROVIDERS[number];
Expand Down