bizrouter 0.1.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.
- package/README.md +50 -0
- package/dist/api.js +81 -0
- package/dist/args.js +64 -0
- package/dist/browser.js +18 -0
- package/dist/catalog.js +44 -0
- package/dist/commands/doctor.js +117 -0
- package/dist/commands/login.js +97 -0
- package/dist/commands/misc.js +57 -0
- package/dist/commands/models.js +57 -0
- package/dist/commands/setup.js +173 -0
- package/dist/commands/shared.js +11 -0
- package/dist/config.js +98 -0
- package/dist/harness/claude.js +61 -0
- package/dist/harness/codex.js +45 -0
- package/dist/harness/hermes.js +27 -0
- package/dist/harness/opencode.js +55 -0
- package/dist/index.js +156 -0
- package/dist/prompt.js +36 -0
- package/dist/spawn.js +92 -0
- package/dist/ui.js +67 -0
- package/package.json +37 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { fetchCatalog } from '../api.js';
|
|
5
|
+
import { pickDefaultModel, FALLBACK_DEFAULT } from '../catalog.js';
|
|
6
|
+
import { apiBase, ENV_KEY_NAME } from '../config.js';
|
|
7
|
+
import { claudeEnv } from '../harness/claude.js';
|
|
8
|
+
import { CODEX_PROVIDER_ID } from '../harness/codex.js';
|
|
9
|
+
import { OPENCODE_PROVIDER_ID, opencodeProvider } from '../harness/opencode.js';
|
|
10
|
+
import { c, CliError, info, ok, print, warn } from '../ui.js';
|
|
11
|
+
import { requireApiKey } from './shared.js';
|
|
12
|
+
export function claudeSettingsPath() {
|
|
13
|
+
return join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude'), 'settings.json');
|
|
14
|
+
}
|
|
15
|
+
export function codexConfigPath() {
|
|
16
|
+
return join(process.env.CODEX_HOME ?? join(homedir(), '.codex'), 'config.toml');
|
|
17
|
+
}
|
|
18
|
+
export function opencodeConfigPath() {
|
|
19
|
+
const base = process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config');
|
|
20
|
+
return join(base, 'opencode', 'opencode.json');
|
|
21
|
+
}
|
|
22
|
+
function backup(path) {
|
|
23
|
+
if (!existsSync(path))
|
|
24
|
+
return undefined;
|
|
25
|
+
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '');
|
|
26
|
+
const target = `${path}.bak-${stamp}`;
|
|
27
|
+
copyFileSync(path, target);
|
|
28
|
+
return target;
|
|
29
|
+
}
|
|
30
|
+
function writeText(path, text) {
|
|
31
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
32
|
+
writeFileSync(path, text);
|
|
33
|
+
}
|
|
34
|
+
/** Merge BizRouter's env block into a Claude Code settings.json object. */
|
|
35
|
+
export function mergeClaudeSettings(existing, env) {
|
|
36
|
+
const currentEnv = (existing.env && typeof existing.env === 'object' ? existing.env : {});
|
|
37
|
+
return { ...existing, env: { ...currentEnv, ...env } };
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Rewrite ~/.codex/config.toml so `codex` alone uses BizRouter: top-level
|
|
41
|
+
* `model_provider`/`model` are replaced in the preamble (before the first
|
|
42
|
+
* table header) and a fresh `[model_providers.bizrouter]` block replaces any
|
|
43
|
+
* previous one. Other tables are left byte-for-byte.
|
|
44
|
+
*/
|
|
45
|
+
export function upsertCodexConfig(text, options) {
|
|
46
|
+
const lines = text.split(/\r?\n/);
|
|
47
|
+
const firstHeader = lines.findIndex((l) => /^\s*\[/.test(l));
|
|
48
|
+
const preambleEnd = firstHeader === -1 ? lines.length : firstHeader;
|
|
49
|
+
const preamble = lines.slice(0, preambleEnd).filter((l) => !/^\s*(model_provider|model)\s*=/.test(l));
|
|
50
|
+
const rest = lines.slice(preambleEnd);
|
|
51
|
+
// Drop an existing [model_providers.bizrouter] table (header through the line before the next header).
|
|
52
|
+
const cleaned = [];
|
|
53
|
+
let skipping = false;
|
|
54
|
+
for (const line of rest) {
|
|
55
|
+
if (/^\s*\[/.test(line))
|
|
56
|
+
skipping = new RegExp(`^\\s*\\[model_providers\\.${CODEX_PROVIDER_ID}\\]`).test(line);
|
|
57
|
+
if (!skipping)
|
|
58
|
+
cleaned.push(line);
|
|
59
|
+
}
|
|
60
|
+
while (cleaned.length && cleaned[cleaned.length - 1]?.trim() === '')
|
|
61
|
+
cleaned.pop();
|
|
62
|
+
while (preamble.length && preamble[preamble.length - 1]?.trim() === '')
|
|
63
|
+
preamble.pop();
|
|
64
|
+
const block = [
|
|
65
|
+
`[model_providers.${CODEX_PROVIDER_ID}]`,
|
|
66
|
+
'name = "BizRouter"',
|
|
67
|
+
`base_url = "${options.apiBase}/v1"`,
|
|
68
|
+
`env_key = "${ENV_KEY_NAME}"`,
|
|
69
|
+
'wire_api = "responses"',
|
|
70
|
+
];
|
|
71
|
+
const out = [
|
|
72
|
+
...preamble,
|
|
73
|
+
`model_provider = "${CODEX_PROVIDER_ID}"`,
|
|
74
|
+
`model = "${options.model}"`,
|
|
75
|
+
...(cleaned.length ? ['', ...cleaned] : []),
|
|
76
|
+
'',
|
|
77
|
+
...block,
|
|
78
|
+
'',
|
|
79
|
+
];
|
|
80
|
+
return out.join('\n');
|
|
81
|
+
}
|
|
82
|
+
function readJsonObject(path) {
|
|
83
|
+
if (!existsSync(path))
|
|
84
|
+
return {};
|
|
85
|
+
const raw = readFileSync(path, 'utf8');
|
|
86
|
+
if (!raw.trim())
|
|
87
|
+
return {};
|
|
88
|
+
try {
|
|
89
|
+
const parsed = JSON.parse(raw);
|
|
90
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
91
|
+
throw new Error('object expected');
|
|
92
|
+
return parsed;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
throw new CliError(`${path} 를 JSON 으로 읽을 수 없어 수정하지 않았습니다.`, {
|
|
96
|
+
hint: '주석이나 문법 오류가 있으면 먼저 정리한 뒤 다시 실행하세요.',
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
export async function setupCommand(argv) {
|
|
101
|
+
const [target, ...rest] = argv;
|
|
102
|
+
const dryRun = rest.includes('--dry-run');
|
|
103
|
+
const modelFlagIndex = rest.findIndex((a) => a === '--model' || a === '-m');
|
|
104
|
+
const pinned = modelFlagIndex >= 0 ? rest[modelFlagIndex + 1] : rest.find((a) => a.startsWith('--model='))?.slice(8);
|
|
105
|
+
const known = ['claude', 'codex', 'opencode'];
|
|
106
|
+
if (!target || !known.includes(target)) {
|
|
107
|
+
throw new CliError('설정을 남길 도구를 지정하세요: bizrouter setup <claude|codex|opencode> [--model ID] [--dry-run]', {
|
|
108
|
+
hint: 'Hermes 는 `bizrouter hermes` 로 실행할 때마다 연결되므로 별도 설정이 없습니다.',
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
const apiKey = requireApiKey();
|
|
112
|
+
const base = apiBase();
|
|
113
|
+
if (target === 'claude') {
|
|
114
|
+
const path = claudeSettingsPath();
|
|
115
|
+
const next = mergeClaudeSettings(readJsonObject(path), claudeEnv({ apiKey, apiBase: base, closedNetwork: false }));
|
|
116
|
+
const text = `${JSON.stringify(next, null, 2)}\n`;
|
|
117
|
+
if (dryRun) {
|
|
118
|
+
print(text);
|
|
119
|
+
return 0;
|
|
120
|
+
}
|
|
121
|
+
const bak = backup(path);
|
|
122
|
+
writeText(path, text);
|
|
123
|
+
ok(`${path} 의 env 블록에 BizRouter 설정을 넣었습니다.${bak ? ` (백업: ${bak})` : ''}`);
|
|
124
|
+
warn('이 파일에는 API 키가 그대로 저장됩니다. 저장소에 커밋되는 프로젝트 .claude/settings.json 이 아니라 사용자 설정이라 안전하지만, 기기를 공유한다면 `bizrouter claude` 실행 방식을 권장합니다.');
|
|
125
|
+
info('이제 `claude` 만 실행해도 BizRouter 로 연결됩니다. 되돌리려면 백업 파일을 복원하세요.');
|
|
126
|
+
return 0;
|
|
127
|
+
}
|
|
128
|
+
const catalog = await fetchCatalog(apiKey, { allowStale: true }).catch(() => undefined);
|
|
129
|
+
const models = catalog?.models ?? [];
|
|
130
|
+
if (target === 'codex') {
|
|
131
|
+
const model = pinned ?? pickDefaultModel('codex', models) ?? FALLBACK_DEFAULT.codex;
|
|
132
|
+
const path = codexConfigPath();
|
|
133
|
+
const current = existsSync(path) ? readFileSync(path, 'utf8') : '';
|
|
134
|
+
const text = upsertCodexConfig(current, { model, apiBase: base });
|
|
135
|
+
if (dryRun) {
|
|
136
|
+
print(text);
|
|
137
|
+
return 0;
|
|
138
|
+
}
|
|
139
|
+
const bak = backup(path);
|
|
140
|
+
writeText(path, text);
|
|
141
|
+
ok(`${path} 에 BizRouter provider 를 넣고 기본 모델을 ${model} 로 맞췼습니다.${bak ? ` (백업: ${bak})` : ''}`);
|
|
142
|
+
printEnvHint();
|
|
143
|
+
return 0;
|
|
144
|
+
}
|
|
145
|
+
const model = pinned ?? pickDefaultModel('opencode', models) ?? FALLBACK_DEFAULT.opencode;
|
|
146
|
+
const path = opencodeConfigPath();
|
|
147
|
+
const existing = readJsonObject(path);
|
|
148
|
+
const provider = (existing.provider && typeof existing.provider === 'object' ? existing.provider : {});
|
|
149
|
+
const next = {
|
|
150
|
+
$schema: 'https://opencode.ai/config.json',
|
|
151
|
+
...existing,
|
|
152
|
+
provider: { ...provider, [OPENCODE_PROVIDER_ID]: opencodeProvider(models, undefined, base) },
|
|
153
|
+
model: `${OPENCODE_PROVIDER_ID}/${model}`,
|
|
154
|
+
};
|
|
155
|
+
const text = `${JSON.stringify(next, null, 2)}\n`;
|
|
156
|
+
if (dryRun) {
|
|
157
|
+
print(text);
|
|
158
|
+
return 0;
|
|
159
|
+
}
|
|
160
|
+
const bak = backup(path);
|
|
161
|
+
writeText(path, text);
|
|
162
|
+
ok(`${path} 에 BizRouter provider(모델 ${Object.keys(next.provider[OPENCODE_PROVIDER_ID]?.models ?? {}).length}개)를 넣고 기본 모델을 ${model} 로 맞췼습니다.${bak ? ` (백업: ${bak})` : ''}`);
|
|
163
|
+
if (models.length === 0)
|
|
164
|
+
warn('모델 목록을 가져오지 못해 provider 의 모델 표가 비어 있습니다. 네트워크가 되면 다시 실행하세요.');
|
|
165
|
+
printEnvHint();
|
|
166
|
+
return 0;
|
|
167
|
+
}
|
|
168
|
+
function printEnvHint() {
|
|
169
|
+
print();
|
|
170
|
+
print(`이 설정은 셸의 ${c.bold(ENV_KEY_NAME)} 환경 변수에서 키를 읽습니다. 셸 프로필(~/.zshrc 등)에 한 줄을 추가하세요:`);
|
|
171
|
+
print(` ${c.cyan(`eval "$(bizrouter env)"`)}`);
|
|
172
|
+
print(c.dim('또는 매번 `bizrouter codex` / `bizrouter opencode` 로 실행하면 이 단계가 필요 없습니다.'));
|
|
173
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { ENV_KEY_NAME, resolveCredential } from '../config.js';
|
|
2
|
+
import { CliError } from '../ui.js';
|
|
3
|
+
export function requireApiKey() {
|
|
4
|
+
const cred = resolveCredential();
|
|
5
|
+
if (!cred.apiKey) {
|
|
6
|
+
throw new CliError('BizRouter API 키가 없습니다.', {
|
|
7
|
+
hint: `\`bizrouter login\` 으로 키를 저장하거나 ${ENV_KEY_NAME} 환경 변수를 설정하세요.`,
|
|
8
|
+
});
|
|
9
|
+
}
|
|
10
|
+
return cred.apiKey;
|
|
11
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
export const DEFAULT_API_BASE = 'https://api.bizrouter.ai';
|
|
5
|
+
export const DEFAULT_CONSOLE_URL = 'https://bizrouter.ai';
|
|
6
|
+
export const KEYS_PAGE_PATH = '/settings/keys';
|
|
7
|
+
export const ENV_KEY_NAME = 'BIZROUTER_API_KEY';
|
|
8
|
+
export const KEY_PREFIX = 'sk-br-';
|
|
9
|
+
export const HARNESSES = ['claude', 'codex', 'opencode', 'hermes'];
|
|
10
|
+
export function configDir() {
|
|
11
|
+
return process.env.BIZROUTER_CONFIG_DIR ?? join(homedir(), '.bizrouter');
|
|
12
|
+
}
|
|
13
|
+
export function credentialsPath() {
|
|
14
|
+
return join(configDir(), 'credentials.json');
|
|
15
|
+
}
|
|
16
|
+
export function configPath() {
|
|
17
|
+
return join(configDir(), 'config.json');
|
|
18
|
+
}
|
|
19
|
+
export function cacheDir() {
|
|
20
|
+
return join(configDir(), 'cache');
|
|
21
|
+
}
|
|
22
|
+
export function tmpDir() {
|
|
23
|
+
return join(configDir(), 'tmp');
|
|
24
|
+
}
|
|
25
|
+
function ensureDir(dir) {
|
|
26
|
+
if (!existsSync(dir))
|
|
27
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
28
|
+
}
|
|
29
|
+
export function readJsonFile(path) {
|
|
30
|
+
if (!existsSync(path))
|
|
31
|
+
return undefined;
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export function writePrivateJson(path, value) {
|
|
40
|
+
ensureDir(join(path, '..'));
|
|
41
|
+
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
42
|
+
try {
|
|
43
|
+
chmodSync(path, 0o600);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// Windows has no POSIX mode bits; the file is still user-private by default.
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export function loadUserConfig() {
|
|
50
|
+
return readJsonFile(configPath()) ?? {};
|
|
51
|
+
}
|
|
52
|
+
export function saveUserConfig(config) {
|
|
53
|
+
writePrivateJson(configPath(), config);
|
|
54
|
+
}
|
|
55
|
+
export function apiBase() {
|
|
56
|
+
const fromEnv = process.env.BIZROUTER_API_BASE;
|
|
57
|
+
const base = fromEnv || loadUserConfig().api_base || DEFAULT_API_BASE;
|
|
58
|
+
return base.replace(/\/+$/, '');
|
|
59
|
+
}
|
|
60
|
+
export function consoleUrl() {
|
|
61
|
+
const base = process.env.BIZROUTER_CONSOLE_URL || loadUserConfig().console_url || DEFAULT_CONSOLE_URL;
|
|
62
|
+
return base.replace(/\/+$/, '');
|
|
63
|
+
}
|
|
64
|
+
/** Environment first (CI, containers), then the key saved by `bizrouter login`. */
|
|
65
|
+
export function resolveCredential() {
|
|
66
|
+
const fromEnv = process.env[ENV_KEY_NAME]?.trim();
|
|
67
|
+
if (fromEnv)
|
|
68
|
+
return { apiKey: fromEnv, source: 'env' };
|
|
69
|
+
const stored = readJsonFile(credentialsPath());
|
|
70
|
+
if (stored?.api_key)
|
|
71
|
+
return { apiKey: stored.api_key, source: 'stored', path: credentialsPath() };
|
|
72
|
+
return { source: 'none' };
|
|
73
|
+
}
|
|
74
|
+
export function saveCredential(apiKey) {
|
|
75
|
+
const path = credentialsPath();
|
|
76
|
+
writePrivateJson(path, { api_key: apiKey, saved_at: new Date().toISOString() });
|
|
77
|
+
return path;
|
|
78
|
+
}
|
|
79
|
+
export function clearCredential() {
|
|
80
|
+
const path = credentialsPath();
|
|
81
|
+
if (!existsSync(path))
|
|
82
|
+
return false;
|
|
83
|
+
unlinkSync(path);
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
export function looksLikeApiKey(value) {
|
|
87
|
+
return value.startsWith(KEY_PREFIX) && value.length >= 20 && !/\s/.test(value);
|
|
88
|
+
}
|
|
89
|
+
export function ensureTmpDir() {
|
|
90
|
+
const dir = tmpDir();
|
|
91
|
+
ensureDir(dir);
|
|
92
|
+
return dir;
|
|
93
|
+
}
|
|
94
|
+
export function ensureCacheDir() {
|
|
95
|
+
const dir = cacheDir();
|
|
96
|
+
ensureDir(dir);
|
|
97
|
+
return dir;
|
|
98
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { hasFlag } from '../args.js';
|
|
4
|
+
import { isAnthropicModelId } from '../catalog.js';
|
|
5
|
+
import { ensureTmpDir } from '../config.js';
|
|
6
|
+
import { CliError } from '../ui.js';
|
|
7
|
+
/**
|
|
8
|
+
* Environment Claude Code needs to talk to BizRouter. Mirrors what the
|
|
9
|
+
* integration guide documents; `ANTHROPIC_API_KEY` must be present and empty
|
|
10
|
+
* so Claude Code never falls back to a saved claude.ai login.
|
|
11
|
+
*/
|
|
12
|
+
export function claudeEnv(options) {
|
|
13
|
+
const env = {
|
|
14
|
+
ANTHROPIC_BASE_URL: `${options.apiBase}/claude`,
|
|
15
|
+
ANTHROPIC_AUTH_TOKEN: options.apiKey,
|
|
16
|
+
ANTHROPIC_API_KEY: '',
|
|
17
|
+
CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: '1',
|
|
18
|
+
};
|
|
19
|
+
if (options.closedNetwork)
|
|
20
|
+
env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = '1';
|
|
21
|
+
return env;
|
|
22
|
+
}
|
|
23
|
+
export function assertClaudeModel(model) {
|
|
24
|
+
if (!isAnthropicModelId(model)) {
|
|
25
|
+
throw new CliError(`Claude Code 는 Claude 모델만 실행할 수 있습니다: ${model}`, {
|
|
26
|
+
hint: 'Claude Code 는 thinking·cache_control 같은 Anthropic 전용 필드를 항상 보내서 다른 모델은 400 으로 거절됩니다. GPT·Gemini 는 `bizrouter codex` 또는 `bizrouter opencode` 로 실행하세요.',
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function buildClaudePlan(options) {
|
|
31
|
+
if (options.model)
|
|
32
|
+
assertClaudeModel(options.model);
|
|
33
|
+
const env = claudeEnv(options);
|
|
34
|
+
const args = [];
|
|
35
|
+
let cleanup;
|
|
36
|
+
// A user-level settings.json `env` block outranks shell variables, so a
|
|
37
|
+
// stale ANTHROPIC_BASE_URL there would silently win. The command-line
|
|
38
|
+
// settings level beats every file, so we hand the same env block over as a
|
|
39
|
+
// private temp file. The key never appears in the process arguments.
|
|
40
|
+
if (options.settingsFile !== false && !hasFlag(options.passthrough, '--settings')) {
|
|
41
|
+
const path = join(ensureTmpDir(), `claude-settings-${process.pid}-${Date.now()}.json`);
|
|
42
|
+
writeFileSync(path, JSON.stringify({ env }), { mode: 0o600 });
|
|
43
|
+
args.push('--settings', path);
|
|
44
|
+
cleanup = () => {
|
|
45
|
+
try {
|
|
46
|
+
unlinkSync(path);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// already gone
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
if (options.model && !hasFlag(options.passthrough, '--model'))
|
|
54
|
+
args.push('--model', options.model);
|
|
55
|
+
args.push(...options.passthrough);
|
|
56
|
+
const notes = [
|
|
57
|
+
`Claude Code → ${env.ANTHROPIC_BASE_URL}` + (options.model ? ` · 모델 ${options.model}` : ' · 모델은 Claude Code 기본값(sonnet/opus 별칭 자동 매핑)'),
|
|
58
|
+
'`/model` 선택창에 이 키로 쓸 수 있는 Claude 모델이 자동으로 채워집니다.',
|
|
59
|
+
];
|
|
60
|
+
return { bin: 'claude', args, env, notes, cleanup };
|
|
61
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { ENV_KEY_NAME } from '../config.js';
|
|
2
|
+
/** True when a Codex config.toml sets service_tier, which BizRouter rejects as an unpriced tier. */
|
|
3
|
+
export function codexConfigPinsServiceTier(configText) {
|
|
4
|
+
return /^\s*service_tier\s*=/m.test(configText);
|
|
5
|
+
}
|
|
6
|
+
export const CODEX_PROVIDER_ID = 'bizrouter';
|
|
7
|
+
/** TOML string literal for a `-c key=value` override. */
|
|
8
|
+
function tomlString(value) {
|
|
9
|
+
return JSON.stringify(value);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Codex reads `~/.codex/config.toml`, but `-c key=value` overrides win over the
|
|
13
|
+
* file, so the launch never has to edit the user's config. The provider block
|
|
14
|
+
* is exactly what the integration guide documents for a persistent setup.
|
|
15
|
+
*/
|
|
16
|
+
export function codexOverrides(options) {
|
|
17
|
+
const overrides = [
|
|
18
|
+
`model_provider=${tomlString(CODEX_PROVIDER_ID)}`,
|
|
19
|
+
`model_providers.${CODEX_PROVIDER_ID}.name=${tomlString('BizRouter')}`,
|
|
20
|
+
`model_providers.${CODEX_PROVIDER_ID}.base_url=${tomlString(`${options.apiBase}/v1`)}`,
|
|
21
|
+
`model_providers.${CODEX_PROVIDER_ID}.env_key=${tomlString(ENV_KEY_NAME)}`,
|
|
22
|
+
`model_providers.${CODEX_PROVIDER_ID}.wire_api=${tomlString('responses')}`,
|
|
23
|
+
`model=${tomlString(options.model)}`,
|
|
24
|
+
];
|
|
25
|
+
if (options.reasoningEffort) {
|
|
26
|
+
// Codex knows minimal/low/medium/high/xhigh; map the two ends Ori also accepts.
|
|
27
|
+
const effort = options.reasoningEffort === 'max' ? 'xhigh' : options.reasoningEffort === 'none' ? 'minimal' : options.reasoningEffort;
|
|
28
|
+
overrides.push(`model_reasoning_effort=${tomlString(effort)}`);
|
|
29
|
+
}
|
|
30
|
+
if (options.resetServiceTier)
|
|
31
|
+
overrides.push(`service_tier=${tomlString('default')}`);
|
|
32
|
+
return overrides.flatMap((o) => ['-c', o]);
|
|
33
|
+
}
|
|
34
|
+
export function buildCodexPlan(options) {
|
|
35
|
+
const args = [...codexOverrides(options), ...options.passthrough];
|
|
36
|
+
return {
|
|
37
|
+
bin: 'codex',
|
|
38
|
+
args,
|
|
39
|
+
env: { [ENV_KEY_NAME]: options.apiKey },
|
|
40
|
+
notes: [
|
|
41
|
+
`Codex → ${options.apiBase}/v1 (Responses API) · 모델 ${options.model}` + (options.reasoningEffort ? ` · 추론 강도 ${options.reasoningEffort}` : ''),
|
|
42
|
+
...(options.resetServiceTier ? ['config.toml 의 service_tier 는 BizRouter 에 요금 계약이 없어 이번 실행에서는 기본 등급으로 되돌립니다.'] : []),
|
|
43
|
+
],
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const HERMES_SUBCOMMANDS = new Set(['chat']);
|
|
2
|
+
/**
|
|
3
|
+
* Hermes reaches any OpenAI-compatible endpoint through its `custom` provider,
|
|
4
|
+
* which reads `CUSTOM_BASE_URL` / `CUSTOM_API_KEY`. `--provider` and `--model`
|
|
5
|
+
* apply to this invocation only, so the user's config.yaml is left alone.
|
|
6
|
+
*/
|
|
7
|
+
export function hermesArgs(options) {
|
|
8
|
+
const flags = ['--provider', 'custom', '--model', options.model];
|
|
9
|
+
const [first, ...rest] = options.passthrough;
|
|
10
|
+
if (first === undefined)
|
|
11
|
+
return ['chat', ...flags];
|
|
12
|
+
if (HERMES_SUBCOMMANDS.has(first))
|
|
13
|
+
return [first, ...flags, ...rest];
|
|
14
|
+
// Global flags (e.g. `-z "prompt"`, `--tui`) accept the overrides in front.
|
|
15
|
+
return [...flags, ...options.passthrough];
|
|
16
|
+
}
|
|
17
|
+
export function buildHermesPlan(options) {
|
|
18
|
+
return {
|
|
19
|
+
bin: 'hermes',
|
|
20
|
+
args: hermesArgs(options),
|
|
21
|
+
env: {
|
|
22
|
+
CUSTOM_BASE_URL: `${options.apiBase}/v1`,
|
|
23
|
+
CUSTOM_API_KEY: options.apiKey,
|
|
24
|
+
},
|
|
25
|
+
notes: [`Hermes → custom provider ${options.apiBase}/v1 · 모델 ${options.model}`],
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { chatModels } from '../api.js';
|
|
2
|
+
import { ENV_KEY_NAME } from '../config.js';
|
|
3
|
+
export const OPENCODE_PROVIDER_ID = 'bizrouter';
|
|
4
|
+
// The generic OpenAI-compatible provider against /v1 finishes turns cleanly.
|
|
5
|
+
// @bizrouter/ai-sdk-provider over /ai-sdk kept OpenCode looping after the
|
|
6
|
+
// answer (no terminal finish reason) in the 2026-09-06 run, so it is not used.
|
|
7
|
+
export const OPENCODE_PROVIDER_NPM = '@ai-sdk/openai-compatible';
|
|
8
|
+
/**
|
|
9
|
+
* Provider block for opencode.json. Every chat model the key may use is listed
|
|
10
|
+
* so the `/models` picker shows the full BizRouter catalog with real context
|
|
11
|
+
* and output limits instead of a hand-maintained sample.
|
|
12
|
+
*/
|
|
13
|
+
export function opencodeProvider(models, reasoningEffort, apiBase = 'https://api.bizrouter.ai') {
|
|
14
|
+
const entries = {};
|
|
15
|
+
for (const m of chatModels(models)) {
|
|
16
|
+
const entry = {
|
|
17
|
+
name: m.name,
|
|
18
|
+
limit: { context: m.context_length, output: m.max_output_tokens },
|
|
19
|
+
};
|
|
20
|
+
if (reasoningEffort && m.supported_parameters.includes('reasoning_effort') && !/claude|anthropic/i.test(m.id)) {
|
|
21
|
+
const effort = reasoningEffort === 'max' ? 'xhigh' : reasoningEffort === 'none' ? 'minimal' : reasoningEffort;
|
|
22
|
+
entry.options = { reasoningEffort: effort };
|
|
23
|
+
}
|
|
24
|
+
entries[m.id] = entry;
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
npm: OPENCODE_PROVIDER_NPM,
|
|
28
|
+
name: 'BizRouter',
|
|
29
|
+
options: { baseURL: `${apiBase}/v1`, apiKey: `{env:${ENV_KEY_NAME}}` },
|
|
30
|
+
models: entries,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export function opencodeConfigContent(options) {
|
|
34
|
+
return JSON.stringify({
|
|
35
|
+
$schema: 'https://opencode.ai/config.json',
|
|
36
|
+
provider: { [OPENCODE_PROVIDER_ID]: opencodeProvider(options.models, options.reasoningEffort, options.apiBase) },
|
|
37
|
+
model: `${OPENCODE_PROVIDER_ID}/${options.model}`,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* OpenCode merges `OPENCODE_CONFIG_CONTENT` on top of the user's global and
|
|
42
|
+
* project config, so the BizRouter provider is added for this launch without
|
|
43
|
+
* touching any file on disk.
|
|
44
|
+
*/
|
|
45
|
+
export function buildOpencodePlan(options) {
|
|
46
|
+
return {
|
|
47
|
+
bin: 'opencode',
|
|
48
|
+
args: [...options.passthrough],
|
|
49
|
+
env: {
|
|
50
|
+
[ENV_KEY_NAME]: options.apiKey,
|
|
51
|
+
OPENCODE_CONFIG_CONTENT: opencodeConfigContent(options),
|
|
52
|
+
},
|
|
53
|
+
notes: [`OpenCode → provider "${OPENCODE_PROVIDER_ID}" (${chatModels(options.models).length}개 모델) · 기본 모델 ${options.model}`],
|
|
54
|
+
};
|
|
55
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { fetchCatalog } from './api.js';
|
|
3
|
+
import { ArgError, parseLaunchArgs } from './args.js';
|
|
4
|
+
import { FALLBACK_DEFAULT, pickDefaultModel, suggestModels } from './catalog.js';
|
|
5
|
+
import { authCommand, loginCommand, logoutCommand } from './commands/login.js';
|
|
6
|
+
import { doctorCommand } from './commands/doctor.js';
|
|
7
|
+
import { envCommand, helpText, updateCommand, VERSION } from './commands/misc.js';
|
|
8
|
+
import { modelsCommand } from './commands/models.js';
|
|
9
|
+
import { setupCommand } from './commands/setup.js';
|
|
10
|
+
import { requireApiKey } from './commands/shared.js';
|
|
11
|
+
import { apiBase, loadUserConfig } from './config.js';
|
|
12
|
+
import { buildClaudePlan } from './harness/claude.js';
|
|
13
|
+
import { buildCodexPlan, codexConfigPinsServiceTier } from './harness/codex.js';
|
|
14
|
+
import { codexConfigPath } from './commands/setup.js';
|
|
15
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
16
|
+
import { buildHermesPlan } from './harness/hermes.js';
|
|
17
|
+
import { buildOpencodePlan } from './harness/opencode.js';
|
|
18
|
+
import { runPlan } from './spawn.js';
|
|
19
|
+
import { CliError, fail, info, maskKey, print, warn } from './ui.js';
|
|
20
|
+
const LAUNCH_HELP = {
|
|
21
|
+
claude: 'bizrouter claude [--model ID] [--closed-network] [--dry-run] [claude 옵션…]',
|
|
22
|
+
codex: 'bizrouter codex [--model ID] [--reasoning-effort 단계] [--dry-run] [codex 옵션…]',
|
|
23
|
+
opencode: 'bizrouter opencode [--model ID] [--reasoning-effort 단계] [--dry-run] [opencode 옵션…]',
|
|
24
|
+
hermes: 'bizrouter hermes [--model ID] [--dry-run] [hermes 옵션…]',
|
|
25
|
+
};
|
|
26
|
+
async function resolveModel(harness, args, apiKey) {
|
|
27
|
+
const pinned = args.model ?? loadUserConfig().defaults?.[harness];
|
|
28
|
+
let models = [];
|
|
29
|
+
try {
|
|
30
|
+
models = (await fetchCatalog(apiKey, { allowStale: true })).models;
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
// A rejected key is fatal for a real launch; a dry run only prints the plan.
|
|
34
|
+
if (!args.dryRun && error instanceof CliError && /유효하지 않/.test(error.message))
|
|
35
|
+
throw error;
|
|
36
|
+
warn(`모델 목록을 가져오지 못해 기본값으로 진행합니다. (${error instanceof Error ? error.message : String(error)})`);
|
|
37
|
+
}
|
|
38
|
+
if (pinned) {
|
|
39
|
+
if (models.length && !models.some((m) => m.id === pinned)) {
|
|
40
|
+
const suggestions = suggestModels(models, pinned);
|
|
41
|
+
warn(`「${pinned}」 는 이 키로 쓸 수 있는 모델 목록에 없습니다.${suggestions.length ? ` 비슷한 모델: ${suggestions.join(', ')}` : ''}`);
|
|
42
|
+
info('그대로 시도합니다. 키의 「API key 사용 범위」나 조직의 모델 정책이 막고 있으면 요청이 거절됩니다.');
|
|
43
|
+
}
|
|
44
|
+
return { model: pinned, models };
|
|
45
|
+
}
|
|
46
|
+
if (harness === 'claude')
|
|
47
|
+
return { models };
|
|
48
|
+
return { model: pickDefaultModel(harness, models) ?? FALLBACK_DEFAULT[harness], models };
|
|
49
|
+
}
|
|
50
|
+
async function launch(harness, argv) {
|
|
51
|
+
let args;
|
|
52
|
+
try {
|
|
53
|
+
args = parseLaunchArgs(argv);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (error instanceof ArgError)
|
|
57
|
+
throw new CliError(error.message, { hint: LAUNCH_HELP[harness] });
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
if (args.help) {
|
|
61
|
+
print(LAUNCH_HELP[harness]);
|
|
62
|
+
print(helpText());
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
const apiKey = requireApiKey();
|
|
66
|
+
const base = apiBase();
|
|
67
|
+
const { model, models } = await resolveModel(harness, args, apiKey);
|
|
68
|
+
let plan;
|
|
69
|
+
switch (harness) {
|
|
70
|
+
case 'claude':
|
|
71
|
+
plan = buildClaudePlan({ apiKey, apiBase: base, model, closedNetwork: args.closedNetwork, passthrough: args.passthrough });
|
|
72
|
+
if (args.reasoningEffort)
|
|
73
|
+
warn('Claude Code 는 추론 강도를 실행 옵션으로 받지 않습니다. 세션 안에서 /effort 를 사용하세요.');
|
|
74
|
+
break;
|
|
75
|
+
case 'codex':
|
|
76
|
+
plan = buildCodexPlan({
|
|
77
|
+
apiKey,
|
|
78
|
+
apiBase: base,
|
|
79
|
+
model: model ?? FALLBACK_DEFAULT.codex,
|
|
80
|
+
reasoningEffort: args.reasoningEffort,
|
|
81
|
+
resetServiceTier: existsSync(codexConfigPath()) && codexConfigPinsServiceTier(readFileSync(codexConfigPath(), 'utf8')),
|
|
82
|
+
passthrough: args.passthrough,
|
|
83
|
+
});
|
|
84
|
+
if (args.closedNetwork)
|
|
85
|
+
warn('--closed-network 는 Claude Code 전용 옵션입니다.');
|
|
86
|
+
break;
|
|
87
|
+
case 'opencode':
|
|
88
|
+
plan = buildOpencodePlan({ apiKey, apiBase: base, model: model ?? FALLBACK_DEFAULT.opencode, models, reasoningEffort: args.reasoningEffort, passthrough: args.passthrough });
|
|
89
|
+
if (args.closedNetwork)
|
|
90
|
+
warn('--closed-network 는 Claude Code 전용 옵션입니다.');
|
|
91
|
+
break;
|
|
92
|
+
case 'hermes':
|
|
93
|
+
plan = buildHermesPlan({ apiKey, apiBase: base, model: model ?? FALLBACK_DEFAULT.hermes, passthrough: args.passthrough });
|
|
94
|
+
if (args.reasoningEffort)
|
|
95
|
+
warn('Hermes 는 추론 강도 옵션을 받지 않습니다.');
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
if (!args.dryRun)
|
|
99
|
+
for (const note of plan.notes ?? [])
|
|
100
|
+
info(note);
|
|
101
|
+
return runPlan(plan, { dryRun: args.dryRun, maskValue: maskKey });
|
|
102
|
+
}
|
|
103
|
+
async function main(argv) {
|
|
104
|
+
const [command, ...rest] = argv;
|
|
105
|
+
switch (command) {
|
|
106
|
+
case undefined:
|
|
107
|
+
case 'help':
|
|
108
|
+
case '--help':
|
|
109
|
+
case '-h':
|
|
110
|
+
print(helpText());
|
|
111
|
+
return 0;
|
|
112
|
+
case '--version':
|
|
113
|
+
case '-v':
|
|
114
|
+
case 'version':
|
|
115
|
+
print(VERSION);
|
|
116
|
+
return 0;
|
|
117
|
+
case 'login':
|
|
118
|
+
return loginCommand(rest);
|
|
119
|
+
case 'auth':
|
|
120
|
+
return authCommand();
|
|
121
|
+
case 'logout':
|
|
122
|
+
return logoutCommand();
|
|
123
|
+
case 'models':
|
|
124
|
+
return modelsCommand(rest);
|
|
125
|
+
case 'setup':
|
|
126
|
+
return setupCommand(rest);
|
|
127
|
+
case 'doctor':
|
|
128
|
+
return doctorCommand();
|
|
129
|
+
case 'env':
|
|
130
|
+
return envCommand();
|
|
131
|
+
case 'update':
|
|
132
|
+
return updateCommand();
|
|
133
|
+
case 'claude':
|
|
134
|
+
case 'codex':
|
|
135
|
+
case 'opencode':
|
|
136
|
+
case 'hermes':
|
|
137
|
+
return launch(command, rest);
|
|
138
|
+
default:
|
|
139
|
+
throw new CliError(`알 수 없는 명령입니다: ${command}`, { hint: '`bizrouter help` 로 전체 명령을 볼 수 있습니다.' });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
main(process.argv.slice(2))
|
|
143
|
+
.then((code) => {
|
|
144
|
+
process.exitCode = code;
|
|
145
|
+
})
|
|
146
|
+
.catch((error) => {
|
|
147
|
+
if (error instanceof CliError) {
|
|
148
|
+
fail(error.message);
|
|
149
|
+
if (error.hint)
|
|
150
|
+
info(error.hint);
|
|
151
|
+
process.exitCode = error.exitCode;
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
fail(error instanceof Error ? error.stack ?? error.message : String(error));
|
|
155
|
+
process.exitCode = 1;
|
|
156
|
+
});
|
package/dist/prompt.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline';
|
|
2
|
+
import { Writable } from 'node:stream';
|
|
3
|
+
/** Read one line from stdin without echoing it (for API keys). */
|
|
4
|
+
export function readSecret(promptText) {
|
|
5
|
+
if (!process.stdin.isTTY) {
|
|
6
|
+
return new Promise((resolve) => {
|
|
7
|
+
let data = '';
|
|
8
|
+
process.stdin.setEncoding('utf8');
|
|
9
|
+
process.stdin.on('data', (chunk) => (data += chunk));
|
|
10
|
+
process.stdin.on('end', () => resolve(data.trim()));
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
const muted = new Writable({
|
|
15
|
+
write(_chunk, _encoding, callback) {
|
|
16
|
+
callback();
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
process.stderr.write(promptText);
|
|
20
|
+
const rl = createInterface({ input: process.stdin, output: muted, terminal: true });
|
|
21
|
+
rl.question('', (answer) => {
|
|
22
|
+
rl.close();
|
|
23
|
+
process.stderr.write('\n');
|
|
24
|
+
resolve(answer.trim());
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
export function readLine(promptText) {
|
|
29
|
+
return new Promise((resolve) => {
|
|
30
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
31
|
+
rl.question(promptText, (answer) => {
|
|
32
|
+
rl.close();
|
|
33
|
+
resolve(answer.trim());
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
}
|