diff --git a/src/config.ts b/src/config.ts index 343ece2..2d9927e 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" | "orcarouter"; + +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 }); } @@ -44,7 +50,7 @@ interface OpenCodeMemConfig { autoCaptureMaxRetries?: number; autoCaptureMaxContextBytes?: number; autoCaptureLanguage?: string; - memoryProvider?: "openai-chat" | "openai-responses" | "anthropic" | "minimax" | "orcarouter"; + memoryProvider?: MemoryProviderType; memoryModel?: string; memoryApiUrl?: string; memoryApiKey?: string; @@ -127,7 +133,7 @@ const DEFAULTS: Required< memoryModel?: string; memoryApiUrl?: string; memoryApiKey?: string; - memoryProvider?: "openai-chat" | "openai-responses" | "anthropic" | "minimax" | "orcarouter"; + memoryProvider?: MemoryProviderType; memoryTemperature?: number | false; memoryExtraParams?: Record; opencodeProvider?: string; @@ -370,7 +376,7 @@ const CONFIG_TEMPLATE = `{ "autoCaptureEnabled": true, - // Provider type: "openai-chat" | "openai-responses" | "anthropic" | "minimax" | "orcarouter" + // Provider type: "atlas-cloud" | "openai-chat" | "openai-responses" | "anthropic" | "minimax" | "orcarouter" // 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", @@ -390,6 +396,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" @@ -629,7 +643,13 @@ export function normalizeAutoCleanupRetentionDays(value: number): 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); @@ -677,10 +697,9 @@ function buildConfig(fileConfig: OpenCodeMemConfig) { autoCaptureMaxRetries: fileConfig.autoCaptureMaxRetries ?? DEFAULTS.autoCaptureMaxRetries, autoCaptureMaxContextBytes, autoCaptureLanguage: fileConfig.autoCaptureLanguage, - memoryProvider: (fileConfig.memoryProvider ?? "openai-chat") as - "openai-chat" | "openai-responses" | "anthropic" | "minimax" | "orcarouter", - memoryModel: fileConfig.memoryModel, - memoryApiUrl: fileConfig.memoryApiUrl, + memoryProvider, + memoryModel, + memoryApiUrl, memoryApiKey, memoryTemperature: fileConfig.memoryTemperature, memoryExtraParams: fileConfig.memoryExtraParams, @@ -689,8 +708,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 cf8de0c..bd81e40 100644 --- a/src/services/ai/ai-provider-factory.ts +++ b/src/services/ai/ai-provider-factory.ts @@ -4,6 +4,7 @@ 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 { OrcaRouterProvider } from "./providers/orcarouter.js"; import { aiSessionManager } from "./session/ai-session-manager.js"; import type { AIProviderType } from "./session/session-types.js"; @@ -11,6 +12,9 @@ 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); @@ -36,6 +40,7 @@ export class AIProviderFactory { static getSupportedProviders(): AIProviderType[] { return [ + "atlas-cloud", "openai-chat", "openai-responses", "anthropic", 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 9ae645a..5868b4c 100644 --- a/src/services/ai/providers/openai-chat-completion.ts +++ b/src/services/ai/providers/openai-chat-completion.ts @@ -103,7 +103,7 @@ export class OpenAIChatCompletionProvider extends BaseAIProvider { } getProviderName(): string { - return "openai-chat"; + return this.sessionProviderTag(); } supportsSession(): boolean { diff --git a/src/services/ai/session/session-types.ts b/src/services/ai/session/session-types.ts index db4676c..a2b0ffa 100644 --- a/src/services/ai/session/session-types.ts +++ b/src/services/ai/session/session-types.ts @@ -1,5 +1,11 @@ export type AIProviderType = - "openai-chat" | "openai-responses" | "anthropic" | "minimax" | "google-gemini" | "orcarouter"; + | "atlas-cloud" + | "openai-chat" + | "openai-responses" + | "anthropic" + | "minimax" + | "google-gemini" + | "orcarouter"; export interface AIMessage { id?: number; diff --git a/src/types/index.ts b/src/types/index.ts index f6a0e82..a40a3d1 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -18,4 +18,10 @@ export interface MemoryMetadata { } export type AIProviderType = - "openai-chat" | "openai-responses" | "anthropic" | "minimax" | "google-gemini" | "orcarouter"; + | "atlas-cloud" + | "openai-chat" + | "openai-responses" + | "anthropic" + | "minimax" + | "google-gemini" + | "orcarouter"; 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 fcdc771..e9022f4 100644 --- a/tests/config-resolution.test.ts +++ b/tests/config-resolution.test.ts @@ -188,4 +188,68 @@ 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 { + // memoryProvider is a global-only field (see + // assertProjectRemoteProviderConfigIsSafe), so the preset is configured + // globally and the project config leaves provider fields alone. + existsSpy = spyOn(fs, "existsSync").mockReturnValue(true); + readSpy = spyOn(fs, "readFileSync").mockImplementation((p) => { + const path = normalizePath(p); + if (path.includes(".opencode/opencode-mem")) { + return JSON.stringify({ autoCaptureEnabled: true }) as any; + } + return JSON.stringify({ memoryProvider: "atlas-cloud" }) as any; + }); + + 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; + } + } + }); + + it("refuses a project config that switches memoryProvider to Atlas Cloud", () => { + // Raised in review: with a global OpenAI setup, a project config carrying + // only `memoryProvider: "atlas-cloud"` used to shallow-merge into a state + // where the provider said Atlas while the model, URL and key stayed + // OpenAI's — and a partial override could pair a new endpoint with an + // inherited credential. Upstream now rejects provider fields at project + // scope outright, which closes that hole for every provider, not just this + // one. This locks that behaviour in. + existsSpy = spyOn(fs, "existsSync").mockReturnValue(true); + readSpy = spyOn(fs, "readFileSync").mockImplementation((p) => { + const path = normalizePath(p); + if (path.includes(".opencode/opencode-mem")) { + return JSON.stringify({ memoryProvider: "atlas-cloud" }) as any; + } + return JSON.stringify({ + memoryProvider: "openai-chat", + memoryModel: "gpt-global", + memoryApiUrl: "https://api.openai.com/v1", + memoryApiKey: "global-openai-secret", + }) as any; + }); + + expect(() => initConfig("/my/project")).toThrow( + /Project config cannot set remote provider fields: memoryProvider/ + ); + }); });