@miphamai/cli 0.5.7 → 0.5.9
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/bin/mipham.ts +95 -0
- package/package.json +1 -1
- package/src/config/loader.ts +178 -14
- package/src/plugin/plugin-manager.ts +77 -0
- package/src/providers/anthropic.ts +12 -2
- package/src/providers/bootstrap.ts +11 -1
- package/src/providers/openai-compat.ts +17 -4
- package/src/shared/constants.ts +45 -0
- package/src/shared/update.ts +158 -0
- package/src/skills/loader.ts +13 -0
- package/src/skills/registry.ts +325 -0
- package/src/ui/app.tsx +1 -1
- package/src/ui/chat.tsx +11 -1
- package/src/ui/command-picker.tsx +145 -0
- package/src/ui/commands.ts +788 -164
- package/src/ui/input.tsx +95 -26
- package/dist/mipham +0 -0
package/bin/mipham.ts
CHANGED
|
@@ -178,6 +178,97 @@ async function runPluginCLI(): Promise<boolean> {
|
|
|
178
178
|
return true
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
// ── Update self ────────────────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
async function runUpdate(): Promise<boolean> {
|
|
184
|
+
const args = process.argv.slice(2)
|
|
185
|
+
if (args[0] !== 'update' && args[0] !== 'upgrade') return false
|
|
186
|
+
|
|
187
|
+
const { getCurrentVersion, backupConfig, performUpdate, restoreConfig, getConfigPath } =
|
|
188
|
+
await import('../src/shared/update')
|
|
189
|
+
|
|
190
|
+
const { existsSync } = await import('node:fs')
|
|
191
|
+
|
|
192
|
+
const PACKAGE = '@miphamai/cli'
|
|
193
|
+
|
|
194
|
+
const currentVersion = getCurrentVersion()
|
|
195
|
+
console.log(`Mipham Code update`)
|
|
196
|
+
console.log(` Current: v${currentVersion}`)
|
|
197
|
+
console.log(` Checking npm registry for latest version...`)
|
|
198
|
+
console.log()
|
|
199
|
+
|
|
200
|
+
// Check latest version from npm
|
|
201
|
+
let latestVersion = ''
|
|
202
|
+
try {
|
|
203
|
+
const { execSync } = await import('node:child_process')
|
|
204
|
+
const result = execSync(`npm view ${PACKAGE} version --json`, {
|
|
205
|
+
encoding: 'utf-8',
|
|
206
|
+
timeout: 10_000,
|
|
207
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
208
|
+
}).trim()
|
|
209
|
+
latestVersion = result.replace(/"/g, '')
|
|
210
|
+
} catch (err: unknown) {
|
|
211
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
212
|
+
console.log(`✗ Failed to check latest version: ${msg}`)
|
|
213
|
+
console.log(` Check manually: https://www.npmjs.com/package/${PACKAGE}`)
|
|
214
|
+
process.exit(1)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (!latestVersion) {
|
|
218
|
+
console.log('✗ Could not determine latest version.')
|
|
219
|
+
process.exit(1)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
console.log(` Latest: v${latestVersion}`)
|
|
223
|
+
console.log()
|
|
224
|
+
|
|
225
|
+
// Compare versions
|
|
226
|
+
if (currentVersion === latestVersion) {
|
|
227
|
+
console.log(`✓ Already up to date (v${currentVersion})`)
|
|
228
|
+
process.exit(0)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
console.log(`→ New version available: v${currentVersion} → v${latestVersion}`)
|
|
232
|
+
console.log()
|
|
233
|
+
|
|
234
|
+
// Backup config before updating
|
|
235
|
+
const backupPath = backupConfig(`update-v${currentVersion}`)
|
|
236
|
+
if (backupPath) {
|
|
237
|
+
console.log(`✓ Config backed up to: ${backupPath}`)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
console.log()
|
|
241
|
+
console.log(`Updating ${PACKAGE} to v${latestVersion}...`)
|
|
242
|
+
|
|
243
|
+
const ok = performUpdate(latestVersion)
|
|
244
|
+
if (!ok) {
|
|
245
|
+
console.log()
|
|
246
|
+
console.log(`✗ Update failed.`)
|
|
247
|
+
if (backupPath && existsSync(backupPath)) {
|
|
248
|
+
console.log(` Your config backup is at: ${backupPath}`)
|
|
249
|
+
}
|
|
250
|
+
process.exit(1)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Verify config survived
|
|
254
|
+
const configPath = getConfigPath()
|
|
255
|
+
if (existsSync(configPath)) {
|
|
256
|
+
console.log()
|
|
257
|
+
console.log(`✓ Config preserved: ${configPath}`)
|
|
258
|
+
} else if (backupPath && existsSync(backupPath)) {
|
|
259
|
+
console.log()
|
|
260
|
+
console.log('⚠ Config was removed during update. Restoring from backup...')
|
|
261
|
+
if (restoreConfig(backupPath)) {
|
|
262
|
+
console.log(`✓ Config restored.`)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
console.log()
|
|
267
|
+
console.log(`✓ Updated to ${PACKAGE} v${latestVersion}`)
|
|
268
|
+
console.log(` Run 'mipham --version' to verify.`)
|
|
269
|
+
process.exit(0)
|
|
270
|
+
}
|
|
271
|
+
|
|
181
272
|
async function main() {
|
|
182
273
|
// ── Version flag ──────────────────────────────────────────────────────────
|
|
183
274
|
if (
|
|
@@ -190,6 +281,10 @@ async function main() {
|
|
|
190
281
|
process.exit(0)
|
|
191
282
|
}
|
|
192
283
|
|
|
284
|
+
// ── Update / upgrade ──────────────────────────────────────────────────────
|
|
285
|
+
const handledUpdate = await runUpdate()
|
|
286
|
+
if (handledUpdate) return
|
|
287
|
+
|
|
193
288
|
// Check for plugin subcommands first
|
|
194
289
|
const handledPlugin = await runPluginCLI()
|
|
195
290
|
if (handledPlugin) return
|
package/package.json
CHANGED
package/src/config/loader.ts
CHANGED
|
@@ -1,30 +1,194 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
readFileSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
copyFileSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
unlinkSync,
|
|
8
|
+
chmodSync,
|
|
9
|
+
} from 'node:fs'
|
|
10
|
+
import { join, dirname } from 'node:path'
|
|
11
|
+
import { homedir } from 'node:os'
|
|
3
12
|
import { parse as parseYaml } from 'yaml'
|
|
4
|
-
import type { MiphamConfig } from '../shared/index.ts'
|
|
13
|
+
import type { MiphamConfig, ProviderConfig } from '../shared/index.ts'
|
|
5
14
|
import { DEFAULT_CONFIG } from './defaults'
|
|
6
15
|
|
|
16
|
+
const MIPHAM_HOME = join(homedir(), '.mipham')
|
|
17
|
+
const BACKUP_PREFIX = 'config.backup-'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Parse a YAML config file safely. Returns null on any error (missing file, bad syntax, etc).
|
|
21
|
+
* Prints a warning to stderr so the user knows something is wrong.
|
|
22
|
+
*/
|
|
23
|
+
function safeParseYaml(path: string, label: string): Partial<MiphamConfig> | null {
|
|
24
|
+
try {
|
|
25
|
+
if (!existsSync(path)) return null
|
|
26
|
+
const raw = readFileSync(path, 'utf-8')
|
|
27
|
+
return parseYaml(raw) as Partial<MiphamConfig>
|
|
28
|
+
} catch (err: unknown) {
|
|
29
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
30
|
+
process.stderr.write(`⚠ Mipham Code: failed to parse ${label} (${path}): ${msg}\n`)
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Deep-merge providers: for each provider in the user config, only override
|
|
37
|
+
* the fields the user explicitly set (apiKey, baseUrl). All other fields
|
|
38
|
+
* (name, protocol, models, status) come from the base defaults.
|
|
39
|
+
*
|
|
40
|
+
* This prevents users from accidentally losing model definitions when they
|
|
41
|
+
* only want to set their API key.
|
|
42
|
+
*/
|
|
43
|
+
function mergeProviders(
|
|
44
|
+
baseProviders: ProviderConfig[],
|
|
45
|
+
overrideProviders: ProviderConfig[],
|
|
46
|
+
): ProviderConfig[] {
|
|
47
|
+
const merged = [...baseProviders]
|
|
48
|
+
|
|
49
|
+
for (const op of overrideProviders) {
|
|
50
|
+
const idx = merged.findIndex((bp) => bp.id === op.id)
|
|
51
|
+
if (idx === -1) {
|
|
52
|
+
// Provider not in defaults — add it wholesale (custom provider)
|
|
53
|
+
merged.push(op)
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Merge: user overrides only the fields they provide
|
|
58
|
+
const base = merged[idx]!
|
|
59
|
+
merged[idx] = {
|
|
60
|
+
id: base.id,
|
|
61
|
+
name: op.name || base.name,
|
|
62
|
+
protocol: op.protocol || base.protocol,
|
|
63
|
+
baseUrl: op.baseUrl ?? base.baseUrl,
|
|
64
|
+
apiKey: op.apiKey ?? base.apiKey,
|
|
65
|
+
models: op.models?.length ? op.models : base.models,
|
|
66
|
+
status: op.status ?? base.status,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return merged
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function mergeConfig(base: MiphamConfig, override: Partial<MiphamConfig>): MiphamConfig {
|
|
74
|
+
const merged: MiphamConfig = { ...base, ...override }
|
|
75
|
+
if (override.providers) {
|
|
76
|
+
merged.providers = mergeProviders(base.providers, override.providers)
|
|
77
|
+
} else {
|
|
78
|
+
merged.providers = base.providers
|
|
79
|
+
}
|
|
80
|
+
return merged
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Save a timestamped backup of config.yml to ~/.mipham/.
|
|
85
|
+
* Keeps at most 5 backups; older ones are pruned.
|
|
86
|
+
*/
|
|
87
|
+
function backupConfig(configPath: string): void {
|
|
88
|
+
try {
|
|
89
|
+
if (!existsSync(configPath)) return
|
|
90
|
+
mkdirSync(MIPHAM_HOME, { recursive: true, mode: 0o700 })
|
|
91
|
+
|
|
92
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-')
|
|
93
|
+
const backupPath = join(MIPHAM_HOME, `${BACKUP_PREFIX}${ts}.yml`)
|
|
94
|
+
copyFileSync(configPath, backupPath)
|
|
95
|
+
chmodSync(backupPath, 0o600) // owner read/write only — contains API keys
|
|
96
|
+
|
|
97
|
+
// Prune old backups (keep last 5)
|
|
98
|
+
const files = readdirSync(MIPHAM_HOME)
|
|
99
|
+
.filter((f) => f.startsWith(BACKUP_PREFIX) && f.endsWith('.yml'))
|
|
100
|
+
.sort()
|
|
101
|
+
while (files.length > 5) {
|
|
102
|
+
const old = files.shift()!
|
|
103
|
+
try {
|
|
104
|
+
unlinkSync(join(MIPHAM_HOME, old))
|
|
105
|
+
} catch {
|
|
106
|
+
// best-effort cleanup
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
} catch {
|
|
110
|
+
// best-effort; never crash because backup failed
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Try to restore config from the most recent backup.
|
|
116
|
+
* Returns true if restored successfully.
|
|
117
|
+
*/
|
|
118
|
+
function tryRestoreFromBackup(configPath: string): boolean {
|
|
119
|
+
try {
|
|
120
|
+
if (!existsSync(MIPHAM_HOME)) return false
|
|
121
|
+
const files = readdirSync(MIPHAM_HOME)
|
|
122
|
+
.filter((f) => f.startsWith(BACKUP_PREFIX) && f.endsWith('.yml'))
|
|
123
|
+
.sort()
|
|
124
|
+
.reverse() // newest first
|
|
125
|
+
|
|
126
|
+
if (files.length === 0) return false
|
|
127
|
+
|
|
128
|
+
const latestBackup = join(MIPHAM_HOME, files[0]!)
|
|
129
|
+
copyFileSync(latestBackup, configPath)
|
|
130
|
+
process.stderr.write(`⚠ Mipham Code: restored config from backup (${files[0]})\n`)
|
|
131
|
+
return true
|
|
132
|
+
} catch (err: unknown) {
|
|
133
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
134
|
+
process.stderr.write(`⚠ Mipham Code: failed to restore config from backup: ${msg}\n`)
|
|
135
|
+
return false
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
7
139
|
export function loadConfig(cwd: string = process.cwd()): MiphamConfig {
|
|
8
140
|
const configPath = join(cwd, '.mipham', 'config.yml')
|
|
9
|
-
const userConfigPath = join(
|
|
141
|
+
const userConfigPath = join(MIPHAM_HOME, 'config.yml')
|
|
10
142
|
|
|
11
143
|
let config = { ...DEFAULT_CONFIG }
|
|
12
144
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
145
|
+
// ── Load project-level config ──
|
|
146
|
+
const projectConfig = safeParseYaml(configPath, 'project config')
|
|
147
|
+
if (projectConfig) {
|
|
16
148
|
config = mergeConfig(config, projectConfig)
|
|
149
|
+
} else if (existsSync(configPath)) {
|
|
150
|
+
// File exists but failed to parse — try to restore from backup
|
|
151
|
+
process.stderr.write(`⚠ Mipham Code: project config is corrupted, attempting recovery...\n`)
|
|
152
|
+
if (!tryRestoreFromBackup(configPath)) {
|
|
153
|
+
process.stderr.write(`⚠ Mipham Code: no backup available for project config. Skipping.\n`)
|
|
154
|
+
} else {
|
|
155
|
+
// Retry parsing after restore
|
|
156
|
+
const restored = safeParseYaml(configPath, 'restored project config')
|
|
157
|
+
if (restored) {
|
|
158
|
+
config = mergeConfig(config, restored)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
17
161
|
}
|
|
18
162
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
163
|
+
// ── Load user-level config ──
|
|
164
|
+
const userConfig = safeParseYaml(userConfigPath, 'user config')
|
|
165
|
+
if (userConfig) {
|
|
22
166
|
config = mergeConfig(config, userConfig)
|
|
167
|
+
} else if (existsSync(userConfigPath)) {
|
|
168
|
+
// File exists but failed to parse — try to restore from backup
|
|
169
|
+
process.stderr.write(`⚠ Mipham Code: user config is corrupted, attempting recovery...\n`)
|
|
170
|
+
if (!tryRestoreFromBackup(userConfigPath)) {
|
|
171
|
+
process.stderr.write(`⚠ Mipham Code: no backup available for user config. Skipping.\n`)
|
|
172
|
+
} else {
|
|
173
|
+
// Retry parsing after restore
|
|
174
|
+
const restored = safeParseYaml(userConfigPath, 'restored user config')
|
|
175
|
+
if (restored) {
|
|
176
|
+
config = mergeConfig(config, restored)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
} else {
|
|
180
|
+
// No user config yet — create the directory so it's ready
|
|
181
|
+
try {
|
|
182
|
+
mkdirSync(MIPHAM_HOME, { recursive: true })
|
|
183
|
+
} catch {
|
|
184
|
+
// best-effort
|
|
185
|
+
}
|
|
23
186
|
}
|
|
24
187
|
|
|
25
|
-
|
|
26
|
-
|
|
188
|
+
// ── Auto-backup: save a copy of the user config if it loaded successfully ──
|
|
189
|
+
if (userConfig) {
|
|
190
|
+
backupConfig(userConfigPath)
|
|
191
|
+
}
|
|
27
192
|
|
|
28
|
-
|
|
29
|
-
return { ...base, ...override, providers: override.providers ?? base.providers }
|
|
193
|
+
return config
|
|
30
194
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mkdirSync, existsSync, writeFileSync, readFileSync, readdirSync, rmSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import { homedir } from 'node:os'
|
|
4
|
+
import { execSync } from 'node:child_process'
|
|
4
5
|
import { validatePlugin } from './plugin-validator'
|
|
5
6
|
|
|
6
7
|
const PLUGIN_DIR = join(homedir(), '.mipham', 'plugins')
|
|
@@ -58,6 +59,82 @@ export class PluginManager {
|
|
|
58
59
|
}
|
|
59
60
|
}
|
|
60
61
|
|
|
62
|
+
/** Validate npm package name against the npm spec. */
|
|
63
|
+
private isValidPackageName(name: string): boolean {
|
|
64
|
+
// npm package name regex: optional @scope/ + package name
|
|
65
|
+
return /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/.test(name)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Install a plugin from npm (e.g. `npm install <package>` into the plugins dir).
|
|
70
|
+
* The npm package must export a valid plugin manifest at its root.
|
|
71
|
+
*/
|
|
72
|
+
installFromNpm(packageName: string): { success: boolean; message: string } {
|
|
73
|
+
if (!this.isValidPackageName(packageName)) {
|
|
74
|
+
return { success: false, message: `Invalid package name: "${packageName}"` }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Derive plugin name from npm package name (strip scope prefix)
|
|
78
|
+
const pluginName = packageName.replace(/^@.+\//, '').replace(/^mipham-plugin-/, '')
|
|
79
|
+
|
|
80
|
+
const destDir = join(this.pluginDir, pluginName)
|
|
81
|
+
if (existsSync(destDir)) {
|
|
82
|
+
return {
|
|
83
|
+
success: false,
|
|
84
|
+
message: `Plugin "${pluginName}" is already installed`,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
// Install the npm package into the plugins directory
|
|
90
|
+
mkdirSync(destDir, { recursive: true })
|
|
91
|
+
|
|
92
|
+
// Create a minimal package.json so npm install works in a subdirectory
|
|
93
|
+
writeFileSync(
|
|
94
|
+
join(destDir, 'package.json'),
|
|
95
|
+
JSON.stringify({ private: true }, null, 2),
|
|
96
|
+
'utf-8',
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
execSync(`npm install ${packageName} --prefix "${destDir}" --no-save`, {
|
|
100
|
+
encoding: 'utf-8',
|
|
101
|
+
stdio: 'pipe',
|
|
102
|
+
timeout: 60_000,
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
// Validate the installed plugin
|
|
106
|
+
const validation = validatePlugin(destDir)
|
|
107
|
+
if (!validation.valid) {
|
|
108
|
+
// Clean up on invalid plugin
|
|
109
|
+
rmSync(destDir, { recursive: true, force: true })
|
|
110
|
+
return { success: false, message: validation.errors.join('; ') }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
this.plugins.push({
|
|
114
|
+
name: validation.manifest?.name || pluginName,
|
|
115
|
+
version: validation.manifest?.version || '0.0.0',
|
|
116
|
+
path: destDir,
|
|
117
|
+
enabled: true,
|
|
118
|
+
installedAt: new Date().toISOString(),
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
this.saveState()
|
|
122
|
+
return {
|
|
123
|
+
success: true,
|
|
124
|
+
message: `Plugin "${pluginName}" installed from npm`,
|
|
125
|
+
}
|
|
126
|
+
} catch (err: unknown) {
|
|
127
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
128
|
+
// Clean up on failure
|
|
129
|
+
try {
|
|
130
|
+
rmSync(destDir, { recursive: true, force: true })
|
|
131
|
+
} catch {
|
|
132
|
+
/* ok */
|
|
133
|
+
}
|
|
134
|
+
return { success: false, message: `npm install failed: ${msg}` }
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
61
138
|
list(): InstalledPlugin[] {
|
|
62
139
|
return [...this.plugins]
|
|
63
140
|
}
|
|
@@ -278,9 +278,19 @@ export class AnthropicProvider implements ProviderInstance {
|
|
|
278
278
|
}
|
|
279
279
|
|
|
280
280
|
private resolveApiKey(keyTemplate: string): string {
|
|
281
|
-
|
|
281
|
+
// Accept both ${VAR} and $VAR syntax
|
|
282
|
+
let match = keyTemplate.match(/^\$\{(.+)\}$/)
|
|
283
|
+
if (!match) match = keyTemplate.match(/^\$([A-Z_][A-Z0-9_]*)$/)
|
|
282
284
|
if (match?.[1]) {
|
|
283
|
-
|
|
285
|
+
const varName = match[1]
|
|
286
|
+
const value = process.env[varName]
|
|
287
|
+
if (!value) {
|
|
288
|
+
process.stderr.write(
|
|
289
|
+
`⚠ Anthropic provider: apiKey references $${varName} but that environment variable is not set\n`,
|
|
290
|
+
)
|
|
291
|
+
return ''
|
|
292
|
+
}
|
|
293
|
+
return value
|
|
284
294
|
}
|
|
285
295
|
return keyTemplate
|
|
286
296
|
}
|
|
@@ -13,8 +13,18 @@ export function bootstrapProviders(
|
|
|
13
13
|
for (const config of configs) {
|
|
14
14
|
if (config.status === 'upcoming') continue
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
// Warn if apiKey is empty (will cause API errors at runtime)
|
|
17
|
+
if (!config.apiKey || config.apiKey.trim() === '') {
|
|
18
|
+
process.stderr.write(
|
|
19
|
+
`⚠ Provider "${config.name}" (${config.id}): apiKey not set. ` +
|
|
20
|
+
`Set it in ~/.mipham/config.yml or via environment variable.\n`,
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const protocol = config.protocol?.toLowerCase()
|
|
25
|
+
switch (protocol) {
|
|
17
26
|
case 'openai-compatible':
|
|
27
|
+
case 'openai-compat': // common user typo
|
|
18
28
|
registry.register(config.id, new OpenAICompatProvider(config))
|
|
19
29
|
break
|
|
20
30
|
case 'anthropic':
|
|
@@ -6,7 +6,9 @@ export class OpenAICompatProvider implements ProviderInstance {
|
|
|
6
6
|
constructor(public config: ProviderConfig) {}
|
|
7
7
|
|
|
8
8
|
async *chat(req: ChatRequest): AsyncGenerator<StreamChunk> {
|
|
9
|
-
|
|
9
|
+
// Accept both baseUrl and baseURL (common YAML typo)
|
|
10
|
+
const rawBase = (this.config as any).baseUrl || (this.config as any).baseURL
|
|
11
|
+
const baseUrl = rawBase?.replace(/\/+$/, '') || 'https://api.openai.com/v1'
|
|
10
12
|
const apiKey = this.resolveApiKey(this.config.apiKey)
|
|
11
13
|
|
|
12
14
|
const body = {
|
|
@@ -136,7 +138,8 @@ export class OpenAICompatProvider implements ProviderInstance {
|
|
|
136
138
|
|
|
137
139
|
async healthCheck(): Promise<boolean> {
|
|
138
140
|
try {
|
|
139
|
-
const
|
|
141
|
+
const rawBase = (this.config as any).baseUrl || (this.config as any).baseURL
|
|
142
|
+
const baseUrl = rawBase?.replace(/\/+$/, '') || 'https://api.openai.com/v1'
|
|
140
143
|
const apiKey = this.resolveApiKey(this.config.apiKey)
|
|
141
144
|
const res = await fetch(`${baseUrl}/models`, {
|
|
142
145
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
@@ -187,9 +190,19 @@ export class OpenAICompatProvider implements ProviderInstance {
|
|
|
187
190
|
}
|
|
188
191
|
|
|
189
192
|
private resolveApiKey(keyTemplate: string): string {
|
|
190
|
-
|
|
193
|
+
// Accept both ${VAR} and $VAR syntax
|
|
194
|
+
let match = keyTemplate.match(/^\$\{(.+)\}$/)
|
|
195
|
+
if (!match) match = keyTemplate.match(/^\$([A-Z_][A-Z0-9_]*)$/)
|
|
191
196
|
if (match?.[1]) {
|
|
192
|
-
|
|
197
|
+
const varName = match[1]
|
|
198
|
+
const value = process.env[varName]
|
|
199
|
+
if (!value) {
|
|
200
|
+
process.stderr.write(
|
|
201
|
+
`⚠ OpenAI-compatible provider: apiKey references $${varName} but that environment variable is not set\n`,
|
|
202
|
+
)
|
|
203
|
+
return ''
|
|
204
|
+
}
|
|
205
|
+
return value
|
|
193
206
|
}
|
|
194
207
|
return keyTemplate
|
|
195
208
|
}
|
package/src/shared/constants.ts
CHANGED
|
@@ -244,6 +244,51 @@ export const DEFAULT_PROVIDERS: ProviderConfig[] = [
|
|
|
244
244
|
},
|
|
245
245
|
],
|
|
246
246
|
},
|
|
247
|
+
{
|
|
248
|
+
id: 'kimi',
|
|
249
|
+
name: 'Kimi (月之暗面)',
|
|
250
|
+
protocol: 'openai-compatible',
|
|
251
|
+
baseUrl: 'https://api.moonshot.cn/v1',
|
|
252
|
+
apiKey: '${KIMI_API_KEY}',
|
|
253
|
+
models: [
|
|
254
|
+
{
|
|
255
|
+
id: 'kimi-latest',
|
|
256
|
+
name: 'Kimi Latest',
|
|
257
|
+
providerId: 'kimi',
|
|
258
|
+
contextWindow: 128_000,
|
|
259
|
+
maxOutput: 16_000,
|
|
260
|
+
vision: true,
|
|
261
|
+
status: 'active',
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
id: 'moonshot-v1-8k',
|
|
265
|
+
name: 'Moonshot v1 8K',
|
|
266
|
+
providerId: 'kimi',
|
|
267
|
+
contextWindow: 8_000,
|
|
268
|
+
maxOutput: 4_000,
|
|
269
|
+
vision: false,
|
|
270
|
+
status: 'active',
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
id: 'moonshot-v1-32k',
|
|
274
|
+
name: 'Moonshot v1 32K',
|
|
275
|
+
providerId: 'kimi',
|
|
276
|
+
contextWindow: 32_000,
|
|
277
|
+
maxOutput: 4_000,
|
|
278
|
+
vision: false,
|
|
279
|
+
status: 'active',
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
id: 'moonshot-v1-128k',
|
|
283
|
+
name: 'Moonshot v1 128K',
|
|
284
|
+
providerId: 'kimi',
|
|
285
|
+
contextWindow: 128_000,
|
|
286
|
+
maxOutput: 4_000,
|
|
287
|
+
vision: false,
|
|
288
|
+
status: 'active',
|
|
289
|
+
},
|
|
290
|
+
],
|
|
291
|
+
},
|
|
247
292
|
{
|
|
248
293
|
id: 'mipham',
|
|
249
294
|
name: 'MiphamAI',
|