@nvae/llmswitch 0.2.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,40 @@
1
+ export function isOpenAiApiFormat(format) {
2
+ return format === "openai-chat" || format === "openai-responses";
3
+ }
4
+ /**
5
+ * Ensure an OpenAI-compatible base URL ends with exactly one `/v1`
6
+ * (no trailing slash after it).
7
+ *
8
+ * Examples:
9
+ * - `http://host:8000` → `http://host:8000/v1`
10
+ * - `http://host:8000/` → `http://host:8000/v1`
11
+ * - `http://host:8000/v1` → `http://host:8000/v1`
12
+ * - `http://host:8000/v1/` → `http://host:8000/v1`
13
+ * - `http://host:8000/v1/v1` → `http://host:8000/v1`
14
+ * - `https://api.openai.com/v1/v1/` → `https://api.openai.com/v1`
15
+ */
16
+ export function ensureOpenAiV1BaseUrl(baseUrl) {
17
+ let base = baseUrl.trim();
18
+ if (!base)
19
+ return base;
20
+ base = base.replace(/\/+$/, "");
21
+ while (/\/v1$/i.test(base)) {
22
+ base = base.replace(/\/v1$/i, "").replace(/\/+$/, "");
23
+ }
24
+ if (!base)
25
+ return "/v1";
26
+ return `${base}/v1`;
27
+ }
28
+ /**
29
+ * Normalize base URL for the given API format.
30
+ * OpenAI formats always get a single trailing `/v1`; others only trim trailing slashes.
31
+ */
32
+ export function normalizeBaseUrlForFormat(apiFormat, baseUrl) {
33
+ const trimmed = baseUrl.trim().replace(/\/+$/, "");
34
+ if (!trimmed)
35
+ return trimmed;
36
+ if (isOpenAiApiFormat(apiFormat)) {
37
+ return ensureOpenAiV1BaseUrl(trimmed);
38
+ }
39
+ return trimmed;
40
+ }
@@ -0,0 +1,177 @@
1
+ import { emptyProxy } from "../types.js";
2
+ import { normalizeBaseUrlForFormat } from "./base-url.js";
3
+ import { buildProxyEnv } from "./proxy.js";
4
+ /**
5
+ * Derive the API base URL from a successful .../models endpoint.
6
+ */
7
+ export function baseUrlFromModelsEndpoint(endpoint) {
8
+ const cleaned = endpoint.trim().replace(/\/+$/, "");
9
+ const match = cleaned.match(/^(.*)\/models$/i);
10
+ return match?.[1] || null;
11
+ }
12
+ /**
13
+ * Prefer the base URL that actually served /models when it differs from input.
14
+ * Common case: user enters http://host:8000 but only /v1/models works.
15
+ */
16
+ export function preferResolvedBaseUrl(inputBaseUrl, resolvedBaseUrl) {
17
+ const input = inputBaseUrl.trim().replace(/\/+$/, "");
18
+ const resolved = (resolvedBaseUrl || "").trim().replace(/\/+$/, "");
19
+ if (!resolved)
20
+ return input;
21
+ if (resolved === input)
22
+ return input;
23
+ return resolved;
24
+ }
25
+ /**
26
+ * Resolve candidate /models URLs for a provider base URL.
27
+ */
28
+ export function modelListEndpoints(baseUrl) {
29
+ const base = baseUrl.trim().replace(/\/+$/, "");
30
+ const endpoints = [];
31
+ const add = (url) => {
32
+ if (!endpoints.includes(url))
33
+ endpoints.push(url);
34
+ };
35
+ if (/\/v1$/i.test(base)) {
36
+ add(`${base}/models`);
37
+ }
38
+ else {
39
+ add(`${base}/models`);
40
+ add(`${base}/v1/models`);
41
+ }
42
+ // Common gateway: .../anthropic → also try sibling /v1/models
43
+ if (/\/anthropic$/i.test(base)) {
44
+ add(`${base.replace(/\/anthropic$/i, "")}/v1/models`);
45
+ }
46
+ return endpoints;
47
+ }
48
+ export function buildModelsRequestHeaders(apiFormat, apiKey) {
49
+ const headers = {
50
+ Accept: "application/json",
51
+ };
52
+ if (!apiKey)
53
+ return headers;
54
+ if (apiFormat === "anthropic") {
55
+ headers["x-api-key"] = apiKey;
56
+ headers["anthropic-version"] = "2023-06-01";
57
+ // Some gateways also accept Bearer
58
+ headers.Authorization = `Bearer ${apiKey}`;
59
+ }
60
+ else {
61
+ headers.Authorization = `Bearer ${apiKey}`;
62
+ }
63
+ return headers;
64
+ }
65
+ export function parseModelIds(payload) {
66
+ if (!payload || typeof payload !== "object")
67
+ return [];
68
+ const root = payload;
69
+ const buckets = [];
70
+ if (Array.isArray(root.data))
71
+ buckets.push(...root.data);
72
+ if (Array.isArray(root.models))
73
+ buckets.push(...root.models);
74
+ if (Array.isArray(payload))
75
+ buckets.push(...payload);
76
+ const ids = new Set();
77
+ for (const item of buckets) {
78
+ if (typeof item === "string" && item.trim()) {
79
+ ids.add(item.trim());
80
+ continue;
81
+ }
82
+ if (!item || typeof item !== "object")
83
+ continue;
84
+ const row = item;
85
+ const id = row.id ?? row.name ?? row.model;
86
+ if (typeof id === "string" && id.trim())
87
+ ids.add(id.trim());
88
+ }
89
+ return Array.from(ids).sort((a, b) => a.localeCompare(b));
90
+ }
91
+ /**
92
+ * Fetch available model IDs from the provider using baseUrl + apiKey.
93
+ * Tries several common /models paths; uses proxy env when configured.
94
+ */
95
+ export async function fetchModelList(options) {
96
+ const { baseUrl, apiKey, apiFormat, proxy, timeoutMs = 20_000 } = options;
97
+ if (!baseUrl?.trim()) {
98
+ throw new Error("Base URL 为空,无法拉取模型列表");
99
+ }
100
+ if (!apiKey?.trim()) {
101
+ throw new Error("API Key 为空,无法拉取模型列表");
102
+ }
103
+ const effectiveBaseUrl = normalizeBaseUrlForFormat(apiFormat, baseUrl);
104
+ const endpoints = modelListEndpoints(effectiveBaseUrl);
105
+ const headers = buildModelsRequestHeaders(apiFormat, apiKey.trim());
106
+ const restore = applyProxyEnv(proxy);
107
+ const errors = [];
108
+ try {
109
+ for (const endpoint of endpoints) {
110
+ try {
111
+ const models = await requestModels(endpoint, headers, timeoutMs);
112
+ if (models.length > 0) {
113
+ const resolvedBaseUrl = baseUrlFromModelsEndpoint(endpoint) || effectiveBaseUrl;
114
+ return {
115
+ models,
116
+ endpoint,
117
+ resolvedBaseUrl: normalizeBaseUrlForFormat(apiFormat, resolvedBaseUrl),
118
+ };
119
+ }
120
+ errors.push(`${endpoint} → 返回空列表`);
121
+ }
122
+ catch (err) {
123
+ const msg = err instanceof Error ? err.message : String(err);
124
+ errors.push(`${endpoint} → ${msg}`);
125
+ }
126
+ }
127
+ }
128
+ finally {
129
+ restore();
130
+ }
131
+ throw new Error(`无法从接口拉取模型列表。\n${errors.map((e) => ` - ${e}`).join("\n")}`);
132
+ }
133
+ async function requestModels(endpoint, headers, timeoutMs) {
134
+ const controller = new AbortController();
135
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
136
+ try {
137
+ const res = await fetch(endpoint, {
138
+ method: "GET",
139
+ headers,
140
+ signal: controller.signal,
141
+ });
142
+ if (!res.ok) {
143
+ const body = (await res.text().catch(() => "")).slice(0, 200);
144
+ throw new Error(`HTTP ${res.status}${body ? `: ${body}` : ""}`);
145
+ }
146
+ const json = await res.json();
147
+ return parseModelIds(json);
148
+ }
149
+ catch (err) {
150
+ if (err instanceof Error && err.name === "AbortError") {
151
+ throw new Error(`请求超时(${timeoutMs}ms)`);
152
+ }
153
+ throw err;
154
+ }
155
+ finally {
156
+ clearTimeout(timer);
157
+ }
158
+ }
159
+ function applyProxyEnv(proxy) {
160
+ if (emptyProxy(proxy))
161
+ return () => undefined;
162
+ const next = buildProxyEnv(proxy);
163
+ const keys = Object.keys(next);
164
+ const backup = new Map();
165
+ for (const key of keys) {
166
+ backup.set(key, process.env[key]);
167
+ process.env[key] = next[key];
168
+ }
169
+ return () => {
170
+ for (const [key, value] of backup) {
171
+ if (value === undefined)
172
+ delete process.env[key];
173
+ else
174
+ process.env[key] = value;
175
+ }
176
+ };
177
+ }
@@ -0,0 +1,40 @@
1
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, renameSync, writeFileSync, } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { randomBytes } from "node:crypto";
4
+ export function ensureDir(dir) {
5
+ mkdirSync(dir, { recursive: true });
6
+ }
7
+ export function atomicWriteFile(filePath, content, mode = 0o600) {
8
+ ensureDir(dirname(filePath));
9
+ const tmp = join(dirname(filePath), `.${randomBytes(8).toString("hex")}.tmp`);
10
+ writeFileSync(tmp, content, { encoding: "utf8", mode });
11
+ try {
12
+ chmodSync(tmp, mode);
13
+ }
14
+ catch {
15
+ // Windows may ignore mode; continue.
16
+ }
17
+ renameSync(tmp, filePath);
18
+ try {
19
+ chmodSync(filePath, mode);
20
+ }
21
+ catch {
22
+ // ignore
23
+ }
24
+ }
25
+ export function backupFile(sourcePath, backupDir, label) {
26
+ if (!existsSync(sourcePath))
27
+ return undefined;
28
+ ensureDir(backupDir);
29
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
30
+ const dest = join(backupDir, `${label}-${stamp}.bak`);
31
+ copyFileSync(sourcePath, dest);
32
+ return dest;
33
+ }
34
+ export function maskSecret(value) {
35
+ if (!value)
36
+ return "(empty)";
37
+ if (value.length <= 8)
38
+ return "****";
39
+ return `${value.slice(0, 4)}…${value.slice(-4)}`;
40
+ }
@@ -0,0 +1,67 @@
1
+ import { homedir, platform } from "node:os";
2
+ import { join } from "node:path";
3
+ export function getAppConfigRoot() {
4
+ if (process.env.LLM_SWITCH_HOME) {
5
+ return process.env.LLM_SWITCH_HOME;
6
+ }
7
+ if (platform() === "win32") {
8
+ const base = process.env.APPDATA || join(homedir(), "AppData", "Roaming");
9
+ return join(base, "llm-switch");
10
+ }
11
+ const xdg = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
12
+ return join(xdg, "llm-switch");
13
+ }
14
+ export function getToolStoreDir(tool) {
15
+ return join(getAppConfigRoot(), tool);
16
+ }
17
+ export function getProfilesDir(tool) {
18
+ return join(getToolStoreDir(tool), "profiles");
19
+ }
20
+ export function getProfilePath(tool, name) {
21
+ return join(getProfilesDir(tool), `${name}.json`);
22
+ }
23
+ export function getStatePath(tool) {
24
+ return join(getToolStoreDir(tool), "state.json");
25
+ }
26
+ export function getBackupsDir(tool) {
27
+ return join(getToolStoreDir(tool), "backups");
28
+ }
29
+ export function getClaudeConfigDir() {
30
+ return process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
31
+ }
32
+ export function getClaudeSettingsPath() {
33
+ return join(getClaudeConfigDir(), "settings.json");
34
+ }
35
+ export function getCodexHome() {
36
+ return process.env.CODEX_HOME || join(homedir(), ".codex");
37
+ }
38
+ export function getCodexConfigPath() {
39
+ return join(getCodexHome(), "config.toml");
40
+ }
41
+ export function getCodexAuthPath() {
42
+ return join(getCodexHome(), "auth.json");
43
+ }
44
+ export function getCodexEnvPath() {
45
+ return join(getCodexHome(), ".env");
46
+ }
47
+ export function getOpenCodeConfigDir() {
48
+ if (process.env.OPENCODE_CONFIG_DIR) {
49
+ return process.env.OPENCODE_CONFIG_DIR;
50
+ }
51
+ const xdg = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
52
+ return join(xdg, "opencode");
53
+ }
54
+ export function getOpenCodeConfigPath() {
55
+ return join(getOpenCodeConfigDir(), "opencode.json");
56
+ }
57
+ export function getOpenCodeAuthPath() {
58
+ if (process.env.OPENCODE_DATA_DIR) {
59
+ return join(process.env.OPENCODE_DATA_DIR, "auth.json");
60
+ }
61
+ if (platform() === "win32") {
62
+ const base = process.env.LOCALAPPDATA || join(homedir(), "AppData", "Local");
63
+ return join(base, "opencode", "auth.json");
64
+ }
65
+ const data = process.env.XDG_DATA_HOME || join(homedir(), ".local", "share");
66
+ return join(data, "opencode", "auth.json");
67
+ }
@@ -0,0 +1,68 @@
1
+ import { emptyProxy } from "../types.js";
2
+ export const PROXY_ENV_KEYS = [
3
+ "HTTP_PROXY",
4
+ "HTTPS_PROXY",
5
+ "ALL_PROXY",
6
+ "http_proxy",
7
+ "https_proxy",
8
+ "all_proxy",
9
+ ];
10
+ /**
11
+ * Build proxy env vars for injection into tool configs.
12
+ * Prefer explicit http/https; when only `all` is set (e.g. socks5h),
13
+ * set ALL_PROXY (and lowercase) and also mirror to HTTP(S)_PROXY
14
+ * so runtimes that only read those still attempt the proxy URL.
15
+ */
16
+ export function buildProxyEnv(proxy) {
17
+ if (emptyProxy(proxy))
18
+ return {};
19
+ const env = {};
20
+ const http = proxy.http?.trim();
21
+ const https = proxy.https?.trim();
22
+ const all = proxy.all?.trim();
23
+ if (all) {
24
+ env.ALL_PROXY = all;
25
+ env.all_proxy = all;
26
+ }
27
+ if (http) {
28
+ env.HTTP_PROXY = http;
29
+ env.http_proxy = http;
30
+ }
31
+ else if (all) {
32
+ env.HTTP_PROXY = all;
33
+ env.http_proxy = all;
34
+ }
35
+ if (https) {
36
+ env.HTTPS_PROXY = https;
37
+ env.https_proxy = https;
38
+ }
39
+ else if (all) {
40
+ env.HTTPS_PROXY = all;
41
+ env.https_proxy = all;
42
+ }
43
+ return env;
44
+ }
45
+ export function clearProxyEnvKeys(env) {
46
+ for (const key of PROXY_ENV_KEYS) {
47
+ delete env[key];
48
+ }
49
+ }
50
+ export function applyProxyToEnvRecord(env, proxy) {
51
+ clearProxyEnvKeys(env);
52
+ const next = buildProxyEnv(proxy);
53
+ for (const [k, v] of Object.entries(next)) {
54
+ env[k] = v;
55
+ }
56
+ }
57
+ export function formatProxySummary(proxy) {
58
+ if (emptyProxy(proxy))
59
+ return "(none)";
60
+ const parts = [];
61
+ if (proxy?.http)
62
+ parts.push(`http=${proxy.http}`);
63
+ if (proxy?.https)
64
+ parts.push(`https=${proxy.https}`);
65
+ if (proxy?.all)
66
+ parts.push(`all=${proxy.all}`);
67
+ return parts.join(", ");
68
+ }
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@nvae/llmswitch",
3
+ "version": "0.2.0",
4
+ "description": "CLI to switch LLM providers and models for Claude Code, Codex, and OpenCode",
5
+ "type": "module",
6
+ "bin": {
7
+ "llms": "dist/index.js",
8
+ "llm-switch": "dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "scripts": {
16
+ "build": "tsc -p tsconfig.build.json",
17
+ "dev": "bun run ./src/index.ts",
18
+ "start": "bun run ./src/index.ts",
19
+ "test": "bun test",
20
+ "typecheck": "tsc -p tsconfig.json --noEmit",
21
+ "prepublishOnly": "bun run typecheck && bun run build"
22
+ },
23
+ "engines": {
24
+ "node": ">=20"
25
+ },
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "keywords": [
30
+ "cli",
31
+ "llm",
32
+ "claude-code",
33
+ "codex",
34
+ "opencode",
35
+ "provider",
36
+ "model-switch"
37
+ ],
38
+ "license": "MIT",
39
+ "dependencies": {
40
+ "@clack/prompts": "^0.11.0",
41
+ "commander": "^14.0.0",
42
+ "smol-toml": "^1.4.2"
43
+ },
44
+ "devDependencies": {
45
+ "@types/bun": "^1.2.19",
46
+ "@types/node": "^24.0.0",
47
+ "typescript": "^5.8.3"
48
+ }
49
+ }