bizrouter 0.3.3 → 0.3.4

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 CHANGED
@@ -186,8 +186,15 @@ server named `bizrouter` inside Claude Code, Codex and OpenCode for that run
186
186
  and `bizrouter_search` with the same rules as above — `bizrouter_api` refuses
187
187
  mutating calls unless `confirm: true` is passed after the user agreed.
188
188
 
189
- Claude Code can only run Anthropic models (it always sends Anthropic-only
190
- fields); use Codex/OpenCode/Hermes for GPT or Gemini.
189
+ Claude Code runs any chat model in the catalog. Its `/model` picker lists
190
+ Claude models by their code and every other chat model as
191
+ `claude-code/<code>` (Claude Code only shows ids containing `claude` or
192
+ `anthropic`); `--model <code>` without the prefix works too. 1M-window models
193
+ carry Claude Code's `[1m]` marker so it accounts for the full window. On a
194
+ non-Anthropic model the gateway drops the Anthropic-only hints Claude Code
195
+ always sends (`cache_control`, `thinking`, `context_management`, earlier
196
+ thinking blocks) and reports dropped breakpoints in
197
+ `x-bizrouter-cache-control-dropped`.
191
198
 
192
199
  ## 7a. BizRouter Search — answers grounded in live data
193
200
 
package/README.md CHANGED
@@ -26,15 +26,18 @@ bizrouter hermes # Hermes
26
26
 
27
27
  | 도구 | 실행 시 하는 일 | 설정 파일 변경 |
28
28
  | --- | --- | --- |
29
- | Claude Code | `ANTHROPIC_BASE_URL=https://api.bizrouter.ai/claude`, 토큰, 빈 `ANTHROPIC_API_KEY`, 모델 선택창 자동 채움을 넘기고, 사용자 settings.json의 다른 게이트웨이 설정이 이기지 못하도록 명령행 settings로 고정 | 없음 |
29
+ | Claude Code | `ANTHROPIC_BASE_URL=https://api.bizrouter.ai/claude`, 토큰, 빈 `ANTHROPIC_API_KEY`, 모델 선택창 자동 채움(이 키로 쓸 수 있는 채팅 모델 전체)을 넘기고, 사용자 settings.json의 다른 게이트웨이 설정이 이기지 못하도록 명령행 settings로 고정 | 없음 |
30
30
  | Codex CLI | `-c` 오버라이드로 BizRouter provider(Responses API)와 모델·추론 강도 지정 | 없음 |
31
31
  | OpenCode | `OPENCODE_CONFIG_CONTENT`로 이 키가 쓸 수 있는 모델 전체가 담긴 `bizrouter` provider를 주입 | 없음 |
32
32
  | Hermes | `custom` provider(`CUSTOM_BASE_URL`/`CUSTOM_API_KEY`)와 모델을 이번 실행에만 지정 | 없음 |
33
33
 
34
34
  도구는 평소처럼 실행됩니다. 바뀌는 것은 그 실행에서 모델을 호출하는 주소와 인증뿐이어서, 화면과 사용법은 같고 과금·모델 정책·감사 로그는 BizRouter 콘솔에 남습니다. `bizrouter` 없이 실행하면 평소대로 동작합니다. 우리 옵션 뒤의 인자는 도구에 그대로 전달됩니다.
35
35
 
36
+ Claude Code는 컨텍스트 창 크기를 모델 ID의 `[1m]` 마커로만 판단합니다(모델 목록의 `context_length`는 무시). 그래서 `bizrouter claude`는 모델을 지정하지 않으면 이 키가 쓸 수 있는 1M 컨텍스트 Claude 모델(Sonnet 우선)을 `ANTHROPIC_MODEL=<모델>[1m]`으로 넘기고, 직접 지정한 1M 모델에도 마커를 붙입니다. 200k 모델에는 붙이지 않습니다. 1M 입력은 표준 단가로 과금됩니다.
37
+
36
38
  ```bash
37
39
  bizrouter claude --model anthropic/claude-opus-5 -p "실패하는 테스트 고쳐줘"
40
+ bizrouter claude --model bizrouter/glm-5.3 # GPT·Gemini·GLM·Kimi 등 이 키가 쓸 수 있는 모든 채팅 모델
38
41
  bizrouter codex --model openai/gpt-5.5 --reasoning-effort high exec "README 요약해줘"
39
42
  bizrouter opencode --model google/gemini-3.5-pro
40
43
  bizrouter claude --dry-run # 실행 대신 넘겨줄 환경 변수와 명령을 보여줍니다
@@ -93,7 +96,7 @@ bizrouter auth / logout / env / update
93
96
 
94
97
  ## 알아둘 것
95
98
 
96
- - Claude Code는 Anthropic 전용 필드(`thinking`, `cache_control`)를 항상 보내므로 Claude 모델만 실행할 수 있습니다. GPT·Gemini는 Codex·OpenCode·Hermes로 실행하십시오.
99
+ - Claude Code에서도 이 키가 쓸 수 있는 채팅 모델 전체를 씁니다. `/model` 선택창에는 Claude 모델은 그대로, 나머지는 `claude-code/` 접두를 붙여(예: `claude-code/bizrouter/glm-5.3[1m]`) 나타나고, `--model`이나 `/model`에는 접두 없는 카탈로그 ID를 그대로 써도 됩니다. Claude 외 모델로 갈 때 게이트웨이가 Claude Code가 항상 보내는 Anthropic 전용 힌트(`cache_control`, `thinking`, `context_management`, 이전 턴 thinking 블록)를 떼어 내며, 뗀 `cache_control` 개수는 응답 헤더 `x-bizrouter-cache-control-dropped`로 알립니다. 1M 컨텍스트 모델에는 `[1m]`을 붙여 Claude Code가 200k로 잘못 계산하지 않게 합니다.
97
100
  - 콘솔 세션은 로그인한 사용자의 권한(owner/member)을 그대로 따릅니다. 구성원(member)은 자기 API 키와 사용량만 다룰 수 있습니다.
98
101
  - API 키(`sk-br-v1-…`)의 권한은 예전과 같습니다. 모델 호출만 되고, 콘솔 관리는 브라우저에서 승인한 사람의 콘솔 세션으로만 됩니다. 범위는 그 사람이 웹 콘솔에서 할 수 있는 일과 같습니다.
99
102
  - 콘솔의 「사용량 조회 API」 키는 별개의 읽기 전용 키입니다. CLI는 쓰지도 바꾸지도 않습니다.
@@ -382,10 +382,25 @@ export async function orgCommand(argv) {
382
382
  }
383
383
  }
384
384
  const AUDIT_USAGE = [
385
- 'bizrouter audit list [--status success|error|blocked] [--model ID] [--key 이름] [--app 이름] [--user 이메일|이름] [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--search 텍스트] [--limit N] [--cursor C] [--json]',
385
+ 'bizrouter audit list [--status success|failure] [--response-type normal|sensitive|blocked] [--model ID] [--key 이름] [--app 이름] [--user 이메일|이름] [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--search 텍스트] [--limit N] [--cursor C] [--json]',
386
386
  'bizrouter audit show <log_id> [--json]',
387
387
  'bizrouter audit stats [--json]',
388
388
  ].join('\n');
389
+ // 서버 감사 로그의 실제 값 (RequestLog.STATUS_CHOICES / RESPONSE_TYPE_CHOICES).
390
+ // 예전 도움말이 `--status error|blocked`를 안내해 0행을 조용히 돌려주는 원인이었다.
391
+ const AUDIT_STATUS_VALUES = ['success', 'failure'];
392
+ const AUDIT_RESPONSE_TYPE_VALUES = ['normal', 'sensitive', 'blocked'];
393
+ export function auditFlagValue(parsed, name, allowed, label) {
394
+ const value = flagString(parsed, name);
395
+ if (value === undefined)
396
+ return undefined;
397
+ if (!allowed.includes(value)) {
398
+ throw new CliError(`--${name} 값은 ${allowed.join('|')} 중 하나여야 합니다. (입력: ${value})`, {
399
+ hint: `${label}에 --response-type을 쓰는 것과 헷갈리지 마십시오. 감사 로그의 status는 요청 성패(success|failure)이고, 차단 여부는 response_type(blocked)입니다.`,
400
+ });
401
+ }
402
+ return value;
403
+ }
389
404
  export async function auditCommand(argv) {
390
405
  const parsed = parseArgs(argv);
391
406
  const [sub, ...rest] = parsed.positional;
@@ -395,7 +410,8 @@ export async function auditCommand(argv) {
395
410
  const from = flagString(parsed, 'from');
396
411
  const to = flagString(parsed, 'to');
397
412
  const data = await get('/audit-logs/', {
398
- status: flagString(parsed, 'status'),
413
+ status: auditFlagValue(parsed, 'status', AUDIT_STATUS_VALUES, '--status'),
414
+ response_type: auditFlagValue(parsed, 'response-type', AUDIT_RESPONSE_TYPE_VALUES, '--response-type'),
399
415
  model: flagString(parsed, 'model'),
400
416
  api_key: flagString(parsed, 'key'),
401
417
  app_name: flagString(parsed, 'app'),
@@ -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.3.3';
5
+ export const VERSION = '0.3.4';
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();
@@ -52,7 +52,7 @@ export async function modelsCommand(argv) {
52
52
  }
53
53
  print();
54
54
  print(c.dim(`${rows.length}개 · 원화 단가는 100만 토큰당 · 환율 ${catalog.exchange_rate}원/달러 · ${catalog.from_cache ? '캐시(최대 10분)' : '지금 조회'}`));
55
- print(c.dim('예: bizrouter codex --model openai/gpt-5.5 · bizrouter claude --model anthropic/claude-opus-5'));
55
+ print(c.dim('예: bizrouter codex --model openai/gpt-5.5 · bizrouter claude --model anthropic/claude-opus-5 · bizrouter claude --model bizrouter/glm-5.3'));
56
56
  print(c.dim('Search: 어느 채팅 모델이든 뒤에 :search 를 붙이면 실제 데이터를 조회해 답합니다 (예: bizrouter search "질문" --model openai/gpt-5.5)'));
57
57
  return 0;
58
58
  }
@@ -4,7 +4,7 @@ import { dirname, join } from 'node:path';
4
4
  import { fetchCatalog } from '../api.js';
5
5
  import { pickDefaultModel, FALLBACK_DEFAULT } from '../catalog.js';
6
6
  import { apiBase, ENV_KEY_NAME } from '../config.js';
7
- import { claudeEnv } from '../harness/claude.js';
7
+ import { catalogCodeOf, claudeEnv, claudeModelArg, defaultClaudeModel, ONE_MILLION_CONTEXT_MARKER } from '../harness/claude.js';
8
8
  import { CODEX_PROVIDER_ID } from '../harness/codex.js';
9
9
  import { OPENCODE_PROVIDER_ID, opencodeProvider } from '../harness/opencode.js';
10
10
  import { c, CliError, info, ok, print, warn } from '../ui.js';
@@ -119,8 +119,19 @@ export async function setupCommand(argv) {
119
119
  const apiKey = requireApiKey();
120
120
  const base = apiBase();
121
121
  if (target === 'claude') {
122
+ // ADR-0031: pin a model so plain `claude` runs on the 1M window. The
123
+ // --model value, else the model already in settings.json, keeps its choice
124
+ // and gets `[1m]` only when the catalog lists it as a 1M model.
122
125
  const path = claudeSettingsPath();
123
- const next = mergeClaudeSettings(readJsonObject(path), claudeEnv({ apiKey, apiBase: base, closedNetwork: false }));
126
+ const existing = readJsonObject(path);
127
+ const chosen = pinned ?? (typeof existing.model === 'string' ? existing.model : undefined);
128
+ const catalog = await fetchCatalog(apiKey, { allowStale: true }).catch(() => undefined);
129
+ const claudeModel = chosen
130
+ ? claudeModelArg(chosen, catalog?.models.find((m) => m.id === catalogCodeOf(chosen))?.context_length)
131
+ : defaultClaudeModel(catalog?.models);
132
+ const next = mergeClaudeSettings(existing, claudeEnv({ apiKey, apiBase: base, closedNetwork: false }));
133
+ if (claudeModel)
134
+ next.model = claudeModel;
124
135
  const text = `${JSON.stringify(next, null, 2)}\n`;
125
136
  if (dryRun) {
126
137
  print(text);
@@ -128,7 +139,8 @@ export async function setupCommand(argv) {
128
139
  }
129
140
  const bak = backup(path);
130
141
  writeText(path, text);
131
- ok(`${path}의 env 블록에 BizRouter 설정을 넣었습니다.${bak ? ` (백업: ${bak})` : ''}`);
142
+ const modelNote = claudeModel ? ` 기본 모델은 ${claudeModel}${claudeModel.endsWith(ONE_MILLION_CONTEXT_MARKER) ? '(1M 컨텍스트)' : ''}입니다.` : '';
143
+ ok(`${path}의 env 블록에 BizRouter 설정을 넣었습니다.${modelNote}${bak ? ` (백업: ${bak})` : ''}`);
132
144
  warn('이 파일에는 API 키가 그대로 저장됩니다. 저장소에 커밋되는 프로젝트 .claude/settings.json이 아니라 사용자 설정이라 안전하지만, 기기를 공유한다면 `bizrouter claude` 실행 방식을 권장합니다.');
133
145
  info('이제 `claude`만 실행해도 BizRouter로 연결됩니다. 되돌리려면 백업 파일을 복원하세요.');
134
146
  return 0;
@@ -1,10 +1,28 @@
1
1
  import { unlinkSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
+ import { chatModels } from '../api.js';
3
4
  import { hasFlag } from '../args.js';
4
- import { isAnthropicModelId } from '../catalog.js';
5
5
  import { ensureTmpDir } from '../config.js';
6
- import { CliError } from '../ui.js';
7
6
  import { claudeMcpConfig } from './mcp.js';
7
+ /** Claude Code's own marker for a 1M context window; it strips it before sending. */
8
+ export const ONE_MILLION_CONTEXT_MARKER = '[1m]';
9
+ /** Prefix the gateway's Claude Code discovery surface puts on non-Anthropic catalog codes. */
10
+ export const CLAUDE_CODE_MODEL_ALIAS_PREFIX = 'claude-code/';
11
+ const ONE_MILLION_CONTEXT_TOKENS = 1_000_000;
12
+ /**
13
+ * The catalog code behind a model id as Claude Code's `/model` picker shows it
14
+ * (`claude-code/<code>[1m]`). Mirrors the gateway's
15
+ * `normalize_claude_code_surface_model`, so catalog lookups accept whatever the
16
+ * user copied from the picker.
17
+ */
18
+ export function catalogCodeOf(model) {
19
+ let code = model.trim();
20
+ if (code.endsWith(ONE_MILLION_CONTEXT_MARKER))
21
+ code = code.slice(0, -ONE_MILLION_CONTEXT_MARKER.length).trimEnd();
22
+ if (code.startsWith(CLAUDE_CODE_MODEL_ALIAS_PREFIX))
23
+ code = code.slice(CLAUDE_CODE_MODEL_ALIAS_PREFIX.length);
24
+ return code;
25
+ }
8
26
  /**
9
27
  * Environment Claude Code needs to talk to BizRouter. Mirrors what the
10
28
  * integration guide documents; `ANTHROPIC_API_KEY` must be present and empty
@@ -21,19 +39,43 @@ export function claudeEnv(options) {
21
39
  env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = '1';
22
40
  return env;
23
41
  }
24
- export function assertClaudeModel(model) {
25
- if (!isAnthropicModelId(model)) {
26
- throw new CliError(`Claude Code는 Claude 모델만 실행할 수 있습니다: ${model}`, {
27
- hint: 'Claude Code는 thinking·cache_control 같은 Anthropic 전용 필드를 항상 보내서 다른 모델은 400으로 거절됩니다. GPT·Gemini는 `bizrouter codex` 또는 `bizrouter opencode`로 실행하세요.',
28
- });
29
- }
42
+ /**
43
+ * The `--model` value Claude Code receives. Any BizRouter catalog id works on
44
+ * the gateway (`/claude/v1/messages` converts non-Anthropic models and drops
45
+ * the Anthropic-only hints Claude Code always sends); this only appends the
46
+ * `[1m]` marker for a 1M-window model so Claude Code does not compact five
47
+ * times too early.
48
+ */
49
+ export function claudeModelArg(model, contextLength) {
50
+ if (model.endsWith(ONE_MILLION_CONTEXT_MARKER))
51
+ return model;
52
+ if (contextLength !== undefined && contextLength >= ONE_MILLION_CONTEXT_TOKENS)
53
+ return `${model}${ONE_MILLION_CONTEXT_MARKER}`;
54
+ return model;
55
+ }
56
+ /**
57
+ * ADR-0031: the `[1m]`-marked Claude model to default to when the user pinned
58
+ * nothing, so a bare `bizrouter claude` session is accounted against the 1M
59
+ * window instead of Claude Code's 200k fallback. Sonnet first (the same
60
+ * family the other harnesses default to), then any 1M Claude model. Undefined
61
+ * when the catalog is unavailable or lists no 1M Claude model, leaving Claude
62
+ * Code's own alias defaults in place.
63
+ */
64
+ export function defaultClaudeModel(models) {
65
+ const oneM = chatModels(models ?? []).filter((m) => m.id.startsWith('anthropic/claude-') && m.context_length >= ONE_MILLION_CONTEXT_TOKENS);
66
+ const found = oneM.find((m) => m.id.startsWith('anthropic/claude-sonnet')) ?? oneM[0];
67
+ return found ? claudeModelArg(found.id, found.context_length) : undefined;
30
68
  }
31
69
  export function buildClaudePlan(options) {
32
- if (options.model)
33
- assertClaudeModel(options.model);
34
70
  const env = claudeEnv(options);
35
71
  const args = [];
36
72
  let cleanup;
73
+ const model = options.model ? claudeModelArg(options.model, options.modelContextLength) : undefined;
74
+ // The default goes in the env, not argv: `--model` in passthrough or
75
+ // `/model` in the session still wins.
76
+ const injected = model ? undefined : defaultClaudeModel(options.models);
77
+ if (injected)
78
+ env.ANTHROPIC_MODEL = injected;
37
79
  // A user-level settings.json `env` block outranks shell variables, so a
38
80
  // stale ANTHROPIC_BASE_URL there would silently win. The command-line
39
81
  // settings level beats every file, so we hand the same env block over as a
@@ -51,14 +93,19 @@ export function buildClaudePlan(options) {
51
93
  }
52
94
  };
53
95
  }
54
- if (options.model && !hasFlag(options.passthrough, '--model'))
55
- args.push('--model', options.model);
96
+ if (model && !hasFlag(options.passthrough, '--model'))
97
+ args.push('--model', model);
56
98
  if (options.mcp && !hasFlag(options.passthrough, '--mcp-config'))
57
99
  args.push('--mcp-config', claudeMcpConfig(options.mcp));
58
100
  args.push(...options.passthrough);
59
101
  const notes = [
60
- `Claude Code → ${env.ANTHROPIC_BASE_URL}` + (options.model ? ` · 모델 ${options.model}` : ' · 모델은 Claude Code 기본값(sonnet/opus 별칭 자동 매핑)'),
61
- '`/model` 선택창에 이 키로 쓸 수 있는 Claude 모델이 자동으로 채워집니다.',
102
+ `Claude Code → ${env.ANTHROPIC_BASE_URL}` +
103
+ (model
104
+ ? ` · 모델 ${model}`
105
+ : injected
106
+ ? ` · 기본 모델 ${injected} (1M 컨텍스트)`
107
+ : ' · 모델은 Claude Code 기본값(sonnet/opus 별칭 자동 매핑)'),
108
+ '`/model` 선택창에 이 키로 쓸 수 있는 채팅 모델 전체가 채워집니다. Claude 외 모델은 `claude-code/` 접두로 표시되고, 1M 컨텍스트 모델에는 `[1m]`이 붙습니다.',
62
109
  ...(options.mcp ? ['콘솔 MCP 서버 「bizrouter」 를 이 세션에 연결합니다 (API 키·정책·통계 조작 · 끄기: --no-mcp).'] : []),
63
110
  ];
64
111
  return { bin: 'claude', args, env, notes, cleanup };
package/dist/index.js CHANGED
@@ -16,7 +16,7 @@ import { isVirtualModelId, searchModelFor, stripSearchSuffix } from './search.js
16
16
  import { setupCommand } from './commands/setup.js';
17
17
  import { requireApiKey } from './commands/shared.js';
18
18
  import { apiBase, loadUserConfig, resolveSession } from './config.js';
19
- import { buildClaudePlan } from './harness/claude.js';
19
+ import { buildClaudePlan, catalogCodeOf } from './harness/claude.js';
20
20
  import { buildCodexPlan, codexConfigPinsServiceTier } from './harness/codex.js';
21
21
  import { codexConfigPath } from './commands/setup.js';
22
22
  import { existsSync, readFileSync } from 'node:fs';
@@ -44,8 +44,8 @@ async function resolveModel(harness, args, apiKey) {
44
44
  }
45
45
  if (pinned) {
46
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)) {
47
+ const lookup = catalogCodeOf(stripSearchSuffix(pinned));
48
+ if (models.length && !isVirtualModelId(lookup) && !models.some((m) => m.id === lookup)) {
49
49
  const suggestions = suggestModels(models, lookup);
50
50
  warn(`「${pinned}」 는 이 키로 쓸 수 있는 모델 목록에 없습니다.${suggestions.length ? ` 비슷한 모델: ${suggestions.join(', ')}` : ''}`);
51
51
  info('그대로 시도합니다. 키의 「API key 사용 범위」나 조직의 모델 정책이 막고 있으면 요청이 거절됩니다.');
@@ -93,7 +93,16 @@ async function launch(harness, argv) {
93
93
  let plan;
94
94
  switch (harness) {
95
95
  case 'claude':
96
- plan = buildClaudePlan({ apiKey, apiBase: base, model, closedNetwork: args.closedNetwork, passthrough: args.passthrough, mcp });
96
+ plan = buildClaudePlan({
97
+ apiKey,
98
+ apiBase: base,
99
+ model,
100
+ modelContextLength: model ? models.find((m) => m.id === catalogCodeOf(model))?.context_length : undefined,
101
+ models,
102
+ closedNetwork: args.closedNetwork,
103
+ passthrough: args.passthrough,
104
+ mcp,
105
+ });
97
106
  if (args.reasoningEffort)
98
107
  warn('Claude Code는 추론 강도를 실행 옵션으로 받지 않습니다. 세션 안에서 /effort를 사용하세요.');
99
108
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bizrouter",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
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",