docs(opencode): IOP 모델 설정 가이드를 추가한다
This commit is contained in:
parent
edd6f19651
commit
61016d5bd0
2 changed files with 406 additions and 0 deletions
|
|
@ -33,3 +33,9 @@ make test-e2e
|
|||
```
|
||||
|
||||
Start with `agent-contract/index.md` for protocol and runtime contracts, and `agent-spec/index.md` for living implementation summaries.
|
||||
|
||||
Operator and client setup guides:
|
||||
|
||||
- [Edge Local Quickstart](docs/edge-local-dev-guide.md)
|
||||
- [dev OpenCode Settings Guide](docs/dev-opencode-settings-guide.md)
|
||||
- [dev-corp Pi Settings Guide](docs/dev-corp-pi-settings-guide.md)
|
||||
|
|
|
|||
400
docs/dev-opencode-settings-guide.md
Normal file
400
docs/dev-opencode-settings-guide.md
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
# dev OpenCode Settings Guide
|
||||
|
||||
IOP dev OpenAI-compatible endpoint를 OpenCode에서 사용하기 위한 설정과 복구 절차다. 이 문서는 저장소 코드나 Edge 런타임을 수정하는 절차가 아니라 사용자 호스트의 OpenCode 설정만 다룬다.
|
||||
|
||||
2026-08-05 기준 다음 조합을 실제 호출로 확인했다.
|
||||
|
||||
- OpenCode: `1.18.3`
|
||||
- IOP endpoint: `http://toki-labs.com:18083/v1`
|
||||
- local model: `iop/ornith:35b`
|
||||
- cloud model: `iop-glm/glm-5.2`
|
||||
|
||||
## 인증 경계
|
||||
|
||||
IOP를 OpenCode에 연결할 때 서로 다른 두 토큰을 구분한다.
|
||||
|
||||
| 용도 | 요청 위치 | OpenCode 환경 변수 |
|
||||
| --- | --- | --- |
|
||||
| IOP 접속 토큰 | `Authorization: Bearer ...` | `IOP_OPENCODE_API_KEY` |
|
||||
| upstream provider 토큰 | `X-IOP-Provider-Authorization: ...` | `IOP_GLM_CODING_PLAN_TOKEN` |
|
||||
|
||||
`/v1/models`부터 실패하면 IOP 접속 토큰 문제다. `/v1/models`는 성공하지만 completion에서 `provider auth token required`가 나오면 upstream provider header 문제다. Edge 설정의 legacy `openai.bearer_token` 값을 사용자용 IOP 토큰 대신 복사하지 않는다.
|
||||
|
||||
실제 토큰은 tracked 문서, 저장소 설정, 셸 명령 인자, 로그, 채팅에 남기지 않는다.
|
||||
|
||||
## 원격 SOPS 토큰 찾기
|
||||
|
||||
dev 사용자 토큰의 현재 source of truth는 원격 호스트 `toki@toki-labs.com`의 다음 파일이다.
|
||||
|
||||
```text
|
||||
~/.config/iop/secrets/dev-openai-toki.sops.yaml
|
||||
```
|
||||
|
||||
이 파일은 원격 호스트 자체의 파일이다. `code-server` 컨테이너의 `/config/.config/sops/iop/*.sops.yaml`과 혼동하지 않는다.
|
||||
|
||||
현재 OpenCode에는 IDE agent 용도로 발급된 다음 token entry를 사용한다.
|
||||
|
||||
```text
|
||||
tokens.toki-dev-cline
|
||||
```
|
||||
|
||||
원격 macOS에서는 SOPS와 age key 경로를 명시한다.
|
||||
|
||||
```bash
|
||||
export SOPS_AGE_KEY_FILE="$HOME/.config/sops/age/keys.txt"
|
||||
secret_file="$HOME/.config/iop/secrets/dev-openai-toki.sops.yaml"
|
||||
|
||||
/opt/homebrew/bin/sops -d --output-type json "$secret_file" |
|
||||
jq -r 'paths(scalars) | map(tostring) | join(".")'
|
||||
```
|
||||
|
||||
위 명령은 key path만 출력한다. 복호화된 전체 JSON이나 token value를 터미널에 출력하지 않는다.
|
||||
|
||||
원격 endpoint와 token 유효성은 응답 본문이나 token을 출력하지 않고 상태 코드로 확인한다.
|
||||
|
||||
```bash
|
||||
export SOPS_AGE_KEY_FILE="$HOME/.config/sops/age/keys.txt"
|
||||
secret_file="$HOME/.config/iop/secrets/dev-openai-toki.sops.yaml"
|
||||
base_url="$(/opt/homebrew/bin/sops -d --extract '["base_url"]' "$secret_file")"
|
||||
iop_token="$(/opt/homebrew/bin/sops -d --extract '["tokens"]["toki-dev-cline"]' "$secret_file")"
|
||||
|
||||
http_code="$(
|
||||
curl -sS -o /dev/null -w '%{http_code}' \
|
||||
-H "Authorization: Bearer $iop_token" \
|
||||
"$base_url/v1/models"
|
||||
)"
|
||||
printf 'status=%s token_present=%s\n' "$http_code" "$([[ -n "$iop_token" ]] && echo yes || echo no)"
|
||||
unset iop_token
|
||||
```
|
||||
|
||||
정상 기준은 `status=200`이다. SOPS의 `base_url`은 원격 호스트 내부에서 사용하는 `127.0.0.1` 주소일 수 있으므로, 로컬 OpenCode 설정에는 외부에서 접근 가능한 dev endpoint를 사용한다.
|
||||
|
||||
## 로컬 SOPS로 안전하게 이전
|
||||
|
||||
원격 SOPS 파일의 age recipient와 로컬 age key가 다르면 파일 자체를 복사해도 복호화할 수 없다. token을 평문 임시 파일에 저장하지 않고 SSH pipe에서 바로 로컬 recipient로 재암호화한다.
|
||||
|
||||
로컬 기준 경로:
|
||||
|
||||
```text
|
||||
~/.config/sops/age/keys.txt
|
||||
~/.config/sops/iop/iop-opencode.sops.yaml
|
||||
```
|
||||
|
||||
```bash
|
||||
local_recipient="$(
|
||||
"$HOME/.local/bin/age-keygen" -y "$HOME/.config/sops/age/keys.txt"
|
||||
)"
|
||||
|
||||
umask 077
|
||||
ssh toki@toki-labs.com \
|
||||
"export SOPS_AGE_KEY_FILE=/Users/toki/.config/sops/age/keys.txt; \
|
||||
/opt/homebrew/bin/sops -d \
|
||||
--extract '[\"tokens\"][\"toki-dev-cline\"]' \
|
||||
/Users/toki/.config/iop/secrets/dev-openai-toki.sops.yaml" |
|
||||
jq -Rn '{data: input}' |
|
||||
"$HOME/.local/bin/sops" encrypt \
|
||||
--age "$local_recipient" \
|
||||
--input-type json \
|
||||
--output-type yaml \
|
||||
--output "$HOME/.config/sops/iop/iop-opencode.sops.yaml" \
|
||||
/dev/stdin
|
||||
|
||||
chmod 600 "$HOME/.config/sops/iop/iop-opencode.sops.yaml"
|
||||
unset local_recipient
|
||||
```
|
||||
|
||||
복호화 가능 여부는 값 대신 길이와 endpoint 상태만 확인한다.
|
||||
|
||||
```bash
|
||||
iop_token="$(
|
||||
"$HOME/.local/bin/sops" -d --extract '["data"]' \
|
||||
"$HOME/.config/sops/iop/iop-opencode.sops.yaml"
|
||||
)"
|
||||
|
||||
http_code="$(
|
||||
curl -sS -o /dev/null -w '%{http_code}' \
|
||||
-H "Authorization: Bearer $iop_token" \
|
||||
http://toki-labs.com:18083/v1/models
|
||||
)"
|
||||
printf 'status=%s token_length=%s\n' "$http_code" "${#iop_token}"
|
||||
unset iop_token
|
||||
```
|
||||
|
||||
## OpenCode 설정 파일
|
||||
|
||||
현재 구성은 다음 파일을 사용한다.
|
||||
|
||||
```text
|
||||
~/.config/opencode/opencode.json
|
||||
~/.config/opencode/iop-glm.json
|
||||
~/.config/opencode/plugins/iop-quota-guard.js
|
||||
~/.local/bin/opencode
|
||||
~/.local/bin/opencode-tmp-wrapper
|
||||
```
|
||||
|
||||
`opencode.json`은 기본 IOP/Ornith provider를, `iop-glm.json`은 GLM provider와 build/plan agent override를 정의한다. 실행 wrapper가 `OPENCODE_CONFIG=~/.config/opencode/iop-glm.json`을 주입하면 OpenCode가 두 설정을 병합한다.
|
||||
|
||||
API key는 평문으로 넣지 않고 반드시 환경 변수 참조를 사용한다.
|
||||
|
||||
```json
|
||||
{
|
||||
"provider": {
|
||||
"iop": {
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"options": {
|
||||
"baseURL": "http://toki-labs.com:18083/v1",
|
||||
"apiKey": "{env:IOP_OPENCODE_API_KEY}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
OpenCode의 custom OpenAI-compatible provider 형식은 공식 [Providers 문서](https://opencode.ai/docs/providers/)를 기준으로 한다.
|
||||
|
||||
## Ornith 35B 설정
|
||||
|
||||
`~/.config/opencode/opencode.json`의 기준값:
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "iop/ornith:35b",
|
||||
"small_model": "iop/ornith:35b",
|
||||
"agent": {
|
||||
"ornith": {
|
||||
"mode": "primary",
|
||||
"model": "iop/ornith:35b",
|
||||
"temperature": 0.6,
|
||||
"top_p": 0.95
|
||||
}
|
||||
},
|
||||
"provider": {
|
||||
"iop": {
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"name": "IOP",
|
||||
"options": {
|
||||
"baseURL": "http://toki-labs.com:18083/v1",
|
||||
"apiKey": "{env:IOP_OPENCODE_API_KEY}"
|
||||
},
|
||||
"models": {
|
||||
"ornith:35b": {
|
||||
"name": "IOP Ornith 35B",
|
||||
"family": "ornith",
|
||||
"reasoning": true,
|
||||
"temperature": true,
|
||||
"tool_call": true,
|
||||
"headers": {
|
||||
"X-IOP-Provider-Authorization": "iop-local"
|
||||
},
|
||||
"interleaved": {
|
||||
"field": "reasoning_content"
|
||||
},
|
||||
"modalities": {
|
||||
"input": ["text"],
|
||||
"output": ["text"]
|
||||
},
|
||||
"limit": {
|
||||
"context": 262144,
|
||||
"output": 32768
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`temperature=0.6`, `top_p=0.95`는 현재 dev Ornith baseline이다. `X-IOP-Provider-Authorization: iop-local`은 현재 dev Lemonade route에서 확인된 provider marker이며, provider 인증 정책이 바뀌면 실제 upstream credential 요구 여부를 다시 확인한다.
|
||||
|
||||
## GLM 5.2 설정
|
||||
|
||||
`~/.config/opencode/iop-glm.json`의 기준값:
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "iop-glm/glm-5.2",
|
||||
"small_model": "iop/ornith:35b",
|
||||
"agent": {
|
||||
"build": {
|
||||
"model": "iop-glm/glm-5.2",
|
||||
"variant": "max",
|
||||
"temperature": 1,
|
||||
"top_p": 1,
|
||||
"steps": 24
|
||||
},
|
||||
"plan": {
|
||||
"model": "iop-glm/glm-5.2",
|
||||
"variant": "max",
|
||||
"temperature": 1,
|
||||
"top_p": 1,
|
||||
"steps": 16
|
||||
}
|
||||
},
|
||||
"provider": {
|
||||
"iop-glm": {
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"name": "IOP GLM",
|
||||
"options": {
|
||||
"baseURL": "http://toki-labs.com:18083/v1",
|
||||
"apiKey": "{env:IOP_OPENCODE_API_KEY}",
|
||||
"timeout": 300000,
|
||||
"chunkTimeout": 120000,
|
||||
"headers": {
|
||||
"X-IOP-Provider-Authorization": "{env:IOP_GLM_CODING_PLAN_TOKEN}"
|
||||
}
|
||||
},
|
||||
"models": {
|
||||
"glm-5.2": {
|
||||
"name": "IOP GLM-5.2",
|
||||
"family": "glm",
|
||||
"reasoning": true,
|
||||
"temperature": true,
|
||||
"tool_call": true,
|
||||
"interleaved": {
|
||||
"field": "reasoning_content"
|
||||
},
|
||||
"modalities": {
|
||||
"input": ["text"],
|
||||
"output": ["text"]
|
||||
},
|
||||
"limit": {
|
||||
"context": 1000000,
|
||||
"output": 131072
|
||||
},
|
||||
"variants": {
|
||||
"high": {
|
||||
"reasoningEffort": "high"
|
||||
},
|
||||
"max": {
|
||||
"reasoningEffort": "max"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
GLM-5.2의 `1M` context, 최대 `131072` output, reasoning effort, sampling parameter는 [Z.AI parameter 문서](https://docs.z.ai/guides/overview/concept-param)와 [GLM-5.2 안내](https://z.ai/blog/glm-5.2)를 기준으로 한다. OpenCode의 OpenAI-compatible GLM-5.2 변형은 현재 [provider transform](https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/provider/transform.ts)에서 `high`와 `max`를 제공한다.
|
||||
|
||||
일반 coding/agent 작업은 `temperature=1`, `top_p=1`, `variant=max`를 기본으로 한다. 지연이나 quota 비용을 줄여야 할 때만 agent별로 `variant=high`를 선택한다.
|
||||
|
||||
## 실행 wrapper
|
||||
|
||||
`~/.local/bin/opencode`는 최소한 다음 순서를 지킨다.
|
||||
|
||||
1. OpenCode XDG data/state/runtime 경로를 준비한다.
|
||||
2. `IOP_OPENCODE_API_KEY`가 없으면 로컬 SOPS의 `data`를 복호화해 export한다.
|
||||
3. `IOP_GLM_CODING_PLAN_TOKEN`이 없으면 기존 Pi `glm-5.2` model header command를 실행해 export한다.
|
||||
4. `OPENCODE_CONFIG=~/.config/opencode/iop-glm.json`을 export한다.
|
||||
5. 실제 OpenCode binary를 실행한다.
|
||||
|
||||
IOP 접속 토큰 부분의 기준 구현:
|
||||
|
||||
```bash
|
||||
if [[ -z "${IOP_OPENCODE_API_KEY:-}" ]]; then
|
||||
IOP_OPENCODE_API_KEY="$(
|
||||
"$HOME/.local/bin/sops" -d --extract '["data"]' \
|
||||
"$HOME/.config/sops/iop/iop-opencode.sops.yaml"
|
||||
)"
|
||||
fi
|
||||
export IOP_OPENCODE_API_KEY
|
||||
```
|
||||
|
||||
`command -v opencode`가 npm symlink나 임시 wrapper를 가리킬 수 있다. 최종 진입점이 위 wrapper를 건너뛰면 SOPS token과 `OPENCODE_CONFIG`가 적용되지 않는다.
|
||||
|
||||
```bash
|
||||
command -v opencode
|
||||
readlink "$(command -v opencode)"
|
||||
sed -n '1,40p' "$(command -v opencode)"
|
||||
```
|
||||
|
||||
현재 `~/.local/bin/opencode-tmp-wrapper`는 다른 환경을 다시 구성하지 않고 `~/.local/bin/opencode`에 그대로 위임한다.
|
||||
|
||||
## 검증
|
||||
|
||||
모델 등록:
|
||||
|
||||
```bash
|
||||
opencode models iop
|
||||
opencode models iop-glm
|
||||
```
|
||||
|
||||
기대값:
|
||||
|
||||
```text
|
||||
iop/ornith:35b
|
||||
iop-glm/glm-5.2
|
||||
```
|
||||
|
||||
민감한 provider option을 출력하지 않고 effective model/agent 설정만 확인한다.
|
||||
|
||||
```bash
|
||||
opencode debug config |
|
||||
jq '{
|
||||
model,
|
||||
small_model,
|
||||
build: (.agent.build | {model, variant, temperature, top_p, steps}),
|
||||
plan: (.agent.plan | {model, variant, temperature, top_p, steps}),
|
||||
ornith: (.agent.ornith | {model, temperature, top_p}),
|
||||
glm_limit: .provider["iop-glm"].models["glm-5.2"].limit,
|
||||
glm_variants: .provider["iop-glm"].models["glm-5.2"].variants,
|
||||
ornith_limit: .provider.iop.models["ornith:35b"].limit
|
||||
}'
|
||||
```
|
||||
|
||||
실제 OpenCode 호출:
|
||||
|
||||
```bash
|
||||
opencode run --agent ornith --model 'iop/ornith:35b' \
|
||||
'Reply with exactly: ORNITH_OPENCODE_OK'
|
||||
|
||||
opencode run --agent build --model 'iop-glm/glm-5.2' \
|
||||
'Reply with exactly: GLM52_OPENCODE_OK'
|
||||
```
|
||||
|
||||
2026-08-05 검증에서는 두 명령 모두 exit code `0`으로 marker를 정확히 반환했다.
|
||||
|
||||
## 장애 대응
|
||||
|
||||
### `/v1/models`가 401 또는 403
|
||||
|
||||
- OpenCode config의 `apiKey`가 `{env:IOP_OPENCODE_API_KEY}`인지 확인한다.
|
||||
- wrapper가 로컬 `iop-opencode.sops.yaml`을 실제로 읽는지 확인한다.
|
||||
- 원격 SOPS token을 상태 코드로 검증하고, 필요하면 평문 파일 없이 다시 재암호화한다.
|
||||
- Edge runtime config에 들어 있는 bearer 값을 사용자 토큰으로 대체 사용하지 않는다.
|
||||
|
||||
### `provider auth token required`
|
||||
|
||||
- IOP 접속은 성공한 상태일 수 있다.
|
||||
- GLM은 `X-IOP-Provider-Authorization={env:IOP_GLM_CODING_PLAN_TOKEN}`을 확인한다.
|
||||
- Ornith는 현재 dev provider marker가 model header에 들어 있는지 확인한다.
|
||||
- IOP와 upstream provider 토큰을 서로 바꾸지 않는다.
|
||||
|
||||
### OpenCode가 다른 model/config를 사용함
|
||||
|
||||
- `command -v opencode`와 symlink/wrapper chain을 확인한다.
|
||||
- `OPENCODE_CONFIG`가 `iop-glm.json`을 가리키는지 확인한다.
|
||||
- `opencode debug config`에서 병합된 provider와 agent를 확인한다.
|
||||
|
||||
### SOPS가 `identity did not match any recipients`로 실패
|
||||
|
||||
- 원격 macOS에서 `SOPS_AGE_KEY_FILE=~/.config/sops/age/keys.txt`를 명시한다.
|
||||
- 원격 SOPS recipient와 로컬 age recipient가 다르면 파일을 그대로 복사하지 않는다.
|
||||
- 이 문서의 SSH pipe 재암호화 절차를 사용한다.
|
||||
|
||||
### 이전 auth cache가 호출에 개입함
|
||||
|
||||
- wrapper가 지정한 `XDG_DATA_HOME`과 다른 경로의 `opencode/auth.json`은 정상 호출에 필요하지 않다.
|
||||
- 설정에 `options.apiKey={env:IOP_OPENCODE_API_KEY}`가 있으면 그 경로를 source of truth로 유지한다.
|
||||
- 사용되지 않는 캐시를 정리할 때는 provider key를 먼저 확인하고 다른 provider credential을 삭제하지 않는다.
|
||||
|
||||
## 보안 체크리스트
|
||||
|
||||
- token 원문을 `jq`, `sed`, `cat`, `set -x`, shell history, 로그에 출력하지 않는다.
|
||||
- 원격 SOPS와 로컬 SOPS의 age key를 혼동하지 않는다.
|
||||
- 로컬 encrypted file 권한은 `600`으로 유지한다.
|
||||
- tracked `opencode.json`이나 문서에 실제 token을 넣지 않는다.
|
||||
- token이 노출됐으면 파일만 다시 암호화하지 말고 원격 source에서 token을 회전한다.
|
||||
- 검증 결과에는 HTTP status, token 존재 여부/길이, model id, marker만 남긴다.
|
||||
Loading…
Reference in a new issue