<!-- task=m-lemonade-provider-serving-validation/01_config_contract plan=0 tag=API -->

# Plan - API

## 이 파일을 읽는 구현 에이전트에게

구현 완료 전 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용, 설계 결정, 검증 출력으로 반드시 채운다. 구현 중 사용자 결정, 사용자 소유 외부 환경, secret, 서비스 준비, 또는 범위 충돌 없이는 안전하게 진행할 수 없으면 review stub의 `사용자 리뷰 요청` 섹션에 정확한 증거를 남기고 멈춘다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 공백은 사용자 리뷰 요청이 아니다. 최종 판정, archive, `complete.log` 작성은 code-review-skill 전용이다.

## 배경

현 마일스톤의 Lemonade provider는 OpenAI-compatible inference server 공통 adapter 계열로 두기로 정리됐다. 현재 config/proto 계약은 `ollama`와 `vllm` 중심이라 Lemonade endpoint, served model, header/auth, timeout, option passthrough의 설정 표면을 안정적으로 표현할 수 없다. 이 plan은 런타임 실행 구현 전에 Edge config에서 Node typed payload까지 이어지는 계약만 확정한다.

## 사용자 리뷰 요청 흐름

구현 중 차단이 생기면 active review stub의 `사용자 리뷰 요청` 섹션에 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 형식으로 기록한다. 구현 에이전트가 채팅으로 직접 사용자에게 묻는 것은 금지되며, code-review가 요청의 타당성을 검증하고 실제 `USER_REVIEW.md` 작성 여부를 결정한다.

## Roadmap Targets

- Milestone: `agent-roadmap/phase/inference-provider-extension/milestones/lemonade-provider-serving-validation.md`
- Task ids:
  - `config-contract`: Lemonade endpoint, served model name 또는 model, auth/header, timeout, option passthrough 설정 계약이 정리되어 있다.
- Completion mode: check-on-pass

## 분석 결과

### 읽은 파일

- `agent-ops/skills/common/router.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-ops/skills/common/update-roadmap/SKILL.md`
- `agent-ops/rules/project/domain/node/rules.md`
- `agent-ops/rules/project/domain/edge/rules.md`
- `agent-ops/rules/project/domain/testing/rules.md`
- `agent-test/local/rules.md`
- `agent-test/local/node-smoke.md`
- `agent-test/local/edge-smoke.md`
- `agent-test/local/platform-common-smoke.md`
- `agent-roadmap/phase/inference-provider-extension/milestones/lemonade-provider-serving-validation.md`
- `proto/iop/node.proto`
- `proto/iop/runtime.proto`
- `packages/go/config/config.go`
- `packages/go/config/config_test.go`
- `apps/edge/internal/node/mapper.go`
- `apps/edge/internal/node/mapper_test.go`
- `apps/edge/internal/node/store.go`
- `apps/edge/internal/node/store_test.go`
- `apps/edge/internal/edgecmd/config.go`
- `apps/edge/internal/edgecmd/edgecmd_test.go`
- `configs/edge.yaml`
- `configs/node.yaml`
- `Makefile`
- `apps/node/internal/adapters/factory.go`
- `apps/node/internal/adapters/factory_internal_test.go`
- `apps/node/internal/adapters/adapters_blackbox_test.go`
- `apps/node/internal/adapters/vllm/vllm.go`
- `apps/node/internal/adapters/vllm/vllm_test.go`

### 테스트 환경 규칙

- `test_env=local`.
- `agent-test/local/rules.md`를 읽었고, 도메인 매핑에 따라 `node-smoke`, `edge-smoke`, `platform-common-smoke`를 읽었다.
- 적용 명령: protobuf 변경은 `make proto`, 공통 설정/proto는 `go test ./packages/go/... ./proto/gen/...`, Edge config/mapper는 변경 패키지 `go test ./apps/edge/...` 중 관련 패키지로 축소한다.
- `go`, `make`, `protoc`, `protoc-gen-go`는 현재 checkout에서 발견됐다.
- 신선한 실행이 필요하므로 Go test cache 출력은 허용하지 않고 `-count=1`을 사용한다.
- secret, token, 개인 endpoint 원문은 tracked 파일에 기록하지 않는다.

### 테스트 커버리지 공백

- `openai_compat` 설정 구조체, normalization, 중복 이름, queue validation은 기존 테스트가 없다. `packages/go/config/config_test.go`에 추가한다.
- `OpenAICompatAdapterConfig` typed proto payload와 Edge mapper는 기존 `vllm` oneof 테스트만 있다. `apps/edge/internal/node/mapper_test.go`와 필요 시 `store_test.go`에 추가한다.
- Edge config validation은 `vllm_instances` endpoint 검사만 있다. `apps/edge/internal/edgecmd/edgecmd_test.go`에 enabled `openai_compat_instances` empty endpoint rejection을 추가한다.
- 실제 Lemonade execution, streaming, field smoke는 이 plan 범위 밖이다.

### 심볼 참조

- rename/remove 없음.
- 추가되는 새 심볼의 call site는 `packages/go/config/config.go`, `apps/edge/internal/node/mapper.go`, `apps/edge/internal/edgecmd/config.go`, generated `proto/gen/iop/runtime.pb.go`, 관련 테스트로 제한한다.

### 분할 판단

- split decision policy를 plan 작성 전에 평가했다.
- 공유 task group: `agent-task/m-lemonade-provider-serving-validation`.
- `01_config_contract`: 독립 선행 작업. 설정/proto/Edge payload 계약만 다룬다.
- `02+01_openai_compatible_adapter`: `01`의 typed config와 generated proto가 PASS된 뒤 Node adapter를 구현한다.
- `03+02_field_smoke`: `02`의 adapter가 PASS된 뒤 fake/real Lemonade smoke를 추가하고 실행한다.
- 이 subtask는 predecessor가 없다.

### 범위 결정 근거

- Node adapter 실행, SSE parsing, real Lemonade endpoint smoke는 제외한다. 계약이 먼저 고정되어야 후속 구현이 안정적이다.
- `apps/node/internal/adapters/vllm`의 behavior 변경은 제외한다. 기존 vLLM 경로 회귀를 피하고 새 common adapter는 후속 subtask에서 구현한다.
- host setup template, tracked `docs/`, archive roadmap/task 경로는 수정하지 않는다.
- `configs/edge.yaml`에는 secret 값 없이 주석 예시만 추가한다.

### 빌드 등급

- `cloud-G05`: protobuf/config schema 변경이고 Edge->Node payload 계약까지 닿지만, 범위와 테스트가 명확하게 제한된다.

## 구현 체크리스트

- [ ] `packages/go/config`에 OpenAI-compatible inference server 설정 계약을 추가하고 normalization/validation 테스트를 작성한다.
- [ ] `proto/iop/runtime.proto`에 typed `OpenAICompatAdapterConfig`를 추가하고 `make proto`로 Go 생성물을 갱신한다.
- [ ] Edge config payload mapper와 config validation, `configs/edge.yaml` 예시를 새 계약에 맞춘다.
- [ ] 계획의 중간 검증과 최종 검증 명령을 실행하고 실제 출력을 review stub에 붙인다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

## API-1 OpenAI-compatible config contract

### 문제

`packages/go/config/config.go:182-195`는 provider instance slice가 `ollama_instances`와 `vllm_instances`뿐이다. Lemonade를 common OpenAI-compatible adapter 계열로 두려면 endpoint, provider label, auth/header, timeout, queue 설정을 표현할 새 계약이 필요하다.

```go
182	type AdaptersConf struct {
183		// Legacy single-instance fields. When non-empty they are normalised into
184		// OllamaInstances / VllmInstances during config load so that all downstream
185		// code only needs to inspect the slice fields.
186		Ollama OllamaConf `mapstructure:"ollama" yaml:"ollama"`
187		Vllm   VllmConf   `mapstructure:"vllm"   yaml:"vllm"`
188		CLI    CLIConf    `mapstructure:"cli"    yaml:"cli"`
189	
190		// Multi-instance collections. Each entry carries a unique Name that acts as
191		// the stable adapter instance identity within the node. Names must be unique
192		// within each type collection; duplicate names are rejected at load time.
193		OllamaInstances []OllamaInstanceConf `mapstructure:"ollama_instances" yaml:"ollama_instances,omitempty"`
194		VllmInstances   []VllmInstanceConf   `mapstructure:"vllm_instances"   yaml:"vllm_instances,omitempty"`
195	}
```

### 해결 방법

- `AdaptersConf`에 legacy single config `OpenAICompat OpenAICompatConf`와 multi config `OpenAICompatInstances []OpenAICompatInstanceConf`를 추가한다.
- YAML key는 `openai_compat`와 `openai_compat_instances`, adapter type string은 `openai_compat`로 고정한다.
- `OpenAICompatConf`와 `OpenAICompatInstanceConf` 필드는 `enabled`, `provider`, `endpoint`, `headers map[string]string`, `capacity`, `max_queue`, `queue_timeout_ms`, `request_timeout_ms`로 둔다.
- `provider`는 비어 있으면 후속 adapter에서 `"openai_compat"`로 취급할 수 있게 보존하고, Lemonade 예시는 `"lemonade"`를 사용한다.
- legacy `openai_compat`가 enabled면 name `"openai_compat"` instance로 normalize한다. 명시 instance와 충돌하면 `provider/endpoint/headers/capacity/max_queue/queue_timeout_ms/request_timeout_ms` 불일치 에러를 낸다.
- enabled `openai_compat_instances`도 `validateProviderQueueConfig`를 통과해야 한다.

### 수정 파일 및 체크리스트

- [ ] `packages/go/config/config.go`: struct, normalize, `sameOpenAICompatInstance`, `findOpenAICompatInstance`, queue validation을 추가한다.
- [ ] `packages/go/config/config_test.go`: load/normalize/idempotent/conflict/duplicate/negative queue 테스트를 추가한다.

### 테스트 작성

- `TestLoadEdge_OpenAICompatInstances`
- `TestNormalizeAdapters_OpenAICompatIdempotent`
- `TestNormalizeAdapters_OpenAICompatConflictErrors`
- `TestNormalizeAdapters_OpenAICompatDuplicateNameRejects`
- `TestNormalizeAdapters_OpenAICompatQueueValidation`

### 중간 검증

```bash
go test -count=1 ./packages/go/config
```

기대 결과: 새 config 계약 테스트와 기존 config 테스트가 모두 PASS.

## API-2 Typed proto payload and mapper

### 문제

`proto/iop/runtime.proto:166-178`의 typed oneof는 `cli`, `ollama`, `vllm`, `mock`뿐이다. `apps/edge/internal/node/mapper.go:58-81`도 vLLM instance만 typed `VllmAdapterConfig`로 보낸다.

```proto
166	message AdapterConfig {
167	  string                 type     = 1; // "mock" | "ollama" | "vllm" | "cli"
168	  bool                   enabled  = 2;
169	  google.protobuf.Struct settings = 3; // legacy/compat path; new adapters use oneof
170	  oneof config {
171	    CLIAdapterConfig    cli    = 4;
172	    OllamaAdapterConfig ollama = 5;
173	    VllmAdapterConfig   vllm   = 6;
174	    MockAdapterConfig   mock   = 7;
175	  }
176	  string name   = 8; // stable instance identity; empty = legacy single-instance
177	  string target = 9; // optional default target/route hint for this instance
178	}
```

```go
58	for _, inst := range rec.Adapters.VllmInstances {
59		if !inst.Enabled {
60			continue
61		}
62		key := "vllm:" + inst.Name
63		if _, dup := seen[key]; dup {
64			return nil, fmt.Errorf("duplicate vllm instance name %q", inst.Name)
65		}
66		seen[key] = struct{}{}
67		payload.Adapters = append(payload.Adapters, &iop.AdapterConfig{
68			Type:    "vllm",
69			Enabled: true,
70			Name:    inst.Name,
71			Config: &iop.AdapterConfig_Vllm{
```

### 해결 방법

- `runtime.proto`에 `OpenAICompatAdapterConfig openai_compat = 10;`을 oneof에 추가한다. 기존 tag는 재사용하지 않는다.
- `OpenAICompatAdapterConfig` message를 추가하고 fields는 `provider=1`, `endpoint=2`, `map<string,string> headers=3`, `capacity=4`, `max_queue=5`, `queue_timeout_ms=6`, `request_timeout_ms=7`로 둔다.
- comment의 type 목록에 `"openai_compat"`를 추가한다.
- `make proto`로 `proto/gen/iop/runtime.pb.go`를 갱신한다. generated 파일은 직접 수정하지 않는다.
- `BuildConfigPayload`가 enabled `OpenAICompatInstances`를 `Type: "openai_compat"`, `Name: inst.Name`, typed oneof로 보낸다.
- duplicate key prefix는 `"openai_compat:"`를 사용한다.

### 수정 파일 및 체크리스트

- [ ] `proto/iop/runtime.proto`: oneof와 message 추가.
- [ ] `proto/gen/iop/runtime.pb.go`: `make proto` 결과로만 갱신.
- [ ] `apps/edge/internal/node/mapper.go`: OpenAI-compatible instance mapping 추가.
- [ ] `apps/edge/internal/node/mapper_test.go`: payload type/name/provider/endpoint/headers/queue 필드 검증 추가.
- [ ] `apps/edge/internal/node/store_test.go`: `LoadFromConfig` normalize 결과가 payload까지 유지되는지 필요 시 추가.

### 테스트 작성

- `TestBuildConfigPayload_OpenAICompatInstances`
- `TestLoadFromConfig_OpenAICompatInPayload` 또는 mapper test로 충분하면 store test는 skip 사유를 review에 기록한다.

### 중간 검증

```bash
make proto
go test -count=1 ./apps/edge/internal/node ./proto/gen/...
```

기대 결과: generated proto compile과 Edge payload tests PASS.

## API-3 Edge validation and example config

### 문제

`apps/edge/internal/edgecmd/config.go:167-187`은 enabled adapter 판정과 endpoint validation에서 Ollama, CLI, vLLM만 본다. `configs/edge.yaml:49-76`, `configs/edge.yaml:84-189`에도 Lemonade/OpenAI-compatible route와 node adapter 예시가 없다.

```go
167		hasOllama := anyOllamaEnabled(n.Adapters)
168		hasVllm := anyVllmEnabled(n.Adapters)
169		if !hasOllama && !n.Adapters.CLI.Enabled && !hasVllm {
170			return fmt.Errorf("node %q: at least one adapter must be enabled", name)
171		}
```

```yaml
68	  #   - model: "qwen3"
69	  #     adapter: "vllm"
70	  #     target: "qwen3-72b"
71	  #     node: "node-gpu-01"
```

### 해결 방법

- `anyOpenAICompatEnabled` helper를 추가하고 enabled adapter validation에 포함한다.
- enabled `openai_compat` 또는 `openai_compat_instances`의 empty endpoint를 reject한다.
- `configs/edge.yaml`에 commented route 예시를 추가한다: `adapter: "openai_compat"`, `target: "<lemonade-served-model>"`, `node: "node-lemonade-01"`.
- node adapter 예시에도 commented `openai_compat_instances`를 추가한다. endpoint 예시는 Lemonade 기본 local API인 `http://127.0.0.1:13305`를 comment로 두고, headers는 secret 값 없이 placeholder comment만 둔다.

### 수정 파일 및 체크리스트

- [ ] `apps/edge/internal/edgecmd/config.go`: helper와 validation 추가.
- [ ] `apps/edge/internal/edgecmd/edgecmd_test.go`: enabled OpenAI-compatible instance, empty endpoint rejection, adapter-only node pass 테스트 추가.
- [ ] `configs/edge.yaml`: commented example 추가. secret 값 금지.

### 테스트 작성

- `TestValidateEdgeConfig_OpenAICompatInstanceEnabled`
- `TestValidateEdgeConfig_OpenAICompatInstanceEmptyEndpointRejected`
- legacy single config를 추가했다면 `TestValidateEdgeConfig_OpenAICompatLegacyEmptyEndpointRejected`

### 중간 검증

```bash
go test -count=1 ./apps/edge/internal/edgecmd
```

기대 결과: config validation tests PASS.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `packages/go/config/config.go` | API-1 |
| `packages/go/config/config_test.go` | API-1 |
| `proto/iop/runtime.proto` | API-2 |
| `proto/gen/iop/runtime.pb.go` | API-2 |
| `apps/edge/internal/node/mapper.go` | API-2 |
| `apps/edge/internal/node/mapper_test.go` | API-2 |
| `apps/edge/internal/node/store_test.go` | API-2 |
| `apps/edge/internal/edgecmd/config.go` | API-3 |
| `apps/edge/internal/edgecmd/edgecmd_test.go` | API-3 |
| `configs/edge.yaml` | API-3 |

## 최종 검증

```bash
make proto
go test -count=1 ./packages/go/config ./apps/edge/internal/node ./apps/edge/internal/edgecmd ./proto/gen/...
git diff --check
```

기대 결과: 모든 명령이 exit 0. `git diff --check`는 whitespace error 없음.

모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
