From 911810cf56e169010ebaa3ac9c98bb143170575b Mon Sep 17 00:00:00 2001 From: binyangzhu000-sudo <224954946+binyangzhu000-sudo@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:09:29 +0800 Subject: [PATCH] feat: add Atlas Cloud memory provider Signed-off-by: binyangzhu000-sudo <224954946+binyangzhu000-sudo@users.noreply.github.com> --- src/config.ts | 39 ++++-- src/services/ai/ai-provider-factory.ts | 13 +- src/services/ai/providers/atlas-cloud.ts | 12 ++ .../ai/providers/openai-chat-completion.ts | 11 +- src/services/ai/session/session-types.ts | 2 +- src/types/index.ts | 2 +- tests/atlas-cloud-provider.test.ts | 126 ++++++++++++++++++ tests/config-resolution.test.ts | 30 +++++ 8 files changed, 219 insertions(+), 16 deletions(-) create mode 100644 src/services/ai/providers/atlas-cloud.ts create mode 100644 tests/atlas-cloud-provider.test.ts diff --git a/src/config.ts b/src/config.ts index 9247e7e..c5d6fd9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -12,6 +12,12 @@ const CONFIG_FILES = [ join(CONFIG_DIR, "opencode-mem.json"), ]; +type MemoryProviderType = + "atlas-cloud" | "openai-chat" | "openai-responses" | "anthropic" | "minimax"; + +const ATLAS_CLOUD_API_URL = "https://api.atlascloud.ai/v1"; +const ATLAS_CLOUD_MODEL = "deepseek-ai/deepseek-v4-pro"; + if (!existsSync(CONFIG_DIR)) { mkdirSync(CONFIG_DIR, { recursive: true }); } @@ -43,7 +49,7 @@ interface OpenCodeMemConfig { autoCaptureIterationTimeout?: number; autoCaptureMaxRetries?: number; autoCaptureLanguage?: string; - memoryProvider?: "openai-chat" | "openai-responses" | "anthropic" | "minimax"; + memoryProvider?: MemoryProviderType; memoryModel?: string; memoryApiUrl?: string; memoryApiKey?: string; @@ -124,7 +130,7 @@ const DEFAULTS: Required< memoryModel?: string; memoryApiUrl?: string; memoryApiKey?: string; - memoryProvider?: "openai-chat" | "openai-responses" | "anthropic" | "minimax"; + memoryProvider?: MemoryProviderType; memoryTemperature?: number | false; memoryExtraParams?: Record; opencodeProvider?: string; @@ -343,7 +349,7 @@ const CONFIG_TEMPLATE = `{ "autoCaptureEnabled": true, - // Provider type: "openai-chat" | "openai-responses" | "anthropic" | "minimax" + // Provider type: "atlas-cloud" | "openai-chat" | "openai-responses" | "anthropic" | "minimax" // Note: "openai-chat" is a generic OpenAI API-compatible mode. // Any service that follows the OpenAI Chat Completions API can use it via custom "memoryApiUrl". "memoryProvider": "openai-chat", @@ -363,6 +369,14 @@ const CONFIG_TEMPLATE = `{ // Common examples: DeepSeek, Qwen (via Alibaba Cloud ModelStudio), // Zhipu GLM (BigModel platform), and Kimi (Moonshot AI platform). + // Atlas Cloud preset (OpenAI-compatible Chat Completions): + // Set ATLASCLOUD_API_KEY in the environment, then use: + // "memoryProvider": "atlas-cloud" + // // Optional overrides: + // // "memoryModel": "deepseek-ai/deepseek-v4-pro" + // // "memoryApiUrl": "https://api.atlascloud.ai/v1" + // // "memoryApiKey": "env://ATLASCLOUD_API_KEY" + // OpenAI Chat Completion (default, backward compatible): // "memoryProvider": "openai-chat" // "memoryModel": "gpt-4o-mini" @@ -569,7 +583,13 @@ function getEmbeddingDimensions(model: string): number { } function buildConfig(fileConfig: OpenCodeMemConfig) { - const memoryApiKey = resolveSecretValue(fileConfig.memoryApiKey); + const memoryProvider = fileConfig.memoryProvider ?? "openai-chat"; + const isAtlasCloud = memoryProvider === "atlas-cloud"; + const memoryModel = fileConfig.memoryModel ?? (isAtlasCloud ? ATLAS_CLOUD_MODEL : undefined); + const memoryApiUrl = fileConfig.memoryApiUrl ?? (isAtlasCloud ? ATLAS_CLOUD_API_URL : undefined); + const memoryApiKey = + resolveSecretValue(fileConfig.memoryApiKey) ?? + (isAtlasCloud ? process.env.ATLASCLOUD_API_KEY : undefined); const embeddingDimensions = fileConfig.embeddingDimensions ?? getEmbeddingDimensions(fileConfig.embeddingModel ?? DEFAULTS.embeddingModel); @@ -606,10 +626,9 @@ function buildConfig(fileConfig: OpenCodeMemConfig) { fileConfig.autoCaptureIterationTimeout ?? DEFAULTS.autoCaptureIterationTimeout, autoCaptureMaxRetries: fileConfig.autoCaptureMaxRetries ?? DEFAULTS.autoCaptureMaxRetries, autoCaptureLanguage: fileConfig.autoCaptureLanguage, - memoryProvider: (fileConfig.memoryProvider ?? "openai-chat") as - "openai-chat" | "openai-responses" | "anthropic" | "minimax", - memoryModel: fileConfig.memoryModel, - memoryApiUrl: fileConfig.memoryApiUrl, + memoryProvider, + memoryModel, + memoryApiUrl, memoryApiKey, memoryTemperature: fileConfig.memoryTemperature, memoryExtraParams: fileConfig.memoryExtraParams, @@ -618,8 +637,8 @@ function buildConfig(fileConfig: OpenCodeMemConfig) { autoCaptureProviderStatus: getAutoCaptureProviderStatus({ opencodeProvider: fileConfig.opencodeProvider, opencodeModel: fileConfig.opencodeModel, - memoryModel: fileConfig.memoryModel, - memoryApiUrl: fileConfig.memoryApiUrl, + memoryModel, + memoryApiUrl, memoryApiKey, }), aiSessionRetentionDays: fileConfig.aiSessionRetentionDays ?? DEFAULTS.aiSessionRetentionDays, diff --git a/src/services/ai/ai-provider-factory.ts b/src/services/ai/ai-provider-factory.ts index c3b5e08..0cf7ef5 100644 --- a/src/services/ai/ai-provider-factory.ts +++ b/src/services/ai/ai-provider-factory.ts @@ -4,12 +4,16 @@ import { OpenAIResponsesProvider } from "./providers/openai-responses.js"; import { AnthropicMessagesProvider } from "./providers/anthropic-messages.js"; import { MiniMaxProvider } from "./providers/minimax.js"; import { GoogleGeminiProvider } from "./providers/google-gemini.js"; +import { AtlasCloudProvider } from "./providers/atlas-cloud.js"; import { aiSessionManager } from "./session/ai-session-manager.js"; import type { AIProviderType } from "./session/session-types.js"; export class AIProviderFactory { static createProvider(providerType: AIProviderType, config: ProviderConfig): BaseAIProvider { switch (providerType) { + case "atlas-cloud": + return new AtlasCloudProvider(config, aiSessionManager); + case "openai-chat": return new OpenAIChatCompletionProvider(config, aiSessionManager); @@ -31,7 +35,14 @@ export class AIProviderFactory { } static getSupportedProviders(): AIProviderType[] { - return ["openai-chat", "openai-responses", "anthropic", "minimax", "google-gemini"]; + return [ + "atlas-cloud", + "openai-chat", + "openai-responses", + "anthropic", + "minimax", + "google-gemini", + ]; } static async cleanupExpiredSessions(): Promise { diff --git a/src/services/ai/providers/atlas-cloud.ts b/src/services/ai/providers/atlas-cloud.ts new file mode 100644 index 0000000..35c7029 --- /dev/null +++ b/src/services/ai/providers/atlas-cloud.ts @@ -0,0 +1,12 @@ +import type { AIProviderType } from "../session/session-types.js"; +import { OpenAIChatCompletionProvider } from "./openai-chat-completion.js"; + +export class AtlasCloudProvider extends OpenAIChatCompletionProvider { + override getProviderName(): string { + return "atlas-cloud"; + } + + protected override sessionProviderTag(): AIProviderType { + return "atlas-cloud"; + } +} diff --git a/src/services/ai/providers/openai-chat-completion.ts b/src/services/ai/providers/openai-chat-completion.ts index 48545da..6cc3295 100644 --- a/src/services/ai/providers/openai-chat-completion.ts +++ b/src/services/ai/providers/openai-chat-completion.ts @@ -5,7 +5,7 @@ import { applySafeExtraParams, } from "./base-provider.js"; import type { AISessionManager } from "../session/ai-session-manager.js"; -import type { AIMessage } from "../session/session-types.js"; +import type { AIMessage, AIProviderType } from "../session/session-types.js"; import type { ChatCompletionTool } from "../tools/tool-schema.js"; import { log } from "../../logger.js"; import { UserProfileValidator } from "../validators/user-profile-validator.js"; @@ -103,6 +103,10 @@ export class OpenAIChatCompletionProvider extends BaseAIProvider { } getProviderName(): string { + return this.sessionProviderTag(); + } + + protected sessionProviderTag(): AIProviderType { return "openai-chat"; } @@ -177,11 +181,12 @@ export class OpenAIChatCompletionProvider extends BaseAIProvider { toolSchema: ChatCompletionTool, sessionId: string ): Promise { - let session = await this.aiSessionManager.getSession(sessionId, "openai-chat"); + const providerType = this.sessionProviderTag(); + let session = await this.aiSessionManager.getSession(sessionId, providerType); if (!session) { session = await this.aiSessionManager.createSession({ - provider: "openai-chat", + provider: providerType, sessionId, }); } diff --git a/src/services/ai/session/session-types.ts b/src/services/ai/session/session-types.ts index 6e162d0..2f39a48 100644 --- a/src/services/ai/session/session-types.ts +++ b/src/services/ai/session/session-types.ts @@ -1,5 +1,5 @@ export type AIProviderType = - "openai-chat" | "openai-responses" | "anthropic" | "minimax" | "google-gemini"; + "atlas-cloud" | "openai-chat" | "openai-responses" | "anthropic" | "minimax" | "google-gemini"; export interface AIMessage { id?: number; diff --git a/src/types/index.ts b/src/types/index.ts index 5c6fc74..7aa5c79 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -18,4 +18,4 @@ export interface MemoryMetadata { } export type AIProviderType = - "openai-chat" | "openai-responses" | "anthropic" | "minimax" | "google-gemini"; + "atlas-cloud" | "openai-chat" | "openai-responses" | "anthropic" | "minimax" | "google-gemini"; diff --git a/tests/atlas-cloud-provider.test.ts b/tests/atlas-cloud-provider.test.ts new file mode 100644 index 0000000..28c12ed --- /dev/null +++ b/tests/atlas-cloud-provider.test.ts @@ -0,0 +1,126 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { AIProviderFactory } from "../src/services/ai/ai-provider-factory.js"; +import { AtlasCloudProvider } from "../src/services/ai/providers/atlas-cloud.js"; +import type { ChatCompletionTool } from "../src/services/ai/tools/tool-schema.js"; + +const toolSchema: ChatCompletionTool = { + type: "function", + function: { + name: "save_memories", + description: "Save memories", + parameters: { + type: "object", + properties: {}, + required: [], + }, + }, +}; + +class FakeSessionManager { + readonly session = { id: "session-1" }; + readonly messages: any[] = []; + lastCreateSessionArgs: any; + + getSession(): null { + return null; + } + + createSession(args: any): { id: string } { + this.lastCreateSessionArgs = args; + return this.session; + } + + getMessages(): any[] { + return this.messages; + } + + getLastSequence(): number { + return this.messages.length - 1; + } + + addMessage(message: any): void { + this.messages.push(message); + } + + updateSession(): void {} +} + +describe("AtlasCloudProvider", () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("is available from the provider factory", () => { + const provider = AIProviderFactory.createProvider("atlas-cloud", { + model: "deepseek-ai/deepseek-v4-pro", + apiUrl: "https://api.atlascloud.ai/v1", + apiKey: "atlas-test-key", + }); + + expect(provider).toBeInstanceOf(AtlasCloudProvider); + expect(provider.getProviderName()).toBe("atlas-cloud"); + expect(AIProviderFactory.getSupportedProviders()).toContain("atlas-cloud"); + }); + + it("uses Atlas Cloud Chat Completions and stores an Atlas session", async () => { + let requestUrl = ""; + let authorization = ""; + let requestBody: Record = {}; + + const validArguments = JSON.stringify({ + preferences: [], + patterns: [], + workflows: [], + codingStyle: {}, + domainKnowledge: [], + }); + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + requestUrl = String(input); + authorization = new Headers(init?.headers).get("authorization") ?? ""; + requestBody = JSON.parse(String(init?.body ?? "{}")); + return new Response( + JSON.stringify({ + choices: [ + { + message: { + tool_calls: [ + { + id: "call-1", + type: "function", + function: { name: "save_memories", arguments: validArguments }, + }, + ], + }, + finish_reason: "tool_calls", + }, + ], + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }) as typeof fetch; + + const sessionManager = new FakeSessionManager(); + const provider = new AtlasCloudProvider( + { + model: "deepseek-ai/deepseek-v4-pro", + apiUrl: "https://api.atlascloud.ai/v1", + apiKey: "atlas-test-key", + }, + sessionManager as any + ); + + const result = await provider.executeToolCall("system", "user", toolSchema, "session-id"); + + expect(result.success).toBe(true); + expect(requestUrl).toBe("https://api.atlascloud.ai/v1/chat/completions"); + expect(authorization).toBe("Bearer atlas-test-key"); + expect(requestBody.model).toBe("deepseek-ai/deepseek-v4-pro"); + expect(sessionManager.lastCreateSessionArgs).toEqual({ + provider: "atlas-cloud", + sessionId: "session-id", + }); + }); +}); diff --git a/tests/config-resolution.test.ts b/tests/config-resolution.test.ts index b944685..958b663 100644 --- a/tests/config-resolution.test.ts +++ b/tests/config-resolution.test.ts @@ -68,4 +68,34 @@ describe("project-scoped config resolution", () => { expect(CONFIG.autoCaptureEnabled).toBe(true); // default value expect(CONFIG.opencodeProvider).toBeUndefined(); }); + + it("resolves Atlas Cloud defaults and environment API key", () => { + const originalApiKey = process.env.ATLASCLOUD_API_KEY; + process.env.ATLASCLOUD_API_KEY = "atlas-test-key"; + + try { + existsSpy = spyOn(fs, "existsSync").mockReturnValue(true); + readSpy = spyOn(fs, "readFileSync").mockReturnValue( + JSON.stringify({ memoryProvider: "atlas-cloud" }) + ); + + initConfig("/my/project"); + + expect(CONFIG.memoryProvider).toBe("atlas-cloud"); + expect(CONFIG.memoryModel).toBe("deepseek-ai/deepseek-v4-pro"); + expect(CONFIG.memoryApiUrl).toBe("https://api.atlascloud.ai/v1"); + expect(CONFIG.memoryApiKey).toBe("atlas-test-key"); + expect(CONFIG.autoCaptureProviderStatus).toEqual({ + ready: true, + mode: "manual", + issues: [], + }); + } finally { + if (originalApiKey === undefined) { + delete process.env.ATLASCLOUD_API_KEY; + } else { + process.env.ATLASCLOUD_API_KEY = originalApiKey; + } + } + }); });