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
39 changes: 29 additions & 10 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, unknown>;
opencodeProvider?: string;
Expand Down Expand Up @@ -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",
Expand All @@ -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"
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
13 changes: 12 additions & 1 deletion src/services/ai/ai-provider-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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<number> {
Expand Down
12 changes: 12 additions & 0 deletions src/services/ai/providers/atlas-cloud.ts
Original file line number Diff line number Diff line change
@@ -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";
}
}
11 changes: 8 additions & 3 deletions src/services/ai/providers/openai-chat-completion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -103,6 +103,10 @@ export class OpenAIChatCompletionProvider extends BaseAIProvider {
}

getProviderName(): string {
return this.sessionProviderTag();
}

protected sessionProviderTag(): AIProviderType {
return "openai-chat";
}

Expand Down Expand Up @@ -177,11 +181,12 @@ export class OpenAIChatCompletionProvider extends BaseAIProvider {
toolSchema: ChatCompletionTool,
sessionId: string
): Promise<ToolCallResult> {
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,
});
}
Expand Down
2 changes: 1 addition & 1 deletion src/services/ai/session/session-types.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
126 changes: 126 additions & 0 deletions tests/atlas-cloud-provider.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {};

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",
});
});
});
30 changes: 30 additions & 0 deletions tests/config-resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
});
});