noiton 1.0.0 → 2.0.0

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.
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Cline Agent Adapter
3
+ *
4
+ * Config: VS Code settings.json with cline config block.
5
+ * Format (under `cline` key):
6
+ * "cline.provider": "openai",
7
+ * "cline.openAiBaseUrl": "<BASE_URL>",
8
+ * "cline.openAiApiKey": "<key>",
9
+ * "cline.openAiModelId": "<model>"
10
+ *
11
+ * VS Code stores multiple providers via cline.provider entries.
12
+ */
13
+
14
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
15
+ import { join, dirname } from "node:path";
16
+ import { homedir } from "node:os";
17
+ import JSON5 from "json5";
18
+ import { BASE_URL, PROVIDER_ID } from "../../constants.js";
19
+
20
+ const HOME = homedir();
21
+ const XDG = process.env.XDG_CONFIG_HOME || join(HOME, ".config");
22
+ const CONFIG_PATH = join(XDG, "Code", "User", "settings.json");
23
+
24
+ function readConfig() {
25
+ if (!existsSync(CONFIG_PATH)) return { config: {}, path: CONFIG_PATH };
26
+ try {
27
+ // VS Code settings support JSON with comments (JSONC)
28
+ const raw = readFileSync(CONFIG_PATH, "utf8");
29
+ const cleaned = raw.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
30
+ return { config: JSON5.parse(cleaned), path: CONFIG_PATH };
31
+ } catch {
32
+ return { config: {}, path: CONFIG_PATH };
33
+ }
34
+ }
35
+
36
+ function writeConfig(config) {
37
+ if (!existsSync(dirname(CONFIG_PATH))) {
38
+ mkdirSync(dirname(dirname(CONFIG_PATH)), { recursive: true });
39
+ }
40
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", "utf8");
41
+ }
42
+
43
+ // ============= ADAPTER INTERFACE =============
44
+
45
+ export const clineAgent = {
46
+ id: "cline",
47
+ name: "Cline",
48
+ supportsMultipleModels: true,
49
+
50
+ detect() {
51
+ return existsSync(CONFIG_PATH);
52
+ },
53
+
54
+ isConfigured() {
55
+ const { config } = readConfig();
56
+ const result = Boolean(
57
+ config?.["cline.openAiBaseUrl"] === BASE_URL ||
58
+ config?.["cline.apiProvider"] === "openai" && config?.["cline.openAiApiKey"]
59
+ );
60
+ return result;
61
+ },
62
+
63
+ getStatus() {
64
+ const { config, path } = readConfig();
65
+ const baseUrl = config?.["cline.openAiBaseUrl"];
66
+ const apiKey = config?.["cline.openAiApiKey"];
67
+ const modelId = config?.["cline.openAiModelId"];
68
+ const configured = Boolean(baseUrl === BASE_URL || (apiKey && baseUrl));
69
+
70
+ const models = modelId ? [modelId] : [];
71
+
72
+ return {
73
+ configured,
74
+ path,
75
+ models,
76
+ apiKey: apiKey ? `${apiKey.slice(0, 6)}...${apiKey.slice(-4)}` : null,
77
+ };
78
+ },
79
+
80
+ configure({ apiKey, models, primaryModel }) {
81
+ const { config } = readConfig();
82
+ config["cline.apiProvider"] = "openai";
83
+ config["cline.openAiBaseUrl"] = `${BASE_URL}/v1`;
84
+ config["cline.openAiApiKey"] = apiKey;
85
+ config["cline.openAiModelId"] = primaryModel || models[0]?.id;
86
+ // Save list of available models as a JSON array string
87
+ config["cline.noitonModels"] = JSON.stringify(models.map((m) => m.id));
88
+ writeConfig(config);
89
+ return { path: CONFIG_PATH, modelsCount: 1 };
90
+ },
91
+
92
+ changeKey(apiKey) {
93
+ const { config } = readConfig();
94
+ if (!config?.["cline.openAiApiKey"]) {
95
+ throw new Error("NOITON não está configurado no Cline");
96
+ }
97
+ config["cline.openAiApiKey"] = apiKey;
98
+ writeConfig(config);
99
+ return { path: CONFIG_PATH };
100
+ },
101
+
102
+ refresh(models) {
103
+ const { config } = readConfig();
104
+ if (!config?.["cline.openAiBaseUrl"]) {
105
+ throw new Error("NOITON não está configurado no Cline");
106
+ }
107
+ config["cline.openAiModelId"] = models[0]?.id || config["cline.openAiModelId"];
108
+ config["cline.noitonModels"] = JSON.stringify(models.map((m) => m.id));
109
+ writeConfig(config);
110
+ return { path: CONFIG_PATH, modelsCount: 1 };
111
+ },
112
+
113
+ remove() {
114
+ const { config } = readConfig();
115
+ let removed = false;
116
+ if (config?.["cline.openAiBaseUrl"] === BASE_URL ||
117
+ config?.["cline.openAiApiKey"]) {
118
+ delete config["cline.apiProvider"];
119
+ delete config["cline.openAiBaseUrl"];
120
+ delete config["cline.openAiApiKey"];
121
+ delete config["cline.openAiModelId"];
122
+ delete config["cline.noitonModels"];
123
+ removed = true;
124
+ }
125
+ if (removed) writeConfig(config);
126
+ return { path: CONFIG_PATH, removed };
127
+ },
128
+ };
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Hermes Agent Adapter
3
+ *
4
+ * Config: ~/.hermes/config.yaml (YAML) + ~/.hermes/.env (env vars)
5
+ * Format:
6
+ * model:
7
+ * default: <model-id>
8
+ * provider: custom
9
+ * base_url: <BASE_URL>
10
+ * api_key: ${NOITON_API_KEY}
11
+ * .env: NOITON_API_KEY=<key>
12
+ */
13
+
14
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync } from "node:fs";
15
+ import { join, dirname } from "node:path";
16
+ import { homedir } from "node:os";
17
+ import YAML from "yaml";
18
+ import { BASE_URL } from "../../constants.js";
19
+
20
+ const HOME = homedir();
21
+ const CONFIG_PATH = join(HOME, ".hermes", "config.yaml");
22
+ const ENV_PATH = join(HOME, ".hermes", ".env");
23
+ const ENV_KEY = "NOITON_API_KEY";
24
+
25
+ function readYaml() {
26
+ if (!existsSync(CONFIG_PATH)) return { config: {}, path: CONFIG_PATH };
27
+ try {
28
+ const raw = readFileSync(CONFIG_PATH, "utf8");
29
+ return { config: YAML.parse(raw) || {}, path: CONFIG_PATH };
30
+ } catch {
31
+ return { config: {}, path: CONFIG_PATH };
32
+ }
33
+ }
34
+
35
+ function writeYaml(config) {
36
+ if (!existsSync(dirname(CONFIG_PATH))) {
37
+ mkdirSync(dirname(CONFIG_PATH), { recursive: true });
38
+ }
39
+ writeFileSync(CONFIG_PATH, YAML.stringify(config, { indent: 2 }), "utf8");
40
+ }
41
+
42
+ function readEnv() {
43
+ if (!existsSync(ENV_PATH)) return {};
44
+ const raw = readFileSync(ENV_PATH, "utf8");
45
+ const env = {};
46
+ for (const line of raw.split("\n")) {
47
+ const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
48
+ if (m) env[m[1]] = m[2];
49
+ }
50
+ return env;
51
+ }
52
+
53
+ function writeEnv(env) {
54
+ if (!existsSync(dirname(ENV_PATH))) {
55
+ mkdirSync(dirname(ENV_PATH), { recursive: true });
56
+ }
57
+ const lines = Object.entries(env).map(([k, v]) => `${k}=${v}`);
58
+ writeFileSync(ENV_PATH, lines.join("\n") + "\n", "utf8");
59
+ }
60
+
61
+ function setEnvKey(key, value) {
62
+ const env = readEnv();
63
+ env[key] = value;
64
+ writeEnv(env);
65
+ }
66
+
67
+ function getEnvKey(key) {
68
+ return readEnv()[key] || null;
69
+ }
70
+
71
+ // ============= ADAPTER INTERFACE =============
72
+
73
+ export const hermesAgent = {
74
+ id: "hermes",
75
+ name: "Hermes",
76
+ supportsMultipleModels: false,
77
+
78
+ detect() {
79
+ return existsSync(CONFIG_PATH) || existsSync(ENV_PATH);
80
+ },
81
+
82
+ isConfigured() {
83
+ const { config } = readYaml();
84
+ return Boolean(config?.model?.base_url === BASE_URL || getEnvKey(ENV_KEY));
85
+ },
86
+
87
+ getStatus() {
88
+ const { config, path } = readYaml();
89
+ const apiKey = getEnvKey(ENV_KEY);
90
+ const configured = config?.model?.base_url === BASE_URL || Boolean(apiKey);
91
+ const models = config?.model?.default ? [config.model.default] : [];
92
+ return { configured, path, models, apiKey, envPath: ENV_PATH };
93
+ },
94
+
95
+ configure({ apiKey, models, primaryModel }) {
96
+ const { config } = readYaml();
97
+ config.model = config.model || {};
98
+ config.model.default = primaryModel || models[0]?.id || "glm-5.2";
99
+ config.model.provider = "custom";
100
+ config.model.base_url = BASE_URL;
101
+ config.model.api_key = `\${${ENV_KEY}}`;
102
+ if (models[0]?.context_window) {
103
+ config.model.context_length = models[0].context_window;
104
+ }
105
+ writeYaml(config);
106
+ setEnvKey(ENV_KEY, apiKey);
107
+ return { path: CONFIG_PATH, envPath: ENV_PATH, modelsCount: 1 };
108
+ },
109
+
110
+ changeKey(apiKey) {
111
+ if (!this.isConfigured()) {
112
+ throw new Error("NOITON não está configurado no Hermes");
113
+ }
114
+ setEnvKey(ENV_KEY, apiKey);
115
+ return { path: ENV_PATH };
116
+ },
117
+
118
+ refresh(models) {
119
+ const { config } = readYaml();
120
+ if (!config?.model?.base_url) {
121
+ throw new Error("NOITON não está configurado no Hermes");
122
+ }
123
+ // Hermes uses a single default model - keep current or update to first
124
+ if (models.length > 0 && !config.model.default) {
125
+ config.model.default = models[0].id;
126
+ }
127
+ if (models[0]?.context_window) {
128
+ config.model.context_length = models[0].context_window;
129
+ }
130
+ writeYaml(config);
131
+ return { path: CONFIG_PATH, modelsCount: models.length };
132
+ },
133
+
134
+ remove() {
135
+ const { config } = readYaml();
136
+ let removed = false;
137
+ if (config?.model?.base_url === BASE_URL) {
138
+ delete config.model.base_url;
139
+ delete config.model.api_key;
140
+ delete config.model.provider;
141
+ delete config.model.context_length;
142
+ delete config.model.default;
143
+ if (Object.keys(config.model).length === 0) delete config.model;
144
+ writeYaml(config);
145
+ removed = true;
146
+ }
147
+ // Remove env key
148
+ const env = readEnv();
149
+ if (env[ENV_KEY]) {
150
+ delete env[ENV_KEY];
151
+ writeEnv(env);
152
+ removed = true;
153
+ }
154
+ return { path: CONFIG_PATH, envPath: ENV_PATH, removed };
155
+ },
156
+ };
@@ -0,0 +1,82 @@
1
+ /**
2
+ * NOITON Agent Registry
3
+ *
4
+ * Central registry of all agent adapters. Each adapter implements:
5
+ * { id, name, supportsMultipleModels, detect, isConfigured, getStatus,
6
+ * configure, changeKey, refresh, remove }
7
+ */
8
+
9
+ import { opencodeAgent } from "./opencode.js";
10
+ import { hermesAgent } from "./hermes.js";
11
+ import { openclawAgent } from "./openclaw.js";
12
+ import { clineAgent } from "./cline.js";
13
+ import { aiderAgent } from "./aider.js";
14
+ import { kiloAgent } from "./kilo.js";
15
+ import { kimiAgent } from "./kimi.js";
16
+
17
+ export const REGISTRY = [
18
+ opencodeAgent,
19
+ hermesAgent,
20
+ openclawAgent,
21
+ clineAgent,
22
+ aiderAgent,
23
+ kiloAgent,
24
+ kimiAgent,
25
+ ];
26
+
27
+ /**
28
+ * Get an agent adapter by ID.
29
+ * @param {string} id
30
+ * @returns {object|null}
31
+ */
32
+ export function getAgent(id) {
33
+ return REGISTRY.find((a) => a.id === id) || null;
34
+ }
35
+
36
+ /**
37
+ * Get all agents that are detected as installed.
38
+ * @returns {Array}
39
+ */
40
+ export function getDetectedAgents() {
41
+ return REGISTRY.filter((a) => a.detect());
42
+ }
43
+
44
+ /**
45
+ * Get all agents that have NOITON configured.
46
+ * @returns {Array}
47
+ */
48
+ export function getConfiguredAgents() {
49
+ return REGISTRY.filter((a) => {
50
+ try {
51
+ return a.isConfigured();
52
+ } catch {
53
+ return false;
54
+ }
55
+ });
56
+ }
57
+
58
+ /**
59
+ * Get status of all detected agents.
60
+ * @returns {Array}
61
+ */
62
+ export function getAllStatuses() {
63
+ return REGISTRY.map((a) => {
64
+ try {
65
+ const detected = a.detect();
66
+ const status = detected ? a.getStatus() : null;
67
+ return {
68
+ id: a.id,
69
+ name: a.name,
70
+ detected,
71
+ ...status,
72
+ };
73
+ } catch (err) {
74
+ return {
75
+ id: a.id,
76
+ name: a.name,
77
+ detected: false,
78
+ error: err.message,
79
+ };
80
+ }
81
+ });
82
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Kilo Code Agent Adapter
3
+ *
4
+ * Config: ~/.config/kilo/kilo.jsonc (JSONC)
5
+ * Format (under `provider.openai-compatible`):
6
+ * model: openai-compatible/<model-id>
7
+ * provider.openai-compatible.options.apiKey
8
+ * provider.openai-compatible.options.baseURL
9
+ * provider.openai-compatible.models.<id>.limit.context/output
10
+ */
11
+
12
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
13
+ import { join, dirname } from "node:path";
14
+ import { homedir } from "node:os";
15
+ import JSON5 from "json5";
16
+ import { BASE_URL } from "../../constants.js";
17
+
18
+ const HOME = homedir();
19
+ const XDG = process.env.XDG_CONFIG_HOME || join(HOME, ".config");
20
+ const CONFIG_PATH = join(XDG, "kilo", "kilo.jsonc");
21
+ const PROVIDER_ID = "openai-compatible";
22
+
23
+ function readConfig() {
24
+ if (!existsSync(CONFIG_PATH)) return { config: {}, path: CONFIG_PATH };
25
+ try {
26
+ const raw = readFileSync(CONFIG_PATH, "utf8");
27
+ // Strip comments (JSONC)
28
+ const cleaned = raw.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
29
+ return { config: JSON5.parse(cleaned), path: CONFIG_PATH };
30
+ } catch {
31
+ return { config: {}, path: CONFIG_PATH };
32
+ }
33
+ }
34
+
35
+ function writeConfig(config) {
36
+ if (!existsSync(dirname(CONFIG_PATH))) {
37
+ mkdirSync(dirname(CONFIG_PATH), { recursive: true });
38
+ }
39
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", "utf8");
40
+ }
41
+
42
+ function buildModels(models) {
43
+ const obj = {};
44
+ for (const m of models) {
45
+ if (!m.id) continue;
46
+ obj[m.id] = {
47
+ name: prettifyName(m.id),
48
+ tool_call: true,
49
+ limit: { context: m.context_window || 32768, output: 4096 },
50
+ };
51
+ }
52
+ return obj;
53
+ }
54
+
55
+ function prettifyName(id) {
56
+ return id
57
+ .replace(/[@/]/g, " ")
58
+ .split(/[-_]/)
59
+ .filter(Boolean)
60
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
61
+ .join(" ");
62
+ }
63
+
64
+ // ============= ADAPTER INTERFACE =============
65
+
66
+ export const kiloAgent = {
67
+ id: "kilo",
68
+ name: "Kilo Code",
69
+ supportsMultipleModels: true,
70
+
71
+ detect() {
72
+ return existsSync(CONFIG_PATH);
73
+ },
74
+
75
+ isConfigured() {
76
+ const { config } = readConfig();
77
+ return Boolean(config?.provider?.[PROVIDER_ID]?.options?.baseURL === BASE_URL);
78
+ },
79
+
80
+ getStatus() {
81
+ const { config, path } = readConfig();
82
+ const provider = config?.provider?.[PROVIDER_ID];
83
+ if (!provider) return { configured: false, path, models: [], apiKey: null };
84
+ const apiKey = provider.options?.apiKey;
85
+ const models = provider.models ? Object.keys(provider.models) : [];
86
+ return {
87
+ configured: provider.options?.baseURL === BASE_URL,
88
+ path,
89
+ models,
90
+ apiKey: apiKey ? (apiKey.startsWith("{env:") ? "(env var)" : `${apiKey.slice(0, 6)}...${apiKey.slice(-4)}`) : null,
91
+ };
92
+ },
93
+
94
+ configure({ apiKey, models, primaryModel }) {
95
+ const { config } = readConfig();
96
+ config.model = `${PROVIDER_ID}/${primaryModel || models[0]?.id}`;
97
+ config.provider = config.provider || {};
98
+ config.provider[PROVIDER_ID] = {
99
+ options: {
100
+ apiKey,
101
+ baseURL: `${BASE_URL}/v1`,
102
+ },
103
+ models: buildModels(models),
104
+ };
105
+ writeConfig(config);
106
+ return { path: CONFIG_PATH, modelsCount: models.length };
107
+ },
108
+
109
+ changeKey(apiKey) {
110
+ const { config } = readConfig();
111
+ if (!config?.provider?.[PROVIDER_ID]) {
112
+ throw new Error("NOITON não está configurado no Kilo Code");
113
+ }
114
+ config.provider[PROVIDER_ID].options.apiKey = apiKey;
115
+ writeConfig(config);
116
+ return { path: CONFIG_PATH };
117
+ },
118
+
119
+ refresh(models) {
120
+ const { config } = readConfig();
121
+ if (!config?.provider?.[PROVIDER_ID]) {
122
+ throw new Error("NOITON não está configurado no Kilo Code");
123
+ }
124
+ config.provider[PROVIDER_ID].models = buildModels(models);
125
+ writeConfig(config);
126
+ return { path: CONFIG_PATH, modelsCount: models.length };
127
+ },
128
+
129
+ remove() {
130
+ const { config } = readConfig();
131
+ let removed = false;
132
+ if (config?.provider?.[PROVIDER_ID]) {
133
+ delete config.provider[PROVIDER_ID];
134
+ delete config.model;
135
+ removed = true;
136
+ writeConfig(config);
137
+ }
138
+ return { path: CONFIG_PATH, removed };
139
+ },
140
+ };
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Kimi Code Agent Adapter
3
+ *
4
+ * Config: ~/.kimi-code/config.toml (TOML)
5
+ * Format:
6
+ * [providers.noiton]
7
+ * type = "openai"
8
+ * base_url = "<BASE_URL>/v1"
9
+ * api_key = "<key>"
10
+ *
11
+ * [models."<id>"]
12
+ * provider = "noiton"
13
+ * model = "<id>"
14
+ * max_context_size = <ctx>
15
+ */
16
+
17
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
18
+ import { join, dirname } from "node:path";
19
+ import { homedir } from "node:os";
20
+ import * as TOML from "@iarna/toml";
21
+ import { BASE_URL, PROVIDER_ID } from "../../constants.js";
22
+
23
+ const HOME = homedir();
24
+ const CONFIG_PATH = join(HOME, ".kimi-code", "config.toml");
25
+
26
+ function readConfig() {
27
+ if (!existsSync(CONFIG_PATH)) return { config: {}, path: CONFIG_PATH };
28
+ try {
29
+ const raw = readFileSync(CONFIG_PATH, "utf8");
30
+ return { config: TOML.parse(raw), path: CONFIG_PATH };
31
+ } catch {
32
+ return { config: {}, path: CONFIG_PATH };
33
+ }
34
+ }
35
+
36
+ function writeConfig(config) {
37
+ if (!existsSync(dirname(CONFIG_PATH))) {
38
+ mkdirSync(dirname(CONFIG_PATH), { recursive: true });
39
+ }
40
+ writeFileSync(CONFIG_PATH, TOML.stringify(config), "utf8");
41
+ }
42
+
43
+ // ============= ADAPTER INTERFACE =============
44
+
45
+ export const kimiAgent = {
46
+ id: "kimi",
47
+ name: "Kimi Code",
48
+ supportsMultipleModels: false,
49
+
50
+ detect() {
51
+ return existsSync(CONFIG_PATH);
52
+ },
53
+
54
+ isConfigured() {
55
+ const { config } = readConfig();
56
+ return Boolean(config?.providers?.[PROVIDER_ID]);
57
+ },
58
+
59
+ getStatus() {
60
+ const { config, path } = readConfig();
61
+ const provider = config?.providers?.[PROVIDER_ID];
62
+ if (!provider) return { configured: false, path, models: [], apiKey: null };
63
+ const apiKey = provider.api_key;
64
+ const models = config.models
65
+ ? Object.entries(config.models)
66
+ .filter(([, v]) => v.provider === PROVIDER_ID)
67
+ .map(([k]) => k)
68
+ : [];
69
+ return {
70
+ configured: true,
71
+ path,
72
+ models,
73
+ apiKey: apiKey ? `${apiKey.slice(0, 6)}...${apiKey.slice(-4)}` : null,
74
+ };
75
+ },
76
+
77
+ configure({ apiKey, models, primaryModel }) {
78
+ const { config } = readConfig();
79
+ config.providers = config.providers || {};
80
+ config.providers[PROVIDER_ID] = {
81
+ type: "openai",
82
+ base_url: `${BASE_URL}/v1`,
83
+ api_key: apiKey,
84
+ };
85
+
86
+ config.models = config.models || {};
87
+ const modelId = primaryModel || models[0]?.id || "glm-5.2";
88
+ config.models[modelId] = {
89
+ provider: PROVIDER_ID,
90
+ model: modelId,
91
+ max_context_size: models[0]?.context_window || 32768,
92
+ };
93
+
94
+ writeConfig(config);
95
+ return { path: CONFIG_PATH, modelsCount: 1 };
96
+ },
97
+
98
+ changeKey(apiKey) {
99
+ const { config } = readConfig();
100
+ if (!config?.providers?.[PROVIDER_ID]) {
101
+ throw new Error("NOITON não está configurado no Kimi Code");
102
+ }
103
+ config.providers[PROVIDER_ID].api_key = apiKey;
104
+ writeConfig(config);
105
+ return { path: CONFIG_PATH };
106
+ },
107
+
108
+ refresh(models) {
109
+ const { config } = readConfig();
110
+ if (!config?.providers?.[PROVIDER_ID]) {
111
+ throw new Error("NOITON não está configurado no Kimi Code");
112
+ }
113
+ // Update context size if model exists
114
+ const modelId = Object.keys(config.models || {}).find(
115
+ (k) => config.models[k]?.provider === PROVIDER_ID
116
+ );
117
+ if (modelId && models[0]?.context_window) {
118
+ config.models[modelId].max_context_size = models[0].context_window;
119
+ }
120
+ writeConfig(config);
121
+ return { path: CONFIG_PATH, modelsCount: 1 };
122
+ },
123
+
124
+ remove() {
125
+ const { config } = readConfig();
126
+ let removed = false;
127
+ if (config?.providers?.[PROVIDER_ID]) {
128
+ delete config.providers[PROVIDER_ID];
129
+ removed = true;
130
+ }
131
+ if (config?.models) {
132
+ for (const [k, v] of Object.entries(config.models)) {
133
+ if (v.provider === PROVIDER_ID) {
134
+ delete config.models[k];
135
+ removed = true;
136
+ }
137
+ }
138
+ }
139
+ // Clean up empty top-level tables
140
+ if (config.providers && Object.keys(config.providers).length === 0) {
141
+ delete config.providers;
142
+ }
143
+ if (config.models && Object.keys(config.models).length === 0) {
144
+ delete config.models;
145
+ }
146
+ if (removed) writeConfig(config);
147
+ return { path: CONFIG_PATH, removed };
148
+ },
149
+ };