bizrouter 0.2.1 → 0.3.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/AGENT_MANUAL.md +36 -4
- package/README.md +18 -1
- package/dist/args.js +5 -1
- package/dist/commands/misc.js +5 -2
- package/dist/commands/models.js +1 -0
- package/dist/commands/search.js +119 -0
- package/dist/harness/hermes.js +4 -1
- package/dist/harness/opencode.js +18 -1
- package/dist/index.js +23 -5
- package/dist/mcp.js +37 -2
- package/dist/search.js +184 -0
- package/package.json +2 -2
package/AGENT_MANUAL.md
CHANGED
|
@@ -48,8 +48,10 @@ bizrouter logout revoke the console session, delet
|
|
|
48
48
|
bizrouter doctor connectivity, key, session, installed agents, conflicts
|
|
49
49
|
bizrouter models [query] [--json] models this key may use, KRW prices per 1M tokens
|
|
50
50
|
|
|
51
|
-
bizrouter claude|codex|opencode|hermes [--model ID] [--reasoning-effort L] [--no-mcp] [tool args…]
|
|
51
|
+
bizrouter claude|codex|opencode|hermes [--model ID] [--reasoning-effort L] [--search] [--no-mcp] [tool args…]
|
|
52
52
|
run a coding agent through BizRouter (config files untouched)
|
|
53
|
+
bizrouter search "question" [--model ID] [--max-tokens N] [--json] [--no-stream]
|
|
54
|
+
BizRouter Search: answer grounded in 50+ live data sources (see §7a)
|
|
53
55
|
|
|
54
56
|
bizrouter api --list [keyword] every console API operation (from the live OpenAPI)
|
|
55
57
|
bizrouter api --schema [METHOD] <path> request/response fields of one operation
|
|
@@ -179,13 +181,43 @@ setting, everything after our flags is passed through untouched.
|
|
|
179
181
|
When a console session exists, the launcher also registers this CLI as an MCP
|
|
180
182
|
server named `bizrouter` inside Claude Code, Codex and OpenCode for that run
|
|
181
183
|
(`--no-mcp` disables it). The agent then has tools `bizrouter_api`,
|
|
182
|
-
`bizrouter_openapi`, `bizrouter_docs`, `bizrouter_models
|
|
183
|
-
with the same rules as above — `bizrouter_api` refuses
|
|
184
|
-
`confirm: true` is passed after the user agreed.
|
|
184
|
+
`bizrouter_openapi`, `bizrouter_docs`, `bizrouter_models`, `bizrouter_whoami`
|
|
185
|
+
and `bizrouter_search` with the same rules as above — `bizrouter_api` refuses
|
|
186
|
+
mutating calls unless `confirm: true` is passed after the user agreed.
|
|
185
187
|
|
|
186
188
|
Claude Code can only run Anthropic models (it always sends Anthropic-only
|
|
187
189
|
fields); use Codex/OpenCode/Hermes for GPT or Gemini.
|
|
188
190
|
|
|
191
|
+
## 7a. BizRouter Search — answers grounded in live data
|
|
192
|
+
|
|
193
|
+
`POST /v1/search` (docs: https://bizrouter.ai/docs/search) makes the model
|
|
194
|
+
look facts up before it answers. The gateway embeds the question, picks the
|
|
195
|
+
matching sources out of 50+ agent.store data servers, lets the model call them
|
|
196
|
+
as tools, and returns a normal chat completion plus a `bizrouter_search`
|
|
197
|
+
block. Sources include Korean law and National Assembly bills, corporate
|
|
198
|
+
filings and financials (DART, FSC), real-estate transactions, national
|
|
199
|
+
statistics and Bank of Korea rates, transport (flights, transit, maps),
|
|
200
|
+
papers and clinical trials, web search and news, weather and air quality, US
|
|
201
|
+
market data. Questions that need no data are answered without tools.
|
|
202
|
+
|
|
203
|
+
Use it whenever an answer must be current or authoritative instead of model
|
|
204
|
+
memory — especially anything about Korean public data, prices, laws, filings
|
|
205
|
+
or news. Lookups are free; only the model's tokens are billed (tool results
|
|
206
|
+
count as input tokens, so keep `max_tokens` ≥ 1500).
|
|
207
|
+
|
|
208
|
+
Three ways to reach it, all using the API key:
|
|
209
|
+
|
|
210
|
+
| Where | How |
|
|
211
|
+
| --- | --- |
|
|
212
|
+
| Terminal | `bizrouter search "질문" [--model ID] [--max-tokens N] [--json] [--no-stream]` — answer on stdout, a one-line summary (sources · calls · rounds · model · cost) on stderr. `--json` prints the full completion including `bizrouter_search.sources[]` and `calls[]`. |
|
|
213
|
+
| Inside an agent (MCP) | Tool `bizrouter_search {query, model?, max_tokens?}` → `{answer, sources, calls, rounds, routed_model, usage}`. Available in Claude Code, Codex and OpenCode launched by this CLI when a console session exists. This is the **only** route for Claude Code (Messages API) and Codex (Responses API), because Search rides on Chat Completions. |
|
|
214
|
+
| Harness model | `bizrouter opencode --search` / `bizrouter hermes --search` pins `<model>:search` so every turn of that session is grounded. Any Chat Completions client can do the same with `model: "openai/gpt-5.5:search"`, `model: "bizrouter/search"`, or body `"search": true`. |
|
|
215
|
+
|
|
216
|
+
Default model is `bizrouter/route` (the organization's Smart Routing policy
|
|
217
|
+
chooses the concrete model). `--model`/`model` accepts any chat model id.
|
|
218
|
+
When the answer cites sources, keep the citation when you relay it to the
|
|
219
|
+
user; when `calls` is empty, say the answer came from the model alone.
|
|
220
|
+
|
|
189
221
|
## 8. Troubleshooting
|
|
190
222
|
|
|
191
223
|
- `콘솔 세션이 없습니다` / 401 → `bizrouter login` (human step).
|
package/README.md
CHANGED
|
@@ -40,7 +40,23 @@ bizrouter opencode --model google/gemini-3.5-pro
|
|
|
40
40
|
bizrouter claude --dry-run # 실행 대신 넘겨줄 환경 변수와 명령을 보여줍니다
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
-
콘솔 세션이 있으면 Claude Code·Codex·OpenCode에 이 CLI가 **MCP 서버 `bizrouter`** 로 함께 연결됩니다. 에이전트는 `bizrouter_api`(콘솔 API 호출)·`bizrouter_openapi`(API 탐색)·`bizrouter_docs`(문서)·`bizrouter_models`·`bizrouter_whoami` 도구로 API 키를 만들고, 정책을 바꾸고, 사용량을
|
|
43
|
+
콘솔 세션이 있으면 Claude Code·Codex·OpenCode에 이 CLI가 **MCP 서버 `bizrouter`** 로 함께 연결됩니다. 에이전트는 `bizrouter_api`(콘솔 API 호출)·`bizrouter_openapi`(API 탐색)·`bizrouter_docs`(문서)·`bizrouter_models`·`bizrouter_whoami`·`bizrouter_search`(Search) 도구로 API 키를 만들고, 정책을 바꾸고, 사용량을 읽고, 실제 데이터를 찾아 답합니다. 데이터를 바꾸는 호출은 사용자 확인 뒤 `confirm: true`를 받아야만 실행됩니다. `--no-mcp`로 끕니다.
|
|
44
|
+
|
|
45
|
+
## Search — 실제 데이터를 찾아 답하기
|
|
46
|
+
|
|
47
|
+
`bizrouter search`는 [BizRouter Search](https://bizrouter.ai/docs/search)(`POST /v1/search`)를 호출합니다. 모델이 질문에 맞는 출처(법령·국회, 기업 공시·재무, 부동산 실거래, 국가통계·금리, 교통, 논문·임상, 웹 검색·뉴스, 날씨, 지도, 미국 주식 등 50종 이상)를 골라 조회한 뒤 출처와 함께 답합니다. 조회는 무료이고 모델 토큰만 과금됩니다.
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
bizrouter search "근로기준법 60조 연차 휴가 조문"
|
|
51
|
+
bizrouter search "한국은행 기준금리 최근 결정" --model openai/gpt-5.5
|
|
52
|
+
bizrouter search "삼성전자 2024년 매출" --json | jq '.bizrouter_search.sources'
|
|
53
|
+
echo "인천공항 오늘 출발 지연 편" | bizrouter search --no-stream
|
|
54
|
+
|
|
55
|
+
bizrouter opencode --search # OpenCode 세션 전체에 Search 를 켬 (<모델>:search)
|
|
56
|
+
bizrouter hermes --search --model openai/gpt-5.5
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
답은 stdout, 출처·조회 횟수·라운드·모델·비용 한 줄은 stderr로 나옵니다. 로그인한 뒤 실행한 Claude Code·Codex·OpenCode 안에서는 MCP 도구 `bizrouter_search`로 같은 일을 합니다. Claude Code(Messages API)·Codex(Responses API)는 `:search` 접미사를 쓸 수 없으므로 이 도구가 두 에이전트에서 Search를 쓰는 길입니다.
|
|
44
60
|
|
|
45
61
|
## 콘솔 관리 명령
|
|
46
62
|
|
|
@@ -69,6 +85,7 @@ bizrouter mcp # MCP 서버(stdio)로 직접
|
|
|
69
85
|
|
|
70
86
|
```bash
|
|
71
87
|
bizrouter models [검색어] # 이 키로 쓸 수 있는 모델·컨텍스트·원화 단가
|
|
88
|
+
bizrouter search "질문" # Search 호출 (--model, --max-tokens, --json, --no-stream)
|
|
72
89
|
bizrouter doctor # 키·세션·연결·설치·충돌 점검
|
|
73
90
|
bizrouter setup claude|codex|opencode # 도구 설정 파일에 BizRouter를 영구 등록 (백업 생성)
|
|
74
91
|
bizrouter auth / logout / env / update
|
package/dist/args.js
CHANGED
|
@@ -7,7 +7,7 @@ export class ArgError extends Error {
|
|
|
7
7
|
* the harness untouched, so `bizrouter claude -p "hi"` still works.
|
|
8
8
|
*/
|
|
9
9
|
export function parseLaunchArgs(argv) {
|
|
10
|
-
const out = { closedNetwork: false, dryRun: false, help: false, noMcp: false, passthrough: [] };
|
|
10
|
+
const out = { closedNetwork: false, dryRun: false, help: false, noMcp: false, search: false, passthrough: [] };
|
|
11
11
|
let i = 0;
|
|
12
12
|
const takeValue = (flag) => {
|
|
13
13
|
const value = argv[i + 1];
|
|
@@ -51,6 +51,10 @@ export function parseLaunchArgs(argv) {
|
|
|
51
51
|
out.noMcp = true;
|
|
52
52
|
i += 1;
|
|
53
53
|
continue;
|
|
54
|
+
case '--search':
|
|
55
|
+
out.search = true;
|
|
56
|
+
i += 1;
|
|
57
|
+
continue;
|
|
54
58
|
case '--help':
|
|
55
59
|
case '-h':
|
|
56
60
|
out.help = true;
|
package/dist/commands/misc.js
CHANGED
|
@@ -2,7 +2,7 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
import { ENV_KEY_NAME } from '../config.js';
|
|
3
3
|
import { c, info, print } from '../ui.js';
|
|
4
4
|
import { requireApiKey } from './shared.js';
|
|
5
|
-
export const VERSION = '0.
|
|
5
|
+
export const VERSION = '0.3.0';
|
|
6
6
|
/** `eval "$(bizrouter env)"` exports the saved key for tools configured by `bizrouter setup`. */
|
|
7
7
|
export function envCommand() {
|
|
8
8
|
const key = requireApiKey();
|
|
@@ -32,10 +32,12 @@ ${c.bold('시작하기')}
|
|
|
32
32
|
bizrouter opencode OpenCode를 BizRouter로 실행합니다
|
|
33
33
|
bizrouter hermes Hermes를 BizRouter로 실행합니다
|
|
34
34
|
도구는 평소처럼 실행되고, 이번 실행의 모델 호출·과금만 BizRouter를 거칩니다. 설정 파일은 바뀌지 않습니다.
|
|
35
|
+
bizrouter search "질문" 모델이 법령·공시·통계·웹 검색 등 실제 데이터를 찾아 출처와 함께 답합니다 (Search)
|
|
35
36
|
|
|
36
37
|
${c.bold('실행 옵션')} (실행 명령 뒤, 도구 자체 옵션 앞에 씁니다)
|
|
37
38
|
--model <ID> BizRouter 모델 ID (예: anthropic/claude-sonnet-5, openai/gpt-5.5)
|
|
38
39
|
--reasoning-effort <단계> minimal · low · medium · high · xhigh (Codex·OpenCode)
|
|
40
|
+
--search 모델에 Search를 켭니다(<모델>:search · OpenCode·Hermes). Claude Code·Codex는 MCP 도구 bizrouter_search로
|
|
39
41
|
--closed-network Claude Code의 부수 트래픽(버전 확인·텔레메트리)을 끕니다
|
|
40
42
|
--no-mcp 콘솔 MCP 서버(bizrouter)를 에이전트에 연결하지 않습니다
|
|
41
43
|
--dry-run 실행하지 않고 넘겨줄 환경 변수와 명령만 보여줍니다
|
|
@@ -54,8 +56,9 @@ ${c.bold('콘솔 관리')} (브라우저 승인 로그인 뒤 · 모두 --json
|
|
|
54
56
|
|
|
55
57
|
${c.bold('그 밖에')}
|
|
56
58
|
bizrouter models [검색어] 이 키로 쓸 수 있는 모델과 원화 단가
|
|
59
|
+
bizrouter search "질문" Search 호출 (--model, --max-tokens, --json, --no-stream · 답은 stdout, 출처는 stderr)
|
|
57
60
|
bizrouter docs [주제] 에이전트 매뉴얼 · 개발 문서 (--list, --full)
|
|
58
|
-
bizrouter mcp MCP 서버(stdio)로 실행 — 에이전트가
|
|
61
|
+
bizrouter mcp MCP 서버(stdio)로 실행 — 에이전트가 콘솔·Search를 도구로 다룹니다
|
|
59
62
|
bizrouter setup <도구> claude · codex · opencode 설정 파일에 BizRouter를 영구 등록
|
|
60
63
|
bizrouter doctor 연결·키·세션·설치·충돌 점검
|
|
61
64
|
bizrouter auth 지금 어떤 계정·키가 쓰이는지 확인
|
package/dist/commands/models.js
CHANGED
|
@@ -53,5 +53,6 @@ export async function modelsCommand(argv) {
|
|
|
53
53
|
print();
|
|
54
54
|
print(c.dim(`${rows.length}개 · 원화 단가는 100만 토큰당 · 환율 ${catalog.exchange_rate}원/달러 · ${catalog.from_cache ? '캐시(최대 10분)' : '지금 조회'}`));
|
|
55
55
|
print(c.dim('예: bizrouter codex --model openai/gpt-5.5 · bizrouter claude --model anthropic/claude-opus-5'));
|
|
56
|
+
print(c.dim('Search: 어느 채팅 모델이든 뒤에 :search 를 붙이면 실제 데이터를 조회해 답합니다 (예: bizrouter search "질문" --model openai/gpt-5.5)'));
|
|
56
57
|
return 0;
|
|
57
58
|
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describeSearch, search, SEARCH_DEFAULT_MODEL, SEARCH_MIN_MAX_TOKENS, searchStream, stripSearchSuffix } from '../search.js';
|
|
2
|
+
import { c, CliError, info, print, warn } from '../ui.js';
|
|
3
|
+
import { requireApiKey } from './shared.js';
|
|
4
|
+
export const SEARCH_USAGE = 'bizrouter search "질문" [--model ID] [--max-tokens N] [--json] [--no-stream]';
|
|
5
|
+
export function parseSearchFlags(argv) {
|
|
6
|
+
const flags = { json: false, stream: true, help: false };
|
|
7
|
+
const words = [];
|
|
8
|
+
let i = 0;
|
|
9
|
+
const takeValue = (flag) => {
|
|
10
|
+
const value = argv[i + 1];
|
|
11
|
+
if (value === undefined || value.startsWith('-'))
|
|
12
|
+
throw new CliError(`${flag} 뒤에 값이 필요합니다.`, { hint: SEARCH_USAGE });
|
|
13
|
+
i += 2;
|
|
14
|
+
return value;
|
|
15
|
+
};
|
|
16
|
+
while (i < argv.length) {
|
|
17
|
+
const token = argv[i];
|
|
18
|
+
const eq = token.indexOf('=');
|
|
19
|
+
const name = token.startsWith('--') && eq > 0 ? token.slice(0, eq) : token;
|
|
20
|
+
const inline = token.startsWith('--') && eq > 0 ? token.slice(eq + 1) : undefined;
|
|
21
|
+
const value = (flag) => (inline !== undefined ? ((i += 1), inline) : takeValue(flag));
|
|
22
|
+
switch (name) {
|
|
23
|
+
case '--model':
|
|
24
|
+
case '-m':
|
|
25
|
+
flags.model = value(name);
|
|
26
|
+
continue;
|
|
27
|
+
case '--max-tokens': {
|
|
28
|
+
const raw = value(name);
|
|
29
|
+
const n = Number(raw);
|
|
30
|
+
if (!Number.isInteger(n) || n <= 0)
|
|
31
|
+
throw new CliError(`--max-tokens 값은 양의 정수여야 합니다: ${raw}`, { hint: SEARCH_USAGE });
|
|
32
|
+
flags.maxTokens = n;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
case '--json':
|
|
36
|
+
flags.json = true;
|
|
37
|
+
i += 1;
|
|
38
|
+
continue;
|
|
39
|
+
case '--no-stream':
|
|
40
|
+
flags.stream = false;
|
|
41
|
+
i += 1;
|
|
42
|
+
continue;
|
|
43
|
+
case '--help':
|
|
44
|
+
case '-h':
|
|
45
|
+
flags.help = true;
|
|
46
|
+
i += 1;
|
|
47
|
+
continue;
|
|
48
|
+
default:
|
|
49
|
+
if (token.startsWith('-') && token.length > 1)
|
|
50
|
+
throw new CliError(`알 수 없는 옵션입니다: ${token}`, { hint: SEARCH_USAGE });
|
|
51
|
+
words.push(token);
|
|
52
|
+
i += 1;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (words.length)
|
|
56
|
+
flags.query = words.join(' ');
|
|
57
|
+
return flags;
|
|
58
|
+
}
|
|
59
|
+
async function readStdin() {
|
|
60
|
+
const chunks = [];
|
|
61
|
+
for await (const chunk of process.stdin)
|
|
62
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
63
|
+
return Buffer.concat(chunks).toString('utf8').trim();
|
|
64
|
+
}
|
|
65
|
+
export function searchHelpText() {
|
|
66
|
+
return `${c.bold('bizrouter search')} — 모델이 실제 데이터를 찾아 출처와 함께 답합니다 (BizRouter Search · POST /v1/search)
|
|
67
|
+
|
|
68
|
+
${SEARCH_USAGE}
|
|
69
|
+
echo "질문" | bizrouter search
|
|
70
|
+
|
|
71
|
+
--model <ID> 답을 쓸 모델. 기본 ${SEARCH_DEFAULT_MODEL}(스마트 라우팅 기본 정책). 예: openai/gpt-5.5, anthropic/claude-sonnet-5
|
|
72
|
+
--max-tokens <N> 출력 상한. 기본 ${SEARCH_MIN_MAX_TOKENS} (도구 결과가 입력에 들어가므로 이보다 작게 잡지 않는 편이 좋습니다)
|
|
73
|
+
--json 화면 출력 대신 응답 JSON 전체 (choices · usage · bizrouter_search)
|
|
74
|
+
--no-stream 답을 한 번에 받아 출력합니다 (기본은 글자가 오는 대로 출력)
|
|
75
|
+
|
|
76
|
+
법령·국회, 기업 공시·재무, 부동산 실거래, 국가통계·금리, 교통, 논문·임상, 웹 검색·뉴스, 날씨, 지도, 미국 주식 등
|
|
77
|
+
50종이 넘는 출처를 질문에 맞게 골라 조회합니다. 데이터가 필요 없는 질문은 도구 없이 답합니다. 조회 자체는 무료이고 모델 토큰만 과금됩니다.
|
|
78
|
+
답은 stdout, 출처·비용은 stderr 로 나옵니다. 문서: https://bizrouter.ai/docs/search
|
|
79
|
+
`;
|
|
80
|
+
}
|
|
81
|
+
export async function searchCommand(argv) {
|
|
82
|
+
const flags = parseSearchFlags(argv);
|
|
83
|
+
if (flags.help) {
|
|
84
|
+
print(searchHelpText());
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
let query = flags.query;
|
|
88
|
+
if (!query && !process.stdin.isTTY)
|
|
89
|
+
query = await readStdin();
|
|
90
|
+
if (!query)
|
|
91
|
+
throw new CliError('질문이 없습니다.', { hint: SEARCH_USAGE });
|
|
92
|
+
const apiKey = requireApiKey();
|
|
93
|
+
if (flags.maxTokens !== undefined && flags.maxTokens < SEARCH_MIN_MAX_TOKENS) {
|
|
94
|
+
warn(`--max-tokens ${flags.maxTokens}은 작은 편입니다. 도구 결과가 입력에 포함되므로 답이 잘리면 ${SEARCH_MIN_MAX_TOKENS} 이상으로 올리십시오.`);
|
|
95
|
+
}
|
|
96
|
+
const model = flags.model ? stripSearchSuffix(flags.model) : undefined;
|
|
97
|
+
const request = { query, model, max_tokens: flags.maxTokens };
|
|
98
|
+
if (flags.json) {
|
|
99
|
+
const result = await search(apiKey, request);
|
|
100
|
+
print(JSON.stringify(result.raw, null, 2));
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
103
|
+
const streaming = flags.stream && Boolean(process.stdout.isTTY);
|
|
104
|
+
info(`데이터를 찾아 답을 씁니다 · 모델 ${model ?? SEARCH_DEFAULT_MODEL} (보통 3~20초, 출처가 느리면 더 걸립니다)`);
|
|
105
|
+
let result;
|
|
106
|
+
if (streaming) {
|
|
107
|
+
result = await searchStream(apiKey, request, (delta) => process.stdout.write(delta));
|
|
108
|
+
if (result.answer && !result.answer.endsWith('\n'))
|
|
109
|
+
process.stdout.write('\n');
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
result = await search(apiKey, request);
|
|
113
|
+
print(result.answer);
|
|
114
|
+
}
|
|
115
|
+
if (!result.answer)
|
|
116
|
+
warn('모델이 빈 답을 돌려주었습니다. --max-tokens 를 올리거나 다른 모델로 다시 시도하십시오.');
|
|
117
|
+
info(describeSearch(result));
|
|
118
|
+
return 0;
|
|
119
|
+
}
|
package/dist/harness/hermes.js
CHANGED
|
@@ -34,6 +34,9 @@ export function buildHermesPlan(options) {
|
|
|
34
34
|
CUSTOM_BASE_URL: `${options.apiBase}/v1`,
|
|
35
35
|
CUSTOM_API_KEY: options.apiKey,
|
|
36
36
|
},
|
|
37
|
-
notes: [
|
|
37
|
+
notes: [
|
|
38
|
+
`Hermes → custom provider ${options.apiBase}/v1 · 모델 ${options.model}`,
|
|
39
|
+
...(options.model.endsWith(':search') ? ['BizRouter Search 켬: 모델이 답하기 전에 법령·공시·통계·웹 검색 등 실제 데이터를 조회합니다 (조회 무료 · 모델 토큰만 과금).'] : []),
|
|
40
|
+
],
|
|
38
41
|
};
|
|
39
42
|
}
|
package/dist/harness/opencode.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { chatModels } from '../api.js';
|
|
2
2
|
import { ENV_KEY_NAME } from '../config.js';
|
|
3
|
+
import { SEARCH_MODEL_SUFFIX } from '../search.js';
|
|
3
4
|
import { opencodeMcpBlock } from './mcp.js';
|
|
4
5
|
export const OPENCODE_PROVIDER_ID = 'bizrouter';
|
|
5
6
|
// The generic OpenAI-compatible provider against /v1 finishes turns cleanly.
|
|
@@ -31,10 +32,25 @@ export function opencodeProvider(models, reasoningEffort, apiBase = 'https://api
|
|
|
31
32
|
models: entries,
|
|
32
33
|
};
|
|
33
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* A pinned model that is not a catalog row (`bizrouter/route`, `openai/gpt-5.5:search`)
|
|
37
|
+
* still needs an entry, or OpenCode refuses the `model` setting. Limits come
|
|
38
|
+
* from the underlying catalog row when there is one.
|
|
39
|
+
*/
|
|
40
|
+
export function withPinnedModel(provider, model, models) {
|
|
41
|
+
const entries = provider.models;
|
|
42
|
+
if (entries[model])
|
|
43
|
+
return provider;
|
|
44
|
+
const base = model.endsWith(SEARCH_MODEL_SUFFIX) ? model.slice(0, -SEARCH_MODEL_SUFFIX.length) : model;
|
|
45
|
+
const row = entries[base] ?? models.find((m) => m.id === base);
|
|
46
|
+
const limit = row ? { context: 'limit' in row ? row.limit.context : row.context_length, output: 'limit' in row ? row.limit.output : row.max_output_tokens } : { context: 200_000, output: 64_000 };
|
|
47
|
+
const label = model.endsWith(SEARCH_MODEL_SUFFIX) ? `${row?.name ?? base} · Search` : model.startsWith('bizrouter/route') ? 'Smart Routing' : model;
|
|
48
|
+
return { ...provider, models: { ...entries, [model]: { name: label, limit } } };
|
|
49
|
+
}
|
|
34
50
|
export function opencodeConfigContent(options) {
|
|
35
51
|
return JSON.stringify({
|
|
36
52
|
$schema: 'https://opencode.ai/config.json',
|
|
37
|
-
provider: { [OPENCODE_PROVIDER_ID]: opencodeProvider(options.models, options.reasoningEffort, options.apiBase) },
|
|
53
|
+
provider: { [OPENCODE_PROVIDER_ID]: withPinnedModel(opencodeProvider(options.models, options.reasoningEffort, options.apiBase), options.model, options.models) },
|
|
38
54
|
model: `${OPENCODE_PROVIDER_ID}/${options.model}`,
|
|
39
55
|
...(options.mcp ? { mcp: opencodeMcpBlock(options.mcp) } : {}),
|
|
40
56
|
});
|
|
@@ -54,6 +70,7 @@ export function buildOpencodePlan(options) {
|
|
|
54
70
|
},
|
|
55
71
|
notes: [
|
|
56
72
|
`OpenCode → provider "${OPENCODE_PROVIDER_ID}" (${chatModels(options.models).length}개 모델) · 기본 모델 ${options.model}`,
|
|
73
|
+
...(options.model.endsWith(SEARCH_MODEL_SUFFIX) ? ['BizRouter Search 켬: 모델이 답하기 전에 법령·공시·통계·웹 검색 등 실제 데이터를 조회합니다 (조회 무료 · 모델 토큰만 과금).'] : []),
|
|
57
74
|
...(options.mcp ? ['콘솔 MCP 서버 「bizrouter」 를 이 세션에 연결합니다 (끄기: --no-mcp).'] : []),
|
|
58
75
|
],
|
|
59
76
|
};
|
package/dist/index.js
CHANGED
|
@@ -11,6 +11,8 @@ import { bizrouterMcpServer } from './harness/mcp.js';
|
|
|
11
11
|
import { doctorCommand } from './commands/doctor.js';
|
|
12
12
|
import { envCommand, helpText, updateCommand, VERSION } from './commands/misc.js';
|
|
13
13
|
import { modelsCommand } from './commands/models.js';
|
|
14
|
+
import { searchCommand } from './commands/search.js';
|
|
15
|
+
import { isVirtualModelId, searchModelFor, stripSearchSuffix } from './search.js';
|
|
14
16
|
import { setupCommand } from './commands/setup.js';
|
|
15
17
|
import { requireApiKey } from './commands/shared.js';
|
|
16
18
|
import { apiBase, loadUserConfig, resolveSession } from './config.js';
|
|
@@ -25,8 +27,8 @@ import { CliError, fail, info, maskKey, print, warn } from './ui.js';
|
|
|
25
27
|
const LAUNCH_HELP = {
|
|
26
28
|
claude: 'bizrouter claude [--model ID] [--closed-network] [--dry-run] [claude 옵션…]',
|
|
27
29
|
codex: 'bizrouter codex [--model ID] [--reasoning-effort 단계] [--dry-run] [codex 옵션…]',
|
|
28
|
-
opencode: 'bizrouter opencode [--model ID] [--reasoning-effort 단계] [--dry-run] [opencode 옵션…]',
|
|
29
|
-
hermes: 'bizrouter hermes [--model ID] [--dry-run] [hermes 옵션…]',
|
|
30
|
+
opencode: 'bizrouter opencode [--model ID] [--reasoning-effort 단계] [--search] [--dry-run] [opencode 옵션…]',
|
|
31
|
+
hermes: 'bizrouter hermes [--model ID] [--search] [--dry-run] [hermes 옵션…]',
|
|
30
32
|
};
|
|
31
33
|
async function resolveModel(harness, args, apiKey) {
|
|
32
34
|
const pinned = args.model ?? loadUserConfig().defaults?.[harness];
|
|
@@ -41,8 +43,10 @@ async function resolveModel(harness, args, apiKey) {
|
|
|
41
43
|
warn(`모델 목록을 가져오지 못해 기본값으로 진행합니다. (${error instanceof Error ? error.message : String(error)})`);
|
|
42
44
|
}
|
|
43
45
|
if (pinned) {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
+
// `bizrouter/route`, `bizrouter/search` and `<id>:search` are policies layered on catalog rows, not rows themselves.
|
|
47
|
+
const lookup = stripSearchSuffix(pinned);
|
|
48
|
+
if (models.length && !isVirtualModelId(pinned) && !models.some((m) => m.id === lookup)) {
|
|
49
|
+
const suggestions = suggestModels(models, lookup);
|
|
46
50
|
warn(`「${pinned}」 는 이 키로 쓸 수 있는 모델 목록에 없습니다.${suggestions.length ? ` 비슷한 모델: ${suggestions.join(', ')}` : ''}`);
|
|
47
51
|
info('그대로 시도합니다. 키의 「API key 사용 범위」나 조직의 모델 정책이 막고 있으면 요청이 거절됩니다.');
|
|
48
52
|
}
|
|
@@ -69,7 +73,19 @@ async function launch(harness, argv) {
|
|
|
69
73
|
}
|
|
70
74
|
const apiKey = requireApiKey();
|
|
71
75
|
const base = apiBase();
|
|
72
|
-
const
|
|
76
|
+
const resolved = await resolveModel(harness, args, apiKey);
|
|
77
|
+
const { models } = resolved;
|
|
78
|
+
let model = resolved.model;
|
|
79
|
+
if (args.search || (model && model.endsWith(':search'))) {
|
|
80
|
+
// Search rides on Chat Completions. Claude Code speaks Messages and Codex speaks Responses,
|
|
81
|
+
// so for them Search is reachable only through the MCP tool `bizrouter_search`.
|
|
82
|
+
if (harness === 'claude' || harness === 'codex') {
|
|
83
|
+
throw new CliError(`${harness === 'claude' ? 'Claude Code' : 'Codex'}는 모델 이름에 :search 를 붙이는 방식(Chat Completions 전용)을 쓸 수 없습니다.`, {
|
|
84
|
+
hint: '로그인돼 있으면 에이전트에 MCP 도구 bizrouter_search 가 함께 연결되어 세션 안에서 "BizRouter Search 로 찾아봐" 하면 됩니다. 터미널에서는 `bizrouter search "질문"`, OpenCode·Hermes 는 `--search` 를 그대로 쓸 수 있습니다.',
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
model = searchModelFor(model ?? FALLBACK_DEFAULT[harness]);
|
|
88
|
+
}
|
|
73
89
|
// With a console session the agent gets the console as MCP tools; without one there is nothing to expose.
|
|
74
90
|
const mcp = !args.noMcp && harness !== 'hermes' && resolveSession().source !== 'none' ? bizrouterMcpServer() : undefined;
|
|
75
91
|
if (!args.noMcp && harness !== 'hermes' && !mcp && !args.dryRun)
|
|
@@ -162,6 +178,8 @@ async function main(argv) {
|
|
|
162
178
|
return mcpCommand();
|
|
163
179
|
case 'models':
|
|
164
180
|
return modelsCommand(rest);
|
|
181
|
+
case 'search':
|
|
182
|
+
return searchCommand(rest);
|
|
165
183
|
case 'setup':
|
|
166
184
|
return setupCommand(rest);
|
|
167
185
|
case 'doctor':
|
package/dist/mcp.js
CHANGED
|
@@ -8,6 +8,7 @@ import { resolveCredential, resolveSession } from './config.js';
|
|
|
8
8
|
import { ConsoleApiError, consoleRequest, fetchOpenApi, fetchProfile, findOperation, HTTP_METHODS, listOperations, MUTATING_METHODS, normalizeConsolePath } from './console.js';
|
|
9
9
|
import { findDocsPages, loadLlmsFull, parseLlmsFull, readAgentManual } from './commands/docs.js';
|
|
10
10
|
import { VERSION } from './commands/misc.js';
|
|
11
|
+
import { search, stripSearchSuffix } from './search.js';
|
|
11
12
|
import { CliError } from './ui.js';
|
|
12
13
|
export const MCP_PROTOCOL_VERSION = '2025-06-18';
|
|
13
14
|
export const MCP_SERVER_NAME = 'bizrouter';
|
|
@@ -53,13 +54,27 @@ export const TOOLS = [
|
|
|
53
54
|
},
|
|
54
55
|
{
|
|
55
56
|
name: 'bizrouter_docs',
|
|
56
|
-
description: 'BizRouter documentation. Without `topic` returns the agent manual (how the CLI, credentials, console API and safety rules work). With `topic` returns matching developer-docs pages (e.g. "chat-completions", "smart-routing", "cli", "인증"). `list: true` returns the page index.',
|
|
57
|
+
description: 'BizRouter documentation. Without `topic` returns the agent manual (how the CLI, credentials, console API and safety rules work). With `topic` returns matching developer-docs pages (e.g. "chat-completions", "smart-routing", "cli", "search", "인증"). `list: true` returns the page index.',
|
|
57
58
|
inputSchema: {
|
|
58
59
|
type: 'object',
|
|
59
60
|
properties: { topic: { type: 'string' }, list: { type: 'boolean' } },
|
|
60
61
|
additionalProperties: false,
|
|
61
62
|
},
|
|
62
63
|
},
|
|
64
|
+
{
|
|
65
|
+
name: 'bizrouter_search',
|
|
66
|
+
description: 'BizRouter Search (POST /v1/search): ask a question and a model looks the facts up in 50+ live sources before answering — Korean law and National Assembly bills, corporate filings and financials (DART/FSC), real-estate transactions, national statistics and BOK rates, transport (flights, transit), papers and clinical trials, web search and news, weather, maps/places, US market data — then answers with sources. Use it whenever an answer needs current or authoritative data instead of model memory (Korean public data especially). Uses the stored API key; billed as model tokens only, lookups are free. Returns the answer plus `sources`, `calls`, `rounds` and `routed_model`.',
|
|
67
|
+
inputSchema: {
|
|
68
|
+
type: 'object',
|
|
69
|
+
properties: {
|
|
70
|
+
query: { type: 'string', description: 'The question, in the language the answer should be in.' },
|
|
71
|
+
model: { type: 'string', description: 'Model to write the answer with (default bizrouter/route = the organization\'s Smart Routing policy). Any chat model id, e.g. "openai/gpt-5.5".' },
|
|
72
|
+
max_tokens: { type: 'integer', description: 'Output cap. Default 1500; tool results share the context, so do not go much lower.' },
|
|
73
|
+
},
|
|
74
|
+
required: ['query'],
|
|
75
|
+
additionalProperties: false,
|
|
76
|
+
},
|
|
77
|
+
},
|
|
63
78
|
];
|
|
64
79
|
function text(value) {
|
|
65
80
|
return { content: [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }] };
|
|
@@ -142,6 +157,26 @@ export async function callTool(name, args) {
|
|
|
142
157
|
}
|
|
143
158
|
return text(readAgentManual());
|
|
144
159
|
}
|
|
160
|
+
case 'bizrouter_search': {
|
|
161
|
+
const cred = resolveCredential();
|
|
162
|
+
if (!cred.apiKey)
|
|
163
|
+
return errorResult('No API key stored. Ask the user to run `bizrouter login`.');
|
|
164
|
+
if (typeof args.query !== 'string' || !args.query.trim())
|
|
165
|
+
return errorResult('`query` is required.');
|
|
166
|
+
const model = typeof args.model === 'string' && args.model.trim() ? stripSearchSuffix(args.model.trim()) : undefined;
|
|
167
|
+
const maxTokens = typeof args.max_tokens === 'number' && Number.isInteger(args.max_tokens) && args.max_tokens > 0 ? args.max_tokens : undefined;
|
|
168
|
+
const result = await search(cred.apiKey, { query: args.query, model, max_tokens: maxTokens });
|
|
169
|
+
const meta = result.meta;
|
|
170
|
+
return text({
|
|
171
|
+
answer: result.answer,
|
|
172
|
+
sources: meta?.sources ?? [],
|
|
173
|
+
calls: meta?.calls ?? [],
|
|
174
|
+
rounds: meta?.rounds ?? 1,
|
|
175
|
+
routed_model: meta?.routed_model ?? result.model ?? null,
|
|
176
|
+
usage: result.usage ?? null,
|
|
177
|
+
note: meta && meta.calls.length ? undefined : 'No data source was consulted; the answer comes from the model alone.',
|
|
178
|
+
});
|
|
179
|
+
}
|
|
145
180
|
default:
|
|
146
181
|
return errorResult(`Unknown tool: ${name}`);
|
|
147
182
|
}
|
|
@@ -165,7 +200,7 @@ export async function handleMessage(message) {
|
|
|
165
200
|
protocolVersion: typeof message.params?.protocolVersion === 'string' ? message.params.protocolVersion : MCP_PROTOCOL_VERSION,
|
|
166
201
|
capabilities: { tools: { listChanged: false } },
|
|
167
202
|
serverInfo: { name: MCP_SERVER_NAME, version: VERSION },
|
|
168
|
-
instructions: 'BizRouter console and
|
|
203
|
+
instructions: 'BizRouter console, docs and Search. Call bizrouter_whoami first. Reads are free; for POST/PUT/PATCH/DELETE ask the user, then pass confirm: true. Never paste a full API key into files or replies. When an answer needs current or authoritative data (Korean law, filings, statistics, prices, news…), call bizrouter_search instead of guessing.',
|
|
169
204
|
});
|
|
170
205
|
case 'notifications/initialized':
|
|
171
206
|
case 'notifications/cancelled':
|
package/dist/search.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// BizRouter Search (`POST /v1/search`): the model looks real data up in
|
|
2
|
+
// agent.store's public and web sources (law, filings, statistics, transport,
|
|
3
|
+
// papers, web search, …) before it answers. This module is the thin client
|
|
4
|
+
// shared by `bizrouter search` and the MCP tool `bizrouter_search`.
|
|
5
|
+
import { apiBase } from './config.js';
|
|
6
|
+
import { CliError } from './ui.js';
|
|
7
|
+
/** Virtual model that follows the organization's default Smart Routing policy. */
|
|
8
|
+
export const SEARCH_DEFAULT_MODEL = 'bizrouter/route';
|
|
9
|
+
/** `openai/gpt-5.5:search` turns Search on for a Chat Completions call. */
|
|
10
|
+
export const SEARCH_MODEL_SUFFIX = ':search';
|
|
11
|
+
/** Time to wait for the first byte; Search rounds can take 30 s when a source is slow. */
|
|
12
|
+
const SEARCH_TIMEOUT_MS = 180_000;
|
|
13
|
+
/** Below this the tool results tend to leave no room for the answer. */
|
|
14
|
+
export const SEARCH_MIN_MAX_TOKENS = 1500;
|
|
15
|
+
export function searchModelFor(base) {
|
|
16
|
+
return base.endsWith(SEARCH_MODEL_SUFFIX) ? base : `${base}${SEARCH_MODEL_SUFFIX}`;
|
|
17
|
+
}
|
|
18
|
+
/** `openai/gpt-5.5:search` → `openai/gpt-5.5`; ids without the suffix are returned as-is. */
|
|
19
|
+
export function stripSearchSuffix(id) {
|
|
20
|
+
return id.endsWith(SEARCH_MODEL_SUFFIX) ? id.slice(0, -SEARCH_MODEL_SUFFIX.length) : id;
|
|
21
|
+
}
|
|
22
|
+
/** `bizrouter/route[/slug]` and `bizrouter/search[/slug]` are policies, not catalog rows. */
|
|
23
|
+
export function isVirtualModelId(id) {
|
|
24
|
+
return /^bizrouter\/(route|search)(\/|$)/.test(stripSearchSuffix(id));
|
|
25
|
+
}
|
|
26
|
+
function buildBody(request, stream) {
|
|
27
|
+
const body = {};
|
|
28
|
+
if (request.query)
|
|
29
|
+
body.query = request.query;
|
|
30
|
+
if (request.messages?.length)
|
|
31
|
+
body.messages = request.messages;
|
|
32
|
+
if (request.model)
|
|
33
|
+
body.model = request.model;
|
|
34
|
+
body.max_tokens = request.max_tokens ?? SEARCH_MIN_MAX_TOKENS;
|
|
35
|
+
if (stream)
|
|
36
|
+
body.stream = true;
|
|
37
|
+
return body;
|
|
38
|
+
}
|
|
39
|
+
async function post(apiKey, body) {
|
|
40
|
+
const controller = new AbortController();
|
|
41
|
+
const timer = setTimeout(() => controller.abort(), SEARCH_TIMEOUT_MS);
|
|
42
|
+
let response;
|
|
43
|
+
try {
|
|
44
|
+
response = await fetch(`${apiBase()}/v1/search`, {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'User-Agent': 'bizrouter-cli' },
|
|
47
|
+
body: JSON.stringify(body),
|
|
48
|
+
signal: controller.signal,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
clearTimeout(timer);
|
|
53
|
+
const reason = error instanceof Error && error.name === 'AbortError' ? `${SEARCH_TIMEOUT_MS / 1000}초 안에 응답이 오지 않았습니다` : String(error);
|
|
54
|
+
throw new CliError(`BizRouter Search(${apiBase()}/v1/search)에 연결할 수 없습니다: ${reason}`);
|
|
55
|
+
}
|
|
56
|
+
// The timer only guards the connection; a streaming body is read by the caller.
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
if (response.status === 401 || response.status === 403) {
|
|
59
|
+
throw new CliError('API 키가 유효하지 않습니다.', { hint: '`bizrouter login`으로 키를 다시 저장하십시오.' });
|
|
60
|
+
}
|
|
61
|
+
if (!response.ok) {
|
|
62
|
+
const text = await response.text().catch(() => '');
|
|
63
|
+
let message = `BizRouter Search가 ${response.status}를 돌려주었습니다.`;
|
|
64
|
+
try {
|
|
65
|
+
const parsed = JSON.parse(text);
|
|
66
|
+
const detail = typeof parsed.error === 'string' ? parsed.error : parsed.error?.message;
|
|
67
|
+
if (detail)
|
|
68
|
+
message = `${message} ${detail}`;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
if (text)
|
|
72
|
+
message = `${message} ${text.slice(0, 300)}`;
|
|
73
|
+
}
|
|
74
|
+
throw new CliError(message);
|
|
75
|
+
}
|
|
76
|
+
return response;
|
|
77
|
+
}
|
|
78
|
+
/** One request, one JSON answer. */
|
|
79
|
+
export async function search(apiKey, request) {
|
|
80
|
+
const response = await post(apiKey, buildBody(request, false));
|
|
81
|
+
const json = (await response.json());
|
|
82
|
+
return {
|
|
83
|
+
answer: json.choices?.[0]?.message?.content ?? '',
|
|
84
|
+
model: json.model,
|
|
85
|
+
meta: json.bizrouter_search,
|
|
86
|
+
usage: json.usage,
|
|
87
|
+
raw: json,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Parse the `data:` payloads of an SSE stream. Exposed for tests; the gateway
|
|
92
|
+
* sends `: connected` and keep-alive comments before the first token, then
|
|
93
|
+
* normal chat.completion.chunk frames, then a final chunk carrying `usage` and
|
|
94
|
+
* `bizrouter_search`, then `[DONE]`.
|
|
95
|
+
*/
|
|
96
|
+
export function* sseData(buffer) {
|
|
97
|
+
for (const line of buffer.split('\n')) {
|
|
98
|
+
const trimmed = line.replace(/\r$/, '');
|
|
99
|
+
if (trimmed.startsWith('data:'))
|
|
100
|
+
yield trimmed.slice(5).trim();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** Streaming request; `onText` receives content deltas as they arrive. */
|
|
104
|
+
export async function searchStream(apiKey, request, onText) {
|
|
105
|
+
const response = await post(apiKey, buildBody(request, true));
|
|
106
|
+
if (!response.body)
|
|
107
|
+
throw new CliError('BizRouter Search 응답에 본문이 없습니다.');
|
|
108
|
+
const decoder = new TextDecoder();
|
|
109
|
+
const reader = response.body.getReader();
|
|
110
|
+
let pending = '';
|
|
111
|
+
let answer = '';
|
|
112
|
+
let model;
|
|
113
|
+
let meta;
|
|
114
|
+
let usage;
|
|
115
|
+
const consume = (data) => {
|
|
116
|
+
if (data === '[DONE]' || !data)
|
|
117
|
+
return;
|
|
118
|
+
let chunk;
|
|
119
|
+
try {
|
|
120
|
+
chunk = JSON.parse(data);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
model = chunk.model ?? model;
|
|
126
|
+
const delta = chunk.choices?.[0]?.delta?.content;
|
|
127
|
+
if (delta) {
|
|
128
|
+
answer += delta;
|
|
129
|
+
onText(delta);
|
|
130
|
+
}
|
|
131
|
+
if (chunk.usage)
|
|
132
|
+
usage = chunk.usage;
|
|
133
|
+
if (chunk.bizrouter_search)
|
|
134
|
+
meta = chunk.bizrouter_search;
|
|
135
|
+
};
|
|
136
|
+
for (;;) {
|
|
137
|
+
const { value, done } = await reader.read();
|
|
138
|
+
if (done)
|
|
139
|
+
break;
|
|
140
|
+
pending += decoder.decode(value, { stream: true });
|
|
141
|
+
const lastBreak = pending.lastIndexOf('\n');
|
|
142
|
+
if (lastBreak < 0)
|
|
143
|
+
continue;
|
|
144
|
+
const complete = pending.slice(0, lastBreak + 1);
|
|
145
|
+
pending = pending.slice(lastBreak + 1);
|
|
146
|
+
for (const data of sseData(complete))
|
|
147
|
+
consume(data);
|
|
148
|
+
}
|
|
149
|
+
for (const data of sseData(pending))
|
|
150
|
+
consume(data);
|
|
151
|
+
return { answer, model, meta, usage, raw: undefined };
|
|
152
|
+
}
|
|
153
|
+
/** Korean one-liner about what was looked up, for stderr after the answer. */
|
|
154
|
+
export function describeSearch(result) {
|
|
155
|
+
const meta = result.meta;
|
|
156
|
+
if (!meta)
|
|
157
|
+
return '출처 정보가 없습니다.';
|
|
158
|
+
const okCalls = meta.calls.filter((c) => c.status === 'ok').length;
|
|
159
|
+
const failed = meta.calls.length - okCalls;
|
|
160
|
+
const label = (s) => (s.name && s.name !== s.slug ? `${s.name}(${s.slug})` : s.slug);
|
|
161
|
+
const parts = [];
|
|
162
|
+
if (meta.calls.length) {
|
|
163
|
+
// `sources` also lists sources that were only offered as hints; name the ones actually consulted.
|
|
164
|
+
const consulted = new Set(meta.calls.map((c) => c.source ?? c.tool.split('__')[0]));
|
|
165
|
+
const names = meta.sources.filter((s) => consulted.has(s.slug)).map(label);
|
|
166
|
+
parts.push(names.length ? `출처 ${names.join(', ')}` : `출처 ${[...consulted].join(', ')}`);
|
|
167
|
+
parts.push(`조회 ${okCalls}회${failed ? ` (실패 ${failed})` : ''}`);
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
parts.push('데이터 조회 없이 답했습니다');
|
|
171
|
+
}
|
|
172
|
+
parts.push(`라운드 ${meta.rounds}`);
|
|
173
|
+
const routed = meta.routed_model ?? result.model;
|
|
174
|
+
if (routed)
|
|
175
|
+
parts.push(`모델 ${routed}`);
|
|
176
|
+
if (result.usage?.cost !== undefined)
|
|
177
|
+
parts.push(`비용 ${formatCost(result.usage.cost)}`);
|
|
178
|
+
return parts.join(' · ');
|
|
179
|
+
}
|
|
180
|
+
function formatCost(krw) {
|
|
181
|
+
if (krw >= 10)
|
|
182
|
+
return `${Math.round(krw).toLocaleString('ko-KR')}원`;
|
|
183
|
+
return `${krw.toLocaleString('ko-KR', { maximumFractionDigits: 1 })}원`;
|
|
184
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bizrouter",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "BizRouter CLI - run Claude Code, Codex, OpenCode, and Hermes through BizRouter,
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "BizRouter CLI - run Claude Code, Codex, OpenCode, and Hermes through BizRouter, operate the BizRouter console (API keys, usage, policies, audit logs) from the terminal or as an MCP server, and ask BizRouter Search for answers grounded in 50+ live data sources",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|