@openpalm/lib 0.9.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -0
- package/package.json +30 -0
- package/src/control-plane/audit.ts +40 -0
- package/src/control-plane/channels.ts +196 -0
- package/src/control-plane/connection-mapping.ts +191 -0
- package/src/control-plane/connection-migration-flags.ts +40 -0
- package/src/control-plane/connection-profiles.ts +317 -0
- package/src/control-plane/core-asset-provider.ts +20 -0
- package/src/control-plane/core-assets.ts +292 -0
- package/src/control-plane/docker.ts +448 -0
- package/src/control-plane/env.ts +70 -0
- package/src/control-plane/fs-asset-provider.ts +61 -0
- package/src/control-plane/fs-registry-provider.ts +46 -0
- package/src/control-plane/lifecycle.ts +373 -0
- package/src/control-plane/memory-config.ts +424 -0
- package/src/control-plane/model-runner.ts +101 -0
- package/src/control-plane/paths.ts +77 -0
- package/src/control-plane/registry-provider.ts +19 -0
- package/src/control-plane/scheduler.ts +498 -0
- package/src/control-plane/secrets.ts +177 -0
- package/src/control-plane/setup-status.ts +31 -0
- package/src/control-plane/setup.test.ts +476 -0
- package/src/control-plane/setup.ts +474 -0
- package/src/control-plane/staging.ts +376 -0
- package/src/control-plane/types.ts +165 -0
- package/src/index.ts +295 -0
- package/src/logger.ts +14 -0
- package/src/provider-constants.ts +106 -0
package/README.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# @openpalm/lib
|
|
2
|
+
|
|
3
|
+
Shared control-plane library consumed by both the CLI and the admin SvelteKit app. All portable logic for managing an OpenPalm stack lives here -- paths, secrets, staging, lifecycle, Docker Compose operations, channel/connection management, scheduling, and setup orchestration.
|
|
4
|
+
|
|
5
|
+
Admin is a thin UI layer that re-exports from this package. The CLI calls these functions directly.
|
|
6
|
+
|
|
7
|
+
## Modules
|
|
8
|
+
|
|
9
|
+
| Module | Purpose |
|
|
10
|
+
|---|---|
|
|
11
|
+
| `types` | Core type definitions -- `ControlPlaneState`, `CoreServiceName`, `ChannelInfo`, `CanonicalConnectionProfile`, etc. |
|
|
12
|
+
| `paths` | XDG directory resolution (`resolveConfigHome`, `resolveDataHome`, `resolveStateHome`, `ensureXdgDirs`) |
|
|
13
|
+
| `env` | `.env` file parsing and merging (`parseEnvContent`, `parseEnvFile`, `mergeEnvContent`) |
|
|
14
|
+
| `secrets` | Secrets management -- ensure, read, patch, mask `secrets.env` |
|
|
15
|
+
| `setup-status` | First-boot detection (`isSetupComplete`, `readSecretsKeys`, `detectUserId`) |
|
|
16
|
+
| `setup` | Setup wizard backend (`performSetup`, `detectProviders`, `validateSetupInput`) |
|
|
17
|
+
| `staging` | Artifact staging pipeline (`stageArtifacts`, `persistArtifacts`, `buildEnvFiles`) |
|
|
18
|
+
| `lifecycle` | Install/update/uninstall/upgrade orchestration (`applyInstall`, `applyUpdate`, `createState`) |
|
|
19
|
+
| `docker` | Docker Compose CLI wrapper (`composeUp`, `composeDown`, `composePs`, `composeLogs`, etc.) |
|
|
20
|
+
| `channels` | Channel discovery, install, uninstall from registry or filesystem |
|
|
21
|
+
| `connection-profiles` | CRUD for connection profiles (LLM providers, embedding, etc.) |
|
|
22
|
+
| `connection-mapping` | Build OpenCode and mem0 config from connection profiles |
|
|
23
|
+
| `memory-config` | Read/write memory service config, push to running memory container |
|
|
24
|
+
| `scheduler` | Automation YAML parsing, Croner-based scheduler, execution log |
|
|
25
|
+
| `model-runner` | Local provider detection (Ollama, Docker Model Runner, LM Studio) |
|
|
26
|
+
| `core-assets` | Seed and read core infrastructure files (compose, Caddyfile, schemas) |
|
|
27
|
+
| `connection-migration-flags` | Migration compatibility detection (`readConnectionMigrationFlags`, `detectConnectionCompatibilityMode`) |
|
|
28
|
+
| `audit` | Append to the audit log |
|
|
29
|
+
| `logger` | Structured logger factory (`createLogger`) |
|
|
30
|
+
| `provider-constants` | LLM provider metadata (`LLM_PROVIDERS`, `PROVIDER_DEFAULT_URLS`, `EMBEDDING_DIMS`) |
|
|
31
|
+
|
|
32
|
+
## Dependency Injection
|
|
33
|
+
|
|
34
|
+
Asset loading differs between CLI and admin. Two interfaces abstract this:
|
|
35
|
+
|
|
36
|
+
### CoreAssetProvider
|
|
37
|
+
|
|
38
|
+
Returns the content of bundled infrastructure files (compose, Caddyfile, schemas, automations). Functions that need these files accept a `CoreAssetProvider` parameter.
|
|
39
|
+
|
|
40
|
+
| Implementation | Consumer | Source |
|
|
41
|
+
|---|---|---|
|
|
42
|
+
| `FilesystemAssetProvider` | CLI | Reads from `DATA_HOME` on disk |
|
|
43
|
+
| `ViteAssetProvider` | Admin | Reads from Vite `$assets` imports (defined in `packages/admin`, not in lib) |
|
|
44
|
+
|
|
45
|
+
### RegistryProvider
|
|
46
|
+
|
|
47
|
+
Returns channel and automation definitions from the registry catalog.
|
|
48
|
+
|
|
49
|
+
| Implementation | Consumer | Source |
|
|
50
|
+
|---|---|---|
|
|
51
|
+
| `FilesystemRegistryProvider` | CLI | Reads from `registry/` directory |
|
|
52
|
+
| `ViteRegistryProvider` | Admin | Reads via `import.meta.glob` (defined in `packages/admin`, not in lib) |
|
|
53
|
+
|
|
54
|
+
## Usage
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
import {
|
|
58
|
+
createState,
|
|
59
|
+
stageArtifacts,
|
|
60
|
+
persistArtifacts,
|
|
61
|
+
applyInstall,
|
|
62
|
+
FilesystemAssetProvider,
|
|
63
|
+
} from "@openpalm/lib";
|
|
64
|
+
|
|
65
|
+
const assets = new FilesystemAssetProvider(dataHome);
|
|
66
|
+
const state = createState(configHome, dataHome, stateHome);
|
|
67
|
+
|
|
68
|
+
stageArtifacts(state, assets);
|
|
69
|
+
persistArtifacts(state, assets);
|
|
70
|
+
await applyInstall(state, assets, "cli");
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Sub-path imports are also available:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
import { resolveConfigHome } from "@openpalm/lib/control-plane/paths";
|
|
77
|
+
import { createLogger } from "@openpalm/lib/shared/logger";
|
|
78
|
+
import { LLM_PROVIDERS } from "@openpalm/lib/provider-constants";
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Architecture
|
|
82
|
+
|
|
83
|
+
See [`docs/technical/core-principles.md`](../../docs/technical/core-principles.md) for the filesystem contract and security invariants that govern this library.
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@openpalm/lib",
|
|
3
|
+
"version": "0.9.4",
|
|
4
|
+
"license": "MPL-2.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Shared control-plane library for OpenPalm — lifecycle, staging, secrets, channels, connections, scheduler",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "bun test"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.ts",
|
|
12
|
+
"./provider-constants": "./src/provider-constants.ts",
|
|
13
|
+
"./shared/logger": "./src/logger.ts",
|
|
14
|
+
"./control-plane/*": "./src/control-plane/*.ts"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"src",
|
|
18
|
+
"assets"
|
|
19
|
+
],
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "https://github.com/itlackey/openpalm",
|
|
23
|
+
"directory": "packages/lib"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"croner": "^9.0.0",
|
|
27
|
+
"dotenv": "^16.4.7",
|
|
28
|
+
"yaml": "^2.8.0"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audit logging for the OpenPalm control plane.
|
|
3
|
+
*/
|
|
4
|
+
import { mkdirSync, appendFileSync } from "node:fs";
|
|
5
|
+
import type { ControlPlaneState, AuditEntry, CallerType } from "./types.js";
|
|
6
|
+
import { MAX_AUDIT_MEMORY } from "./types.js";
|
|
7
|
+
|
|
8
|
+
export function appendAudit(
|
|
9
|
+
state: ControlPlaneState,
|
|
10
|
+
actor: string,
|
|
11
|
+
action: string,
|
|
12
|
+
args: Record<string, unknown>,
|
|
13
|
+
ok: boolean,
|
|
14
|
+
requestId = "",
|
|
15
|
+
callerType: CallerType = "unknown"
|
|
16
|
+
): void {
|
|
17
|
+
const entry: AuditEntry = {
|
|
18
|
+
at: new Date().toISOString(),
|
|
19
|
+
requestId,
|
|
20
|
+
actor,
|
|
21
|
+
callerType,
|
|
22
|
+
action,
|
|
23
|
+
args,
|
|
24
|
+
ok
|
|
25
|
+
};
|
|
26
|
+
state.audit.push(entry);
|
|
27
|
+
if (state.audit.length > MAX_AUDIT_MEMORY) {
|
|
28
|
+
state.audit = state.audit.slice(-MAX_AUDIT_MEMORY);
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
const auditDir = `${state.stateDir}/audit`;
|
|
32
|
+
mkdirSync(auditDir, { recursive: true });
|
|
33
|
+
appendFileSync(
|
|
34
|
+
`${auditDir}/admin-audit.jsonl`,
|
|
35
|
+
JSON.stringify(entry) + "\n"
|
|
36
|
+
);
|
|
37
|
+
} catch {
|
|
38
|
+
// best-effort persistence
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel validation, discovery, install, and uninstall for the OpenPalm control plane.
|
|
3
|
+
*
|
|
4
|
+
* Install/uninstall functions accept a RegistryProvider for catalog access,
|
|
5
|
+
* decoupling from Vite-specific imports.
|
|
6
|
+
*/
|
|
7
|
+
import { mkdirSync, writeFileSync, existsSync, readdirSync, rmSync } from "node:fs";
|
|
8
|
+
import type { ChannelInfo } from "./types.js";
|
|
9
|
+
import { CORE_SERVICES } from "./types.js";
|
|
10
|
+
import type { RegistryProvider } from "./registry-provider.js";
|
|
11
|
+
|
|
12
|
+
// ── Channel Name Validation ───────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
/** Strict channel name: lowercase alphanumeric + hyphens, 1–63 chars, must start with alnum */
|
|
15
|
+
const CHANNEL_NAME_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
|
16
|
+
|
|
17
|
+
function isValidChannelName(name: string): boolean {
|
|
18
|
+
return CHANNEL_NAME_RE.test(name);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ── Channel Discovery ─────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Discover installed channels by scanning CONFIG_HOME/channels/.
|
|
25
|
+
*
|
|
26
|
+
* A channel is any .yml file in the channels directory.
|
|
27
|
+
* A .caddy file is optional — if present, the channel gets Caddy HTTP routing.
|
|
28
|
+
* If absent, the channel is only accessible on the Docker network (host + containers).
|
|
29
|
+
*/
|
|
30
|
+
export function discoverChannels(configDir: string): ChannelInfo[] {
|
|
31
|
+
const channelsDir = `${configDir}/channels`;
|
|
32
|
+
if (!existsSync(channelsDir)) return [];
|
|
33
|
+
|
|
34
|
+
const files = readdirSync(channelsDir);
|
|
35
|
+
const ymlFiles = files.filter((f) => f.endsWith(".yml"));
|
|
36
|
+
const caddyFiles = new Set(files.filter((f) => f.endsWith(".caddy")));
|
|
37
|
+
|
|
38
|
+
return ymlFiles
|
|
39
|
+
.map((ymlFile) => {
|
|
40
|
+
const name = ymlFile.replace(/\.yml$/, "");
|
|
41
|
+
const caddyFile = `${name}.caddy`;
|
|
42
|
+
const hasCaddy = caddyFiles.has(caddyFile);
|
|
43
|
+
return {
|
|
44
|
+
name,
|
|
45
|
+
hasRoute: hasCaddy,
|
|
46
|
+
ymlPath: `${channelsDir}/${ymlFile}`,
|
|
47
|
+
caddyPath: hasCaddy ? `${channelsDir}/${caddyFile}` : null
|
|
48
|
+
};
|
|
49
|
+
})
|
|
50
|
+
.filter((ch) => isValidChannelName(ch.name));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── Allowlist Checks ───────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Check if a service name is allowed. Core services are always allowed.
|
|
57
|
+
* Ollama is allowed when its compose overlay is staged.
|
|
58
|
+
* Channel services (channel-*) are allowed if a corresponding staged .yml exists
|
|
59
|
+
* in STATE_HOME/artifacts/channels/.
|
|
60
|
+
*/
|
|
61
|
+
export function isAllowedService(value: string, stateDir?: string): boolean {
|
|
62
|
+
if (!value || !value.trim() || value !== value.toLowerCase()) return false;
|
|
63
|
+
if ((CORE_SERVICES as string[]).includes(value)) return true;
|
|
64
|
+
if (value === "ollama" && stateDir) {
|
|
65
|
+
return existsSync(`${stateDir}/artifacts/ollama.yml`);
|
|
66
|
+
}
|
|
67
|
+
if (value.startsWith("channel-")) {
|
|
68
|
+
const ch = value.slice("channel-".length);
|
|
69
|
+
if (!isValidChannelName(ch)) return false;
|
|
70
|
+
if (stateDir) {
|
|
71
|
+
return existsSync(`${stateDir}/artifacts/channels/${ch}.yml`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Check if a channel name is valid. Accepts any channel with a staged
|
|
79
|
+
* .yml file in STATE_HOME/artifacts/channels/.
|
|
80
|
+
*/
|
|
81
|
+
export function isValidChannel(value: string, stateDir?: string): boolean {
|
|
82
|
+
if (!value || !value.trim()) return false;
|
|
83
|
+
if (!isValidChannelName(value)) return false;
|
|
84
|
+
if (stateDir) {
|
|
85
|
+
return existsSync(`${stateDir}/artifacts/channels/${value}.yml`);
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Channel Install / Uninstall ─────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Install a channel from the registry catalog into CONFIG_HOME/channels/.
|
|
94
|
+
* Copies the .yml (and optional .caddy) from the registry provider.
|
|
95
|
+
* Refuses if the channel is already installed (files already exist).
|
|
96
|
+
*/
|
|
97
|
+
export function installChannelFromRegistry(
|
|
98
|
+
name: string,
|
|
99
|
+
configDir: string,
|
|
100
|
+
registry: RegistryProvider
|
|
101
|
+
): { ok: true } | { ok: false; error: string } {
|
|
102
|
+
if (!isValidChannelName(name)) {
|
|
103
|
+
return { ok: false, error: `Invalid channel name: ${name}` };
|
|
104
|
+
}
|
|
105
|
+
const channelYml = registry.channelYml();
|
|
106
|
+
if (!(name in channelYml)) {
|
|
107
|
+
return { ok: false, error: `Channel "${name}" not found in registry` };
|
|
108
|
+
}
|
|
109
|
+
const channelsDir = `${configDir}/channels`;
|
|
110
|
+
mkdirSync(channelsDir, { recursive: true });
|
|
111
|
+
|
|
112
|
+
const ymlPath = `${channelsDir}/${name}.yml`;
|
|
113
|
+
if (existsSync(ymlPath)) {
|
|
114
|
+
return { ok: false, error: `Channel "${name}" is already installed` };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
writeFileSync(ymlPath, channelYml[name]);
|
|
118
|
+
const channelCaddy = registry.channelCaddy();
|
|
119
|
+
if (name in channelCaddy) {
|
|
120
|
+
writeFileSync(`${channelsDir}/${name}.caddy`, channelCaddy[name]);
|
|
121
|
+
}
|
|
122
|
+
return { ok: true };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Uninstall a channel by removing its .yml (and .caddy) from CONFIG_HOME/channels/.
|
|
127
|
+
*/
|
|
128
|
+
export function uninstallChannel(
|
|
129
|
+
name: string,
|
|
130
|
+
configDir: string
|
|
131
|
+
): { ok: true } | { ok: false; error: string } {
|
|
132
|
+
if (!isValidChannelName(name)) {
|
|
133
|
+
return { ok: false, error: `Invalid channel name: ${name}` };
|
|
134
|
+
}
|
|
135
|
+
const channelsDir = `${configDir}/channels`;
|
|
136
|
+
const ymlPath = `${channelsDir}/${name}.yml`;
|
|
137
|
+
if (!existsSync(ymlPath)) {
|
|
138
|
+
return { ok: false, error: `Channel "${name}" is not installed` };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
rmSync(ymlPath, { force: true });
|
|
142
|
+
rmSync(`${channelsDir}/${name}.caddy`, { force: true });
|
|
143
|
+
return { ok: true };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── Automation Install / Uninstall ──────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
/** Strict automation name: same rules as channel names */
|
|
149
|
+
const AUTOMATION_NAME_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Install an automation from the registry catalog into CONFIG_HOME/automations/.
|
|
153
|
+
*/
|
|
154
|
+
export function installAutomationFromRegistry(
|
|
155
|
+
name: string,
|
|
156
|
+
configDir: string,
|
|
157
|
+
registry: RegistryProvider
|
|
158
|
+
): { ok: true } | { ok: false; error: string } {
|
|
159
|
+
if (!AUTOMATION_NAME_RE.test(name)) {
|
|
160
|
+
return { ok: false, error: `Invalid automation name: ${name}` };
|
|
161
|
+
}
|
|
162
|
+
const automationYml = registry.automationYml();
|
|
163
|
+
if (!(name in automationYml)) {
|
|
164
|
+
return { ok: false, error: `Automation "${name}" not found in registry` };
|
|
165
|
+
}
|
|
166
|
+
const automationsDir = `${configDir}/automations`;
|
|
167
|
+
mkdirSync(automationsDir, { recursive: true });
|
|
168
|
+
|
|
169
|
+
const ymlPath = `${automationsDir}/${name}.yml`;
|
|
170
|
+
if (existsSync(ymlPath)) {
|
|
171
|
+
return { ok: false, error: `Automation "${name}" is already installed` };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
writeFileSync(ymlPath, automationYml[name]);
|
|
175
|
+
return { ok: true };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Uninstall an automation by removing its .yml from CONFIG_HOME/automations/.
|
|
180
|
+
*/
|
|
181
|
+
export function uninstallAutomation(
|
|
182
|
+
name: string,
|
|
183
|
+
configDir: string
|
|
184
|
+
): { ok: true } | { ok: false; error: string } {
|
|
185
|
+
if (!AUTOMATION_NAME_RE.test(name)) {
|
|
186
|
+
return { ok: false, error: `Invalid automation name: ${name}` };
|
|
187
|
+
}
|
|
188
|
+
const automationsDir = `${configDir}/automations`;
|
|
189
|
+
const ymlPath = `${automationsDir}/${name}.yml`;
|
|
190
|
+
if (!existsSync(ymlPath)) {
|
|
191
|
+
return { ok: false, error: `Automation "${name}" is not installed` };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
rmSync(ymlPath, { force: true });
|
|
195
|
+
return { ok: true };
|
|
196
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { mem0BaseUrlConfig, mem0ProviderName } from '../provider-constants.js';
|
|
3
|
+
import type { MemoryConfig } from './memory-config.js';
|
|
4
|
+
import type { CanonicalConnectionProfile } from './types.js';
|
|
5
|
+
|
|
6
|
+
export type OpenCodeConnectionMappingInput = {
|
|
7
|
+
provider: string;
|
|
8
|
+
baseUrl: string;
|
|
9
|
+
systemModel: string;
|
|
10
|
+
smallModel?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type OpenCodeConnectionMapping = {
|
|
14
|
+
provider: string;
|
|
15
|
+
model: string;
|
|
16
|
+
smallModel: string;
|
|
17
|
+
options?: {
|
|
18
|
+
baseURL?: string;
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type Mem0ConnectionMappingInput = {
|
|
23
|
+
llm: {
|
|
24
|
+
provider: string;
|
|
25
|
+
baseUrl: string;
|
|
26
|
+
model: string;
|
|
27
|
+
apiKeyRef: string;
|
|
28
|
+
};
|
|
29
|
+
embedder: {
|
|
30
|
+
provider: string;
|
|
31
|
+
baseUrl: string;
|
|
32
|
+
model: string;
|
|
33
|
+
apiKeyRef: string;
|
|
34
|
+
};
|
|
35
|
+
embeddingDims: number;
|
|
36
|
+
customInstructions: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type Mem0ConnectionMapping = MemoryConfig;
|
|
40
|
+
|
|
41
|
+
export function buildOpenCodeMapping(input: OpenCodeConnectionMappingInput): OpenCodeConnectionMapping {
|
|
42
|
+
const normalizedBaseUrl = input.baseUrl.trim();
|
|
43
|
+
return {
|
|
44
|
+
provider: input.provider,
|
|
45
|
+
model: input.systemModel,
|
|
46
|
+
smallModel: input.smallModel ?? input.systemModel,
|
|
47
|
+
...(normalizedBaseUrl ? { options: { baseURL: normalizedBaseUrl } } : {}),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function writeOpenCodeProviderConfig(
|
|
52
|
+
configDir: string,
|
|
53
|
+
mapping: OpenCodeConnectionMapping,
|
|
54
|
+
): void {
|
|
55
|
+
const assistantDir = `${configDir}/assistant`;
|
|
56
|
+
mkdirSync(assistantDir, { recursive: true });
|
|
57
|
+
|
|
58
|
+
const configPath = `${assistantDir}/opencode.json`;
|
|
59
|
+
|
|
60
|
+
let existing: Record<string, unknown> = { $schema: 'https://opencode.ai/config.json' };
|
|
61
|
+
let raw: string | undefined;
|
|
62
|
+
try {
|
|
63
|
+
raw = readFileSync(configPath, 'utf-8');
|
|
64
|
+
} catch (err) {
|
|
65
|
+
const nodeErr = err as NodeJS.ErrnoException;
|
|
66
|
+
if (nodeErr.code !== 'ENOENT') {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (raw !== undefined) {
|
|
72
|
+
try {
|
|
73
|
+
existing = JSON.parse(raw) as Record<string, unknown>;
|
|
74
|
+
} catch {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const existingProviders =
|
|
80
|
+
(existing as { providers?: Record<string, unknown> }).providers ?? {};
|
|
81
|
+
const existingProviderConfig =
|
|
82
|
+
(existingProviders as Record<string, unknown>)[mapping.provider] ?? {};
|
|
83
|
+
const existingOptions =
|
|
84
|
+
(existingProviderConfig as { options?: Record<string, unknown> }).options ?? {};
|
|
85
|
+
const updatedOptions: Record<string, unknown> = { ...existingOptions };
|
|
86
|
+
const mappingBaseUrl = mapping.options?.baseURL?.trim();
|
|
87
|
+
|
|
88
|
+
if (mappingBaseUrl) {
|
|
89
|
+
updatedOptions.baseURL = mappingBaseUrl;
|
|
90
|
+
} else {
|
|
91
|
+
delete (updatedOptions as { baseURL?: unknown }).baseURL;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const updatedProviderConfig: Record<string, unknown> = {
|
|
95
|
+
...existingProviderConfig as Record<string, unknown>,
|
|
96
|
+
...(Object.keys(updatedOptions).length > 0 ? { options: updatedOptions } : {}),
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const updated = {
|
|
100
|
+
...existing,
|
|
101
|
+
model: mapping.model,
|
|
102
|
+
smallModel: mapping.smallModel,
|
|
103
|
+
providers: {
|
|
104
|
+
...existingProviders,
|
|
105
|
+
[mapping.provider]: updatedProviderConfig,
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
writeFileSync(configPath, JSON.stringify(updated, null, 2) + '\n');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function buildMem0Mapping(input: Mem0ConnectionMappingInput): Mem0ConnectionMapping {
|
|
113
|
+
const llmConfig: Record<string, unknown> = {
|
|
114
|
+
model: input.llm.model,
|
|
115
|
+
temperature: 0.1,
|
|
116
|
+
max_tokens: 2000,
|
|
117
|
+
api_key: input.llm.apiKeyRef,
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const llmBaseUrlConfig = mem0BaseUrlConfig(input.llm.provider, input.llm.baseUrl);
|
|
121
|
+
if (llmBaseUrlConfig) {
|
|
122
|
+
llmConfig[llmBaseUrlConfig.key] = llmBaseUrlConfig.value;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const embedConfig: Record<string, unknown> = {
|
|
126
|
+
model: input.embedder.model,
|
|
127
|
+
api_key: input.embedder.apiKeyRef,
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const embedBaseUrlConfig = mem0BaseUrlConfig(input.embedder.provider, input.embedder.baseUrl);
|
|
131
|
+
if (embedBaseUrlConfig) {
|
|
132
|
+
embedConfig[embedBaseUrlConfig.key] = embedBaseUrlConfig.value;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
mem0: {
|
|
137
|
+
llm: {
|
|
138
|
+
provider: mem0ProviderName(input.llm.provider),
|
|
139
|
+
config: llmConfig,
|
|
140
|
+
},
|
|
141
|
+
embedder: {
|
|
142
|
+
provider: mem0ProviderName(input.embedder.provider),
|
|
143
|
+
config: embedConfig,
|
|
144
|
+
},
|
|
145
|
+
vector_store: {
|
|
146
|
+
provider: 'qdrant',
|
|
147
|
+
config: {
|
|
148
|
+
collection_name: 'memory',
|
|
149
|
+
path: '/data/qdrant',
|
|
150
|
+
embedding_model_dims: input.embeddingDims,
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
memory: {
|
|
155
|
+
custom_instructions: input.customInstructions,
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function resolveApiKeyRef(profile: CanonicalConnectionProfile): string {
|
|
161
|
+
if (profile.auth.mode === 'api_key' && profile.auth.apiKeySecretRef) {
|
|
162
|
+
return profile.auth.apiKeySecretRef;
|
|
163
|
+
}
|
|
164
|
+
return 'not-needed';
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function buildMem0MappingFromProfiles(
|
|
168
|
+
llmProfile: CanonicalConnectionProfile,
|
|
169
|
+
embedProfile: CanonicalConnectionProfile,
|
|
170
|
+
llmModel: string,
|
|
171
|
+
embedModel: string,
|
|
172
|
+
embeddingDims: number,
|
|
173
|
+
customInstructions: string,
|
|
174
|
+
): Mem0ConnectionMapping {
|
|
175
|
+
return buildMem0Mapping({
|
|
176
|
+
llm: {
|
|
177
|
+
provider: llmProfile.provider,
|
|
178
|
+
baseUrl: llmProfile.baseUrl,
|
|
179
|
+
model: llmModel,
|
|
180
|
+
apiKeyRef: resolveApiKeyRef(llmProfile),
|
|
181
|
+
},
|
|
182
|
+
embedder: {
|
|
183
|
+
provider: embedProfile.provider,
|
|
184
|
+
baseUrl: embedProfile.baseUrl,
|
|
185
|
+
model: embedModel,
|
|
186
|
+
apiKeyRef: resolveApiKeyRef(embedProfile),
|
|
187
|
+
},
|
|
188
|
+
embeddingDims,
|
|
189
|
+
customInstructions,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
type ConnectionCompatibilityMode =
|
|
2
|
+
| 'legacy_patch'
|
|
3
|
+
| 'legacy_unified'
|
|
4
|
+
| 'canonical_dto';
|
|
5
|
+
|
|
6
|
+
export type ConnectionMigrationFlags = {
|
|
7
|
+
enabled: boolean;
|
|
8
|
+
dualRead: boolean;
|
|
9
|
+
dualWrite: boolean;
|
|
10
|
+
preferLegacyRead: boolean;
|
|
11
|
+
annotateAudit: boolean;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function envFlag(name: string, fallback: boolean): boolean {
|
|
15
|
+
const raw = process.env[name];
|
|
16
|
+
if (!raw) return fallback;
|
|
17
|
+
return ['1', 'true', 'yes', 'on'].includes(raw.toLowerCase());
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function readConnectionMigrationFlags(): ConnectionMigrationFlags {
|
|
21
|
+
return {
|
|
22
|
+
enabled: envFlag('OPENPALM_CONNECTION_MIGRATION_ENABLED', true),
|
|
23
|
+
dualRead: envFlag('OPENPALM_CONNECTION_MIGRATION_DUAL_READ', true),
|
|
24
|
+
dualWrite: envFlag('OPENPALM_CONNECTION_MIGRATION_DUAL_WRITE', true),
|
|
25
|
+
preferLegacyRead: envFlag('OPENPALM_CONNECTION_MIGRATION_PREFER_LEGACY_READ', false),
|
|
26
|
+
annotateAudit: envFlag('OPENPALM_CONNECTION_MIGRATION_AUDIT_ANNOTATION', true),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function detectConnectionCompatibilityMode(body: Record<string, unknown>): ConnectionCompatibilityMode {
|
|
31
|
+
if (Array.isArray(body.profiles) && typeof body.assignments === 'object' && body.assignments !== null) {
|
|
32
|
+
return 'canonical_dto';
|
|
33
|
+
}
|
|
34
|
+
if (typeof body.provider === 'string') {
|
|
35
|
+
return 'legacy_unified';
|
|
36
|
+
}
|
|
37
|
+
return 'legacy_patch';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type { ConnectionCompatibilityMode };
|