tars 콘솔 LLM 설정을 표로 고치고 Claude Code 모델 고르기
tars 콘솔의 설정 → LLM 탭에서 프로바이더 하나를 더하거나 티어 모델 하나를 바꾸려면 llm_providers, llm_tiers, llm_role_defaults 세 값을 JSON 그대로 고쳐야 했다. 항목 하나를 바꾸려고 JSON 전체를 손으로 고치는 게 번거로워서 v0.65.0에서 이 세 값을 표로 고치게 바꿨다. 표로 바꾸고 나니 claude-code-cli 티어의 모델 칸에 고를 수 있는 값이 opus, sonnet, haiku 별칭 셋뿐이라는 점도 눈에 걸려서, 10월 22일에 전체 모델 ID 목록을 붙였다.
세 값을 행 단위로 편집하기
표 편집기(ConfigLLMEditor.svelte)는 세 구역으로 나뉜다. 프로바이더는 별칭, 종류, 인증 방식, API 키, base URL을 한 행으로 두고 행을 더하거나 고치거나 지운다. 티어는 고정된 세 개(heavy, standard, light)에 프로바이더, 모델, reasoning effort, 최대 토큰을 고르고, 역할별 기본 티어는 역할과 티어를 짝지어 덮어쓴다. 표가 다루지 않는 값도 있어서 구역마다 “JSON으로 편집” 버튼을 남겨 뒀다.
값과 행을 오가는 변환, 검증, 저장할 패치 만들기는 컴포넌트에서 떼어 lib/llmConfigEditor.ts에 순수 함수로 모았다. 한 구역은 오류가 하나도 없을 때만 저장 대상(dirtyFields)에 올라간다. 프로바이더 별칭을 바꾸면 그 별칭을 쓰던 티어도 같이 바뀌고, 프로바이더를 지우면 그 프로바이더를 가리키던 티어의 참조를 비워서 없는 별칭을 가리킨 채 남지 않게 했다.
API 키는 조금 따로 다뤘다. 서버는 설정 스키마를 돌려줄 때(GET /v1/admin/config/schema) 비어 있지 않은 프로바이더 API 키를 모두 가린다. 그래서 불러온 행의 API 키 칸은 빈칸으로 두고 “(leave blank to keep the existing key)” 안내를 붙였다. 빈칸으로 저장하면 기존 키를 유지해서, 가려진 문자열이 화면에 보이거나 그대로 다시 저장되는 일이 없다. 서버는 antigravity-cli를 이미 받았는데 콘솔의 프로바이더 종류 목록에만 빠져 있어서, 이번에 같이 넣었다.
리뷰에서 나온 것
이 변경은 리뷰를 네 번 돌았는데, 대부분 표와 JSON을 오갈 때 값이 사라지거나 틀어지는 문제였다. auth_mode를 빼고 붙여 넣은 JSON에서 cli만 되는 종류의 행이 api-key로 바뀌는 문제, 페이지의 버리기 버튼이 아직 저장 대상에 오르지 않은 미완성 행을 지우지 못하는 문제가 있었다. 별칭이 비었거나 겹치는 작성 중인 행이 “JSON으로 편집”으로 넘어가면 사라지는 문제도 있었는데, 검증하는 빌더 대신 값을 잃지 않는 초안용 빌더(providerRowsToDraftValue, roleRowsToDraftValue)를 따로 둬서 고쳤다. 프로바이더 행이 오류 상태일 때는 티어가 마지막으로 정상이던 별칭 목록을 기준으로 검사하게 해서, 저장되지 않은 깨진 별칭을 티어가 가리킨 채 저장되지 않게 했다.
표 UI만으로 프로바이더를 더하고 티어 모델을 바꿔 저장한 뒤 서버 스키마로 확인하는 e2e(settings-llm-ui.spec.ts)도 새로 넣었다. 이 테스트는 원래 있던 프로바이더의 가려진 API 키가 저장 후에도 바뀌지 않는지까지 본다. 글을 쓰면서 편집기 단위 테스트를 다시 돌려 봤다.
$ bun test tests/llmConfigEditor.test.ts
...
(pass) renameLLMProviderAlias only rewrites tiers pointing at the previous alias, and is a no-op without a change [0.07ms]
35 pass
0 fail
Ran 35 tests across 1 file. [70.00ms]
claude-code-cli 모델 목록 만들기
claude-code-cli 티어는 별칭 세 개로만 채울 수 있었다. CLI는 --model 값을 그대로 넘기기 때문에 claude-opus-5-5 같은 전체 ID를 손으로 쳐 넣으면 동작했지만, 목록에는 나오지 않았다. claude CLI에는 모델 목록을 돌려주는 명령이 없어서 서버가 목록을 직접 모으기로 했다.
GET /v1/models는 claude-code-cli에 대해 세 곳에서 모델을 모은다. 먼저 프로바이더 목록에 api_key가 있는 anthropic 항목이 있을 때만 Anthropic Models API를 불러 claude-로 시작하는 ID만 남긴다. 다음은 CLI가 ~/.claude.json에 캐시해 둔 모델 옵션인데, 읽기만 하고 모델 ID 문자열만 가져오며 파일이 없거나 깨져 있으면 무시한다. 마지막은 내장 목록 llmdefaults.ClaudeCodeCLIModels에 티어가 이미 쓰는 모델을 더한 것이다. 별칭이 맨 앞에 오고 그 뒤에 전체 ID가 중복 없이 붙는다. 자격 증명은 anthropic 항목의 API 키 하나만 쓰고, CLI의 OAuth 로그인은 읽지 않는다.
var ClaudeCodeCLIModels = []string{
"opus",
"sonnet",
"haiku",
"claude-opus-5-5",
"claude-opus-5",
"claude-sonnet-5-5",
"claude-sonnet-5",
"claude-haiku-5-5",
"claude-fable-5-1",
"claude-fable-5",
}
설정 마법사도 같은 방식으로 목록을 만드는데, 이때는 프로바이더 목록이 아직 없어서 Anthropic API는 부르지 않고 CLI 캐시와 내장 목록만 쓴다. 추천 티어는 CLI가 가장 새 모델로 풀어 주는 별칭을 그대로 쓴다(heavy는 opus, standard는 sonnet, light는 haiku). 콘솔의 LLM 탭에서는 저장된 claude-code-cli 프로바이더에 대해서만 서버 목록을 받아 오게 해서, 별칭을 입력하는 중에는 요청이 나가지 않는다. 5.x 모델 비용 계산은 이번 범위에서 뺐고 값을 짐작해 넣지 않았다.
확인한 것과 남은 것
글을 쓰면서 claude-code-cli 모델 목록 관련 Go 테스트를 다시 돌렸다.
$ go test ./internal/llmdefaults ./internal/tarsserver -run 'ClaudeCodeCLI' -v
=== RUN TestClaudeCodeCLIModelsHasAliasesAndNoDuplicates
--- PASS: TestClaudeCodeCLIModelsHasAliasesAndNoDuplicates (0.00s)
PASS
ok github.com/devlikebear/tars/internal/llmdefaults 0.001s
=== RUN TestClaudeCodeCLIModels_CatalogOnly
--- PASS: TestClaudeCodeCLIModels_CatalogOnly (0.00s)
=== RUN TestClaudeCodeCLIModels_LiveFiltersAndCaches
--- PASS: TestClaudeCodeCLIModels_LiveFiltersAndCaches (0.00s)
=== RUN TestClaudeCodeCLIModels_LiveFailureFallsBackWithWarning
--- PASS: TestClaudeCodeCLIModels_LiveFailureFallsBackWithWarning (0.00s)
=== RUN TestClaudeCodeCLIModels_LiveEmptyIsIgnored
--- PASS: TestClaudeCodeCLIModels_LiveEmptyIsIgnored (0.00s)
...
PASS
ok github.com/devlikebear/tars/internal/tarsserver 0.007s
PR에서는 두 변경 모두 콘솔 타입 검사와 단위 테스트, 콘솔 e2e(표 편집기 때 157개, 모델 목록 때 161개)를 통과했고, 모델 목록 쪽은 변경 줄 커버리지 93.1%였다. 내장 목록은 새 모델이 나오면 손으로 고쳐야 하는 자리라서 한곳(llmdefaults)에만 두었다. 표 편집기는 v0.65.0에 들어갔고, claude-code-cli 모델 목록은 10월 22일에 main에 머지했지만 아직 릴리스에는 들어가지 않았다.