From b38f97865232adca0c22ed4d5e6c7e73f154985c Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 11 Jul 2026 09:48:54 +0900 Subject: [PATCH] feat: update edge config runtime refresh, openai compatible provider, and mapper --- .../inner/edge-config-runtime-refresh.md | 3 + agent-contract/outer/openai-compatible-api.md | 15 ++ .../code_review_cloud_G06_0.log} | 96 +++++--- .../01_config_auth_catalog/complete.log | 43 ++++ .../plan_cloud_G06_0.log} | 0 .../code_review_cloud_G07_0.log} | 30 ++- .../code_review_cloud_G07_1.log | 219 ++++++++++++++++++ .../02+01_provider_tunnel_auth/complete.log | 44 ++++ .../plan_cloud_G07_0.log} | 0 .../plan_cloud_G07_1.log | 171 ++++++++++++++ apps/edge/internal/node/mapper.go | 2 +- apps/edge/internal/node/mapper_test.go | 68 ++++++ apps/edge/internal/openai/server_test.go | 131 +++++++++++ apps/edge/internal/openai/stream.go | 41 ++++ configs/edge.yaml | 49 +++- packages/go/config/config.go | 47 +++- packages/go/config/config_test.go | 201 ++++++++++++++++ 17 files changed, 1118 insertions(+), 42 deletions(-) rename agent-task/{m-seulgivibe-openai-compatible-provider/01_config_auth_catalog/CODE_REVIEW-cloud-G06.md => archive/2026/07/m-seulgivibe-openai-compatible-provider/01_config_auth_catalog/code_review_cloud_G06_0.log} (57%) create mode 100644 agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/01_config_auth_catalog/complete.log rename agent-task/{m-seulgivibe-openai-compatible-provider/01_config_auth_catalog/PLAN-cloud-G06.md => archive/2026/07/m-seulgivibe-openai-compatible-provider/01_config_auth_catalog/plan_cloud_G06_0.log} (100%) rename agent-task/{m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth/CODE_REVIEW-cloud-G07.md => archive/2026/07/m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth/code_review_cloud_G07_0.log} (77%) create mode 100644 agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth/code_review_cloud_G07_1.log create mode 100644 agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth/complete.log rename agent-task/{m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth/PLAN-cloud-G07.md => archive/2026/07/m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth/plan_cloud_G07_0.log} (100%) create mode 100644 agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth/plan_cloud_G07_1.log diff --git a/agent-contract/inner/edge-config-runtime-refresh.md b/agent-contract/inner/edge-config-runtime-refresh.md index 600762b..9542300 100644 --- a/agent-contract/inner/edge-config-runtime-refresh.md +++ b/agent-contract/inner/edge-config-runtime-refresh.md @@ -30,11 +30,13 @@ tracked config에는 public 예시와 기본 구조만 두고, 실제 endpoint/c ## 핵심 규칙 - `openai.principal_tokens[]`는 raw token을 저장하지 않고 hash/reference로 principal 매핑을 관리한다. 각 entry는 `token_ref` (non-empty, unique), `token_hash_sha256` (64-char hex, duplicate hash rejection), `principal_ref` (non-empty), optional `principal_alias` 필드를 갖는다. 여러 entry가 같은 `principal_ref`와 `principal_alias`를 공유할 수 있으며, 이때 `token_ref`가 앱/통합/용도별 사용량 분해 기준이 된다. tracked config에는 raw token을 저장하지 않고 hash/reference만 둔다. +- `openai.provider_auth`는 request-time raw provider token forwarding rule이다. `enabled=false`가 기본이며 raw token 값은 저장하지 않는다. `enabled=true`이고 header fields가 생략되면 `from_header=X-IOP-Provider-Authorization`, `target_header=Authorization`, `scheme=Bearer`, `required=true`로 해석한다. - `openai` deep diff는 restart-required로 분류한다. `openai.principal_tokens[]` 변경은 credential/hash 변경으로 restart-required classifier에 포함된다. - `openai.model_routes[]`는 외부 OpenAI-compatible `model` id를 내부 `adapter + target` route로 매핑하는 compatibility catalog다. - `long_context_threshold_tokens`는 Edge root의 입력 토큰 추정 기준 long-context 분류 threshold다. 기본값은 `100000`이며 0 이하 값은 config load에서 거부한다. - `models[]`는 provider pool 방향의 canonical routing key이며 `nodes[].providers[].id`를 참조한다. `context_window_tokens`는 해당 model group의 provider 공통 단일 요청 최대 context 계약이다. `default_max_tokens`, `min_max_tokens`, `default_thinking_token_budget`은 OpenAI-compatible 요청을 내부 실행으로 넘기기 전에 적용하는 모델 단위 generation policy다. - `nodes[].providers[]`는 Node 아래 resource/provider catalog다. `category`는 `api`, `cli`, `local_inference` resource kind를 나타낸다. +- `nodes[].providers[].type`의 `seulgivibe_claude`와 `seulgivibe_openai`는 runtime type을 `openai_compat`로 정규화한다. Edge가 Node adapter payload를 만들 때 명시 provider label이 없으면 원래 Seulgivibe type alias를 `OpenAICompatAdapterConfig.provider`로 보존한다. - `nodes[].providers[].id`는 전체 Edge config 안에서 중복되면 안 된다. - `nodes[].providers[].adapter`는 같은 Node 안의 enabled adapter instance key를 참조해야 한다. Exact instance key를 우선하고, legacy type-name route는 같은 type의 enabled instance가 정확히 하나일 때만 허용한다. `category: cli` resource는 enabled CLI adapter가 필요하다. - `nodes[].providers[].enabled`: 생략 또는 `true` → provider pool dispatch 후보에 포함. `false` → dispatch pool에서 제외. 비활성화된 provider는 status snapshot에 `status=disabled`, `health=disabled`, `capacity=0`으로 표시된다. adapter process lifecycle 변경 없음. config refresh 시 `enabled` 토글은 live-apply(restart 불필요)로 분류된다. disabled provider의 adapter reference check는 skip되지만 structural validation(type, category, models, numeric bounds)은 수행된다. @@ -55,6 +57,7 @@ tracked config에는 public 예시와 기본 구조만 두고, 실제 endpoint/c - 설정 파일만 바꾸고 `packages/go/config` loading/default/validation과 불일치하게 두지 않는다. - provider id, model id, route id의 의미를 섞지 않는다. 외부 `model`은 OpenAI-compatible 경계의 route key이고, 내부 실행은 `adapter + target`이다. - credential, bearer token, private endpoint 원문을 tracked docs/roadmap/contract에 기록하지 않는다. +- provider auth raw token 원문을 config, docs, task artifact, metric label에 기록하지 않는다. - Node bootstrap 기본 경로에서 사용자가 직접 node config를 작성하거나 adapter/provider 값을 명령줄로 넣어야 하는 계약을 만들지 않는다. ## 변경 시 확인할 코드/테스트 diff --git a/agent-contract/outer/openai-compatible-api.md b/agent-contract/outer/openai-compatible-api.md index 45b4132..af6d38b 100644 --- a/agent-contract/outer/openai-compatible-api.md +++ b/agent-contract/outer/openai-compatible-api.md @@ -44,6 +44,19 @@ Edge 설정에 `openai.principal_tokens[]`가 설정된 경우, caller는 기존 `openai.principal_tokens[]`가 설정되어 있더라도, raw token이 어떤 `principal_tokens` entry에도 매칭되지 않으면 `openai.bearer_token`이 설정된 경우 legacy 단일 bearer auth가 unmapped fallback으로 동작한다. `openai.bearer_token`과 `openai.principal_tokens[]`가 모두 설정된 경우, principal token 매칭이 실패하면 legacy fallback을 시도하고, 그래도 실패하면 `401 unauthorized`를 반환한다. +### Provider auth forwarding + +`openai.provider_auth.enabled=true`이면 caller는 provider별 raw user token을 `openai.provider_auth.from_header`에 담아 보낸다. 기본 header는 `X-IOP-Provider-Authorization`이다. +Edge는 이 값을 provider tunnel request의 `openai.provider_auth.target_header`로 전달한다. 기본 target header는 `Authorization`, 기본 scheme은 `Bearer`다. + +이 provider token은 IOP inbound auth인 `Authorization: Bearer `과 분리된다. `openai.bearer_token` 또는 `openai.principal_tokens[]`가 쓰는 IOP auth token을 외부 provider credential로 재사용하지 않는다. + +금지: + +- raw provider token을 Edge config, tracked docs, roadmap, task artifact, metric label에 저장하지 않는다. +- host-local `~/.claude/anthropic_key.sh`, `~/.codex/config.toml`, env helper 파일을 OpenAI-compatible provider token source of truth로 읽지 않는다. +- missing required provider auth error body나 log에 raw header 값을 echo하지 않는다. + ## Responses API Endpoint: @@ -297,3 +310,5 @@ Edge 설정이 `openai.model_routes[]`를 제공하면 `model`은 먼저 route c 매칭 route가 없으면 기존 fallback 규칙에 따라 `openai.target` 또는 요청의 `model`을 내부 target으로 사용한다. CLI agent를 OpenAI-compatible API로 노출할 때는 route catalog에서 해당 `model`을 명시적으로 `adapter: "cli"`와 target profile로 매핑하는 방식을 우선한다. + +Top-level `models[]`가 있으면 IOP `/v1/models`와 provider-pool dispatch의 static catalog source of truth다. Seulgivibe provider는 runtime adapter type을 `openai_compat`로 정규화하되 provider family label로 `seulgivibe_claude` 또는 `seulgivibe_openai`를 보존할 수 있다. Tracked catalog 예시는 model/provider mapping만 담고 실제 endpoint credential이나 raw user token은 담지 않는다. diff --git a/agent-task/m-seulgivibe-openai-compatible-provider/01_config_auth_catalog/CODE_REVIEW-cloud-G06.md b/agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/01_config_auth_catalog/code_review_cloud_G06_0.log similarity index 57% rename from agent-task/m-seulgivibe-openai-compatible-provider/01_config_auth_catalog/CODE_REVIEW-cloud-G06.md rename to agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/01_config_auth_catalog/code_review_cloud_G06_0.log index 2cf4d96..52354de 100644 --- a/agent-task/m-seulgivibe-openai-compatible-provider/01_config_auth_catalog/CODE_REVIEW-cloud-G06.md +++ b/agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/01_config_auth_catalog/code_review_cloud_G06_0.log @@ -43,43 +43,47 @@ task=m-seulgivibe-openai-compatible-provider/01_config_auth_catalog, plan=0, tag | 항목 | 완료 여부 | |------|---------| -| [SEULGI_CONFIG-1] Provider type alias | [ ] | -| [SEULGI_CONFIG-2] Provider auth config schema | [ ] | -| [SEULGI_CONFIG-3] Static catalog and contract notes | [ ] | +| [SEULGI_CONFIG-1] Provider type alias | [x] | +| [SEULGI_CONFIG-2] Provider auth config schema | [x] | +| [SEULGI_CONFIG-3] Static catalog and contract notes | [x] | ## 구현 체크리스트 -- [ ] [SEULGI_CONFIG-1] `seulgivibe_claude`, `seulgivibe_openai` provider type을 `openai_compat` runtime으로 정규화하고 mapper provider label에 보존한다. -- [ ] [SEULGI_CONFIG-2] `openai.provider_auth` config schema/default/validation을 추가하되 실제 token 값이나 local helper path를 참조하지 않는다. -- [ ] [SEULGI_CONFIG-3] Seulgivibe 정적 model catalog/계약 예시를 추가하고 실제 endpoint secret/token은 문서에 쓰지 않는다. -- [ ] `go test ./packages/go/config -count=1`와 `go test ./apps/edge/internal/node -count=1`를 실행한다. -- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. +- [x] [SEULGI_CONFIG-1] `seulgivibe_claude`, `seulgivibe_openai` provider type을 `openai_compat` runtime으로 정규화하고 mapper provider label에 보존한다. +- [x] [SEULGI_CONFIG-2] `openai.provider_auth` config schema/default/validation을 추가하되 실제 token 값이나 local helper path를 참조하지 않는다. +- [x] [SEULGI_CONFIG-3] Seulgivibe 정적 model catalog/계약 예시를 추가하고 실제 endpoint secret/token은 문서에 쓰지 않는다. +- [x] `go test ./packages/go/config -count=1`와 `go test ./apps/edge/internal/node -count=1`를 실행한다. +- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. ## 코드리뷰 전용 체크리스트 > **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. > 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. -- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. -- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. -- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G06_N.log`로 아카이브한다. -- [ ] active `PLAN-*-G??.md`를 `plan_cloud_G06_M.log`로 아카이브한다. -- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다. -- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. -- [ ] PASS이면 active task 디렉터리 `agent-task/m-seulgivibe-openai-compatible-provider/01_config_auth_catalog/`를 `agent-task/archive/YYYY/MM/m-seulgivibe-openai-compatible-provider/01_config_auth_catalog/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. -- [ ] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. -- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-seulgivibe-openai-compatible-provider/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G06_N.log`로 아카이브한다. +- [x] active `PLAN-*-G??.md`를 `plan_cloud_G06_M.log`로 아카이브한다. +- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다. +- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [x] PASS이면 active task 디렉터리 `agent-task/m-seulgivibe-openai-compatible-provider/01_config_auth_catalog/`를 `agent-task/archive/YYYY/MM/m-seulgivibe-openai-compatible-provider/01_config_auth_catalog/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [x] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-seulgivibe-openai-compatible-provider/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. (parent에 02/03 subtask가 남아 유지) - [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G06.md`와 `CODE_REVIEW-cloud-G06.md`를 작성하고 `complete.log`를 작성하지 않는다. - [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다. - [ ] USER_REVIEW가 연결된 Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다. ## 계획 대비 변경 사항 -_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._ +- 구현 범위는 plan과 동일하다. `configs/edge.yaml`은 plan의 "existing sample config에 placeholder-only example 추가" 조건에 맞는 기존 sample 위치라 함께 갱신했다. +- local `/tmp`가 `no space left on device`로 Go work dir 생성을 실패시켜, Go 검증 명령은 `TMPDIR`, `GOTMPDIR`, `GOCACHE`를 `/config/workspace/.iop-go-tmp/...`로 지정해 실행했다. 소스/검증 의미는 동일하며 repo 내부 artifact는 남기지 않았다. ## 주요 설계 결정 -_구현 에이전트가 주요 설계 결정 사항을 기록한다._ +- `openai.provider_auth`는 disabled 상태에서 zero-value를 유지하고, enabled일 때만 `from_header`, `target_header`, `scheme`, `required` 기본값을 적용한다. +- `from_header`와 `target_header`는 명시적으로 blank가 들어오면 설정 오류로 처리하고, 생략된 경우에만 안전한 기본값을 적용한다. +- Seulgivibe alias type은 runtime adapter type을 `openai_compat`로 정규화하되, `provider:`가 생략된 provider-first mapper payload에는 원래 alias label을 보존한다. +- tracked config/contract 예시는 `example.invalid` endpoint와 header 이름만 담고, raw token 또는 개인 endpoint 원문은 담지 않았다. ## 사용자 리뷰 요청 @@ -113,36 +117,34 @@ _구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 ### SEULGI_CONFIG-1 중간 검증 ``` -$ go test ./packages/go/config -run 'TestLoadEdge_SeulgivibeProviderFirstAliases' -count=1 -(output) -$ go test ./apps/edge/internal/node -run 'TestBuildConfigPayload_ProviderFirstSeulgivibeDefaultsProviderFromType' -count=1 -(output) +$ env TMPDIR=/config/workspace/.iop-go-tmp/go-build GOTMPDIR=/config/workspace/.iop-go-tmp/go-build GOCACHE=/config/workspace/.iop-go-tmp/go-cache go test ./packages/go/config -run 'TestLoadEdge_SeulgivibeProviderFirstAliases' -count=1 +ok iop/packages/go/config 0.032s +$ env TMPDIR=/config/workspace/.iop-go-tmp/go-build GOTMPDIR=/config/workspace/.iop-go-tmp/go-build GOCACHE=/config/workspace/.iop-go-tmp/go-cache go test ./apps/edge/internal/node -run 'TestBuildConfigPayload_ProviderFirstSeulgivibeDefaultsProviderFromType' -count=1 +ok iop/apps/edge/internal/node 0.027s ``` ### SEULGI_CONFIG-2 중간 검증 ``` -$ go test ./packages/go/config -run 'TestLoadEdge_OpenAIProviderAuth|TestLoadEdge_OpenAIDefaults|TestLoadEdge_OpenAIOverride' -count=1 -(output) +$ env TMPDIR=/config/workspace/.iop-go-tmp/go-build GOTMPDIR=/config/workspace/.iop-go-tmp/go-build GOCACHE=/config/workspace/.iop-go-tmp/go-cache go test ./packages/go/config -run 'TestLoadEdge_OpenAIProviderAuth|TestLoadEdge_OpenAIDefaults|TestLoadEdge_OpenAIOverride' -count=1 +ok iop/packages/go/config 0.054s ``` ### SEULGI_CONFIG-3 중간 검증 ``` $ rg --sort path -n 'e[y]J|secret -|OPENAI_API_KEY=.*e[y]J' agent-contract configs -(output) $ test $? -eq 1 -(output) +(no output; `rg` produced no matches and exited 1, `test` passed) ``` ### 최종 검증 ``` -$ go test ./packages/go/config -count=1 -(output) -$ go test ./apps/edge/internal/node -count=1 -(output) +$ env TMPDIR=/config/workspace/.iop-go-tmp/go-build GOTMPDIR=/config/workspace/.iop-go-tmp/go-build GOCACHE=/config/workspace/.iop-go-tmp/go-cache go test ./packages/go/config -count=1 +ok iop/packages/go/config 0.417s +$ env TMPDIR=/config/workspace/.iop-go-tmp/go-build GOTMPDIR=/config/workspace/.iop-go-tmp/go-build GOCACHE=/config/workspace/.iop-go-tmp/go-cache go test ./apps/edge/internal/node -count=1 +ok iop/apps/edge/internal/node 0.023s $ rg --sort path -n 'e[y]J|secret -|OPENAI_API_KEY=.*e[y]J' agent-contract configs -(output) $ test $? -eq 1 -(output) +(no output; `rg` produced no matches and exited 1, `test` passed) ``` --- @@ -150,3 +152,31 @@ $ test $? -eq 1 > **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?** > If anything is blank, go back and fill it in before saving this file. > Leave review-agent-only sections unchanged. + +## 코드리뷰 결과 + +date=2026-07-11 +review=cloud-G06, N=0 + +### 종합 판정 + +PASS + +### 차원별 평가 + +- Correctness: Pass — `NormalizeProviderType`/`openAICompatProviderLabel`의 alias 추가와 `normalizeOpenAIProviderAuth`의 disabled/enabled/blank 처리 로직이 정확하다. `v.InConfig`로 "명시 blank"와 "생략"을 구분해 default vs reject를 올바르게 분기한다. +- Completeness: Pass — plan 3개 항목(alias, provider_auth schema, catalog/contract)이 모두 구현·검증 완료. 구현 체크리스트 및 최종 필수 항목 체크됨. +- Test coverage: Pass — alias load/normalize, proto label 보존, provider_auth disabled/enabled/override/blank-reject, raw-token 필드 부재 reflection 테스트까지 존재하며 assertion이 의미 있다. +- API contract: Pass — inner/outer 계약과 `configs/edge.yaml` 예시가 코드 schema/정규화 동작과 일치한다. +- Code quality: Pass — debug print/dead code/TODO 없음. 기존 패턴(`validateOpenAIPrincipalTokens`, provider label switch)과 일관. +- Implementation deviation: Pass — edge.yaml sample 갱신과 Go temp dir 우회는 review stub `계획 대비 변경 사항`에 기록됨. +- Verification trust: Pass — 리뷰어가 `go test ./packages/go/config`, `go test ./apps/edge/internal/node`, secret scan을 직접 재실행해 stub에 붙은 출력과 일치 확인. +- Spec conformance (SDD Evidence Map): Pass — S01(alias→openai_compat 정규화 + provider label 보존)과 S02(static `models[]` 5개 catalog + served model membership 검증)를 config/mapper 테스트가 커버한다. + +### 발견된 문제 + +- (Nit) plan `SEULGI_CONFIG-2`가 명명한 `TestLoadEdge_OpenAIProviderAuthDefaultsDisabled`는 기존 `TestLoadEdge_OpenAIDefaults`에 disabled-default assertion으로 통합되고, 대신 `TestLoadEdge_OpenAIProviderAuthOverride`와 `TestEdgeOpenAIProviderAuthConf_NoRawTokenField`가 추가되었다. 커버리지는 plan 이상이라 비차단. 별도 수정 불필요. + +### 다음 단계 + +- PASS. `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/01_config_auth_catalog/`로 이동한다. diff --git a/agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/01_config_auth_catalog/complete.log b/agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/01_config_auth_catalog/complete.log new file mode 100644 index 0000000..743225c --- /dev/null +++ b/agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/01_config_auth_catalog/complete.log @@ -0,0 +1,43 @@ +# Complete - m-seulgivibe-openai-compatible-provider/01_config_auth_catalog + +## 완료 일시 + +2026-07-11 + +## 요약 + +Seulgivibe Claude/OpenAI provider alias, `openai.provider_auth` schema, 정적 model catalog/계약 예시 foundation 구현. 1 loop, 최종 판정 PASS. + +## 루프 이력 + +| Plan | Review | Verdict | 메모 | +|------|--------|---------|------| +| `plan_cloud_G06_0.log` | `code_review_cloud_G06_0.log` | PASS | 3개 항목 모두 구현·검증 완료, 검증 명령 리뷰어 재실행 일치 | + +## 구현/정리 내용 + +- `seulgivibe_claude`/`seulgivibe_openai` provider type을 `openai_compat` runtime으로 정규화하고, 명시 `provider:`가 없으면 provider label에 alias를 보존한다 (`packages/go/config/config.go`, `apps/edge/internal/node/mapper.go`). +- `openai.provider_auth` config schema/default/validation 추가 (`EdgeOpenAIProviderAuthConf`, `normalizeOpenAIProviderAuth`): disabled 기본, enabled 시 `from_header=X-IOP-Provider-Authorization`/`target_header=Authorization`/`scheme=Bearer`/`required=true` default, 명시 blank header reject, raw token 미저장. +- Seulgivibe 정적 model catalog와 provider auth forwarding 계약을 inner/outer contract, `configs/edge.yaml` placeholder 예시에 추가. 실제 endpoint/token 대신 `example.invalid` placeholder만 사용. + +## 최종 검증 + +- `go test ./packages/go/config -count=1` - PASS; `ok iop/packages/go/config` +- `go test ./apps/edge/internal/node -count=1` - PASS; `ok iop/apps/edge/internal/node` +- `rg --sort path -n 'e[y]J|secret -|OPENAI_API_KEY=.*e[y]J' agent-contract configs; test $? -eq 1` - PASS; 매치 없음, rg exit 1 + +## Roadmap Completion + +- Milestone: `agent-roadmap/phase/routing-policy-model-orchestration/milestones/seulgivibe-openai-compatible-provider.md` +- Milestone link: [Milestone 문서](agent-roadmap/phase/routing-policy-model-orchestration/milestones/seulgivibe-openai-compatible-provider.md) +- Completed task ids: + - `config-auth-catalog`: PASS; evidence=`plan_cloud_G06_0.log`, `code_review_cloud_G06_0.log`; verification=`go test ./packages/go/config -count=1`, `go test ./apps/edge/internal/node -count=1`, secret scan +- Not completed task ids: 없음 + +## 잔여 Nit + +- plan `SEULGI_CONFIG-2`의 disabled-default 테스트가 기존 `TestLoadEdge_OpenAIDefaults`로 통합되고 override/reflection 테스트가 추가됨. 커버리지는 plan 이상이라 조치 불필요. + +## 후속 작업 + +- 01 PASS로 후속 split subtask `02+01_provider_tunnel_auth`, `03+01,02_responses_passthrough`의 선행 의존성이 충족됨. 두 subtask는 별도 active task로 진행한다. diff --git a/agent-task/m-seulgivibe-openai-compatible-provider/01_config_auth_catalog/PLAN-cloud-G06.md b/agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/01_config_auth_catalog/plan_cloud_G06_0.log similarity index 100% rename from agent-task/m-seulgivibe-openai-compatible-provider/01_config_auth_catalog/PLAN-cloud-G06.md rename to agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/01_config_auth_catalog/plan_cloud_G06_0.log diff --git a/agent-task/m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth/CODE_REVIEW-cloud-G07.md b/agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth/code_review_cloud_G07_0.log similarity index 77% rename from agent-task/m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth/CODE_REVIEW-cloud-G07.md rename to agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth/code_review_cloud_G07_0.log index 1859b04..e41b7fc 100644 --- a/agent-task/m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth/CODE_REVIEW-cloud-G07.md +++ b/agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth/code_review_cloud_G07_0.log @@ -58,16 +58,16 @@ task=m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth, plan=0, > **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. > 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. -- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. -- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. -- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G07_N.log`로 아카이브한다. -- [ ] active `PLAN-*-G??.md`를 `plan_cloud_G07_M.log`로 아카이브한다. -- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다. +- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G07_N.log`로 아카이브한다. +- [x] active `PLAN-*-G??.md`를 `plan_cloud_G07_M.log`로 아카이브한다. +- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다. - [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. - [ ] PASS이면 active task 디렉터리 `agent-task/m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth/`를 `agent-task/archive/YYYY/MM/m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. - [ ] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. - [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-seulgivibe-openai-compatible-provider/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. -- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.md`를 작성하고 `complete.log`를 작성하지 않는다. +- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.md`를 작성하고 `complete.log`를 작성하지 않는다. - [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다. - [ ] USER_REVIEW가 연결된 Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다. @@ -133,3 +133,21 @@ $ go test ./apps/edge/internal/openai -count=1 > **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?** > If anything is blank, go back and fill it in before saving this file. > Leave review-agent-only sections unchanged. + +## 코드리뷰 결과 + +- 종합 판정: FAIL +- 차원별 평가: + - correctness: Fail + - completeness: Fail + - test coverage: Fail + - API contract: Fail + - code quality: Pass + - implementation deviation: Fail + - verification trust: Fail + - spec conformance: Fail +- 발견된 문제: + - Required `apps/edge/internal/openai/stream.go:370`: `SubmitProviderTunnelRequest`가 여전히 `Headers` 없이 생성되고, `openai.provider_auth`에서 request-time raw provider token을 읽는 helper 호출도 없다. SDD S03과 plan `SEULGI_TUNNEL-1/2`가 요구한 provider `Authorization` forwarding 및 missing-required 사전 차단이 구현되지 않았다. `ProviderAuth` 설정을 읽는 helper를 추가하고, tunnel dispatch 전에 호출해 `Headers`를 채우며 required header 누락 시 `SubmitProviderTunnel` 전에 400 `invalid_request_error`를 반환해야 한다. + - Required `apps/edge/internal/openai/server_test.go:77`: fake tunnel service는 request를 캡처할 수 있지만 provider auth forwarding/missing-required/assertion 테스트가 추가되지 않았다. `go test ./apps/edge/internal/openai -run 'TestProviderTunnelAuthHeaders|TestChatProviderTunnelProviderAuth|TestChatProviderTunnelForwardsProviderAuthHeader' -count=1`를 임시 `GOCACHE=/tmp/iop-go-build-cache`로 실행하면 `ok ... [no tests to run]`이므로 SDD Evidence Map의 S03 증거가 없다. 성공 forwarding, missing-required no-dispatch, inbound IOP `Authorization` 분리 테스트를 추가해야 한다. + - Required `agent-task/m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth/CODE_REVIEW-cloud-G07.md:45`: 구현 항목별 완료 여부, 구현 체크리스트, 계획 대비 변경 사항, 주요 설계 결정, 검증 결과가 모두 비어 있어 구현 완료 상태와 검증 출력을 신뢰할 수 없다. 실제 구현과 검증 후 구현 에이전트 소유 섹션을 채우고 최종 `go test ./apps/edge/internal/openai -count=1` stdout/stderr를 기록해야 한다. +- 다음 단계: FAIL 후속 `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.md`를 작성한다. `USER_REVIEW.md`는 트리거하지 않는다. diff --git a/agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth/code_review_cloud_G07_1.log b/agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth/code_review_cloud_G07_1.log new file mode 100644 index 0000000..7040544 --- /dev/null +++ b/agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth/code_review_cloud_G07_1.log @@ -0,0 +1,219 @@ + + +# Code Review Reference - REVIEW_SEULGI_TUNNEL + +> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.** +> The task is NOT complete until every implementation-owned section below is filled in. +> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving. +> Fill implementation-owned sections, then stop with active files in place and report ready for review. +> If implementation is blocked by a selected Milestone `구현 잠금 > 결정 필요` item, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Environment/secret/service setup, generic scope conflicts, loop exhaustion, and evidence gaps that a follow-up agent can close are normal follow-up issues, not user-review blockers by themselves. +> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record only the linked Milestone lock decision in `사용자 리뷰 요청` and stop for code-review. +> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume. +> Follow the ownership table at the bottom of this file for which sections you own. + +## 개요 + +date=2026-07-11 +task=m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth, plan=1, tag=REVIEW_SEULGI_TUNNEL + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/routing-policy-model-orchestration/milestones/seulgivibe-openai-compatible-provider.md` +- Milestone link: [Milestone 문서](agent-roadmap/phase/routing-policy-model-orchestration/milestones/seulgivibe-openai-compatible-provider.md) +- Task ids: + - `provider-token-tunnel`: Edge OpenAI Chat Completions provider tunnel이 request-time raw user token을 provider Authorization header로 전달 +- Completion mode: check-on-pass + +## Archive Evidence Snapshot + +- Current archived plan log: `agent-task/m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth/plan_cloud_G07_0.log` +- Current archived review log: `agent-task/m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth/code_review_cloud_G07_0.log` +- Verdict: FAIL +- Review number: 1 +- Required summary: + - Required 1: `apps/edge/internal/openai/stream.go:370` still creates `SubmitProviderTunnelRequest` without `Headers`; provider auth helper/injection/missing-required behavior is not implemented. + - Required 2: `apps/edge/internal/openai/server_test.go:77` captures tunnel requests, but no provider auth forwarding/missing-required/inbound auth separation tests exist. + - Required 3: `CODE_REVIEW-cloud-G07.md` implementation-owned sections and verification output were blank, so completion and verification trust were not judgeable. +- Affected files: + - `apps/edge/internal/openai/stream.go` + - `apps/edge/internal/openai/server_test.go` +- Verification evidence: + - `git diff -- apps/edge/internal/openai` was empty at review time. + - `rg -n "providerTunnelAuthHeaders|errProviderAuthRequired|ProviderAuth|X-IOP-Provider-Authorization|TargetHeader|FromHeader" apps/edge/internal/openai apps/edge/internal/openai/server_test.go` found only unrelated bearer-token helper references and no provider auth implementation/tests. + - `GOCACHE=/tmp/iop-go-build-cache go test ./apps/edge/internal/openai -run 'TestProviderTunnelAuthHeaders|TestChatProviderTunnelProviderAuth|TestChatProviderTunnelForwardsProviderAuthHeader' -count=1` returned `ok iop/apps/edge/internal/openai 0.008s [no tests to run]`. + - Plain `go test ./apps/edge/internal/openai -run 'TestProviderTunnelAuthHeaders|TestChatProviderTunnelProviderAuth|TestChatProviderTunnelForwardsProviderAuthHeader' -count=1` failed before package setup with `/config/.cache/go-build/... permission denied`; if this environment issue persists, record the failure and rerun with a temporary `GOCACHE` while keeping the same package/test arguments. +- Roadmap carryover: + - SDD S03 requires configured provider auth request header raw token to become provider `Authorization`, and missing-required must be blocked before dispatch. + - `agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/01_config_auth_catalog/complete.log` shows predecessor `config-auth-catalog` completed and this split dependency is satisfied. +- Allowed narrow reread: + - `agent-task/m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth/plan_cloud_G07_0.log` + - `agent-task/m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth/code_review_cloud_G07_0.log` + - `agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/01_config_auth_catalog/complete.log` + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. +리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. + +1. 판정을 append한다. +2. `CODE_REVIEW-cloud-G07.md` -> `code_review_cloud_G07_N.log`, `PLAN-cloud-G07.md` -> `plan_cloud_G07_M.log`로 아카이브한다. +3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 연결된 Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다. +4. PASS이고 task group이 `m-`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [REVIEW_SEULGI_TUNNEL-1] Provider auth helper and injection | [x] | +| [REVIEW_SEULGI_TUNNEL-2] Handler tests | [x] | +| [REVIEW_SEULGI_TUNNEL-3] Verification evidence and review stub | [x] | + +## 구현 체크리스트 + +- [x] [REVIEW_SEULGI_TUNNEL-1] Provider auth helper를 추가하고 Chat Completions provider tunnel 요청에 provider auth headers를 주입한다. +- [x] [REVIEW_SEULGI_TUNNEL-2] provider auth forwarding, missing-required no-dispatch, inbound IOP auth 분리 handler tests를 추가하고 targeted test를 실행한다. +- [x] [REVIEW_SEULGI_TUNNEL-3] 최종 `go test ./apps/edge/internal/openai -count=1` 결과와 구현 내용을 CODE_REVIEW-cloud-G07.md 구현 에이전트 소유 섹션에 기록한다. +- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 코드리뷰 전용 체크리스트 + +> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. +> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. + +- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G07_N.log`로 아카이브한다. +- [x] active `PLAN-*-G??.md`를 `plan_cloud_G07_M.log`로 아카이브한다. +- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다. +- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [x] PASS이면 active task 디렉터리 `agent-task/m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth/`를 `agent-task/archive/YYYY/MM/m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [x] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-seulgivibe-openai-compatible-provider/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.md`를 작성하고 `complete.log`를 작성하지 않는다. +- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다. +- [ ] USER_REVIEW가 연결된 Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다. + +## 계획 대비 변경 사항 + +- helper는 계획이 허용한 대로 `apps/edge/internal/openai/stream.go`의 `submitChatCompletionTunnel` 바로 앞에 추가했다. 별도 helper 파일을 만들지 않고 기존 파일을 수정하는 프로젝트 규칙을 따랐다. +- 검증 명령은 계약대로 유지했으나, 기본 `go test`가 `/config/.cache/go-build` 권한 오류로 package setup 전에 실패했다. FAIL 리뷰의 Verification evidence 지침대로 같은 package/test arguments를 유지한 채 임시 `GOCACHE=/tmp/iop-go-build-cache`로 재실행했다. 실제 실패 출력과 재실행 출력은 모두 `검증 결과`에 기록했다. +- 테스트 픽스처 헬퍼 `chatProviderAuthServer`와 `staticOKTunnelFrames`를 추가했다. 기존 `chatSidebandServer`/`staticProviderTunnelFrames`는 provider_auth 설정 주입이나 200 프레임 구성이 달라 그대로 재사용할 수 없어, provider-pool 카탈로그(`pool-model`)만 동일하게 맞춘 최소 헬퍼를 새로 두었다. + +## 주요 설계 결정 + +- provider token은 오직 configured `openai.provider_auth.from_header`에서만 읽고, 인바운드 IOP `Authorization` 헤더는 provider credential로 재사용하지 않는다(계약 금지 항목). helper는 raw token/scheme 조합만 반환하며 raw token을 로그/에러/metadata에 남기지 않는다. +- scheme 접두 처리는 대소문자 무시 prefix 비교로 구현해, 이미 `Bearer `으로 온 값은 이중 접두(`Bearer Bearer ...`) 없이 보존한다. +- missing-required는 `errProviderAuthRequired` sentinel로 표현하고, `SubmitProviderTunnel` 호출 전에 `400 invalid_request_error`로 종료한다. 에러 본문에는 raw token을 넣지 않기 위해 고정 문구("provider auth token is required")만 쓴다. +- disabled이거나 required가 아닌데 헤더가 없으면 `nil` headers를 반환해 tunnel 요청에 provider auth 헤더가 생기지 않도록 하고, 기존 passthrough 동작을 바꾸지 않는다. + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 연결 대상: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- Provider token is read only from configured `openai.provider_auth.from_header`. +- Missing required provider auth returns before `SubmitProviderTunnel`. +- Inbound IOP `Authorization` and outbound provider `Authorization` remain separate. +- `SubmitProviderTunnelRequest.Headers` contains only the formatted provider target header and does not store raw token elsewhere. +- Targeted auth tests actually run; `[no tests to run]` is not acceptable. + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +필수 규칙: +- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. +- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. +- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. +- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. +- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다. + +환경 메모: 기본 `go test`는 `/config/.cache/go-build` 권한 오류로 package setup 전에 실패한다. FAIL 리뷰의 Verification evidence 지침대로 같은 package/test arguments를 유지한 채 임시 `GOCACHE=/tmp/iop-go-build-cache`로 재실행했다. + +### REVIEW_SEULGI_TUNNEL-1 중간 검증 +``` +$ go test ./apps/edge/internal/openai -run 'TestProviderTunnelAuthHeaders|TestChatProviderTunnelProviderAuth|TestChatProviderTunnelForwardsProviderAuthHeader' -count=1 +# ./apps/edge/internal/openai +open /config/.cache/go-build/ed/ed613d4d52c717039063dd04b3548d2be21024e0178c5589b824adf6c821751e-d: permission denied +FAIL ./apps/edge/internal/openai [setup failed] +FAIL + +$ GOCACHE=/tmp/iop-go-build-cache go test ./apps/edge/internal/openai -run 'TestProviderTunnelAuthHeaders|TestChatProviderTunnelProviderAuth|TestChatProviderTunnelForwardsProviderAuthHeader' -count=1 -v +=== RUN TestChatProviderTunnelForwardsProviderAuthHeader +=== RUN TestChatProviderTunnelForwardsProviderAuthHeader/raw_token_gets_scheme +=== RUN TestChatProviderTunnelForwardsProviderAuthHeader/already_prefixed_preserved +--- PASS: TestChatProviderTunnelForwardsProviderAuthHeader (0.00s) + --- PASS: TestChatProviderTunnelForwardsProviderAuthHeader/raw_token_gets_scheme (0.00s) + --- PASS: TestChatProviderTunnelForwardsProviderAuthHeader/already_prefixed_preserved (0.00s) +=== RUN TestChatProviderTunnelProviderAuthRequired +--- PASS: TestChatProviderTunnelProviderAuthRequired (0.00s) +=== RUN TestChatProviderTunnelProviderAuthDoesNotReplaceInboundAuth +--- PASS: TestChatProviderTunnelProviderAuthDoesNotReplaceInboundAuth (0.00s) +PASS +ok iop/apps/edge/internal/openai 0.010s +``` + +### REVIEW_SEULGI_TUNNEL-2 중간 검증 +``` +$ GOCACHE=/tmp/iop-go-build-cache go test ./apps/edge/internal/openai -run 'TestChatProviderTunnelProviderAuth|TestChatProviderTunnelForwardsProviderAuthHeader|TestChatProviderTunnelProviderAuthDoesNotReplaceInboundAuth' -count=1 +ok iop/apps/edge/internal/openai 0.009s +``` + +### REVIEW_SEULGI_TUNNEL-3 중간 검증 +``` +$ GOCACHE=/tmp/iop-go-build-cache go test ./apps/edge/internal/openai -count=1 +ok iop/apps/edge/internal/openai 1.813s +``` + +### 최종 검증 +``` +$ go test ./apps/edge/internal/openai -count=1 +# ./apps/edge/internal/openai +open /config/.cache/go-build/ed/ed613d4d52c717039063dd04b3548d2be21024e0178c5589b824adf6c821751e-d: permission denied +FAIL ./apps/edge/internal/openai [setup failed] +FAIL + +$ GOCACHE=/tmp/iop-go-build-cache go test ./apps/edge/internal/openai -count=1 +ok iop/apps/edge/internal/openai 1.813s +``` + +--- + +> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?** +> If anything is blank, go back and fill it in before saving this file. +> Leave review-agent-only sections unchanged. + +## 코드리뷰 결과 + +- 종합 판정: PASS +- 차원별 평가: + - correctness: Pass + - completeness: Pass + - test coverage: Pass + - API contract: Pass + - code quality: Pass + - implementation deviation: Pass + - verification trust: Pass + - spec conformance: Pass +- 발견된 문제: 없음 +- 검토 근거: + - `apps/edge/internal/openai/stream.go:368`에서 configured provider auth header만 읽고, missing-required를 dispatch 전에 400으로 반환하며 raw token을 error surface에 싣지 않는다. + - `apps/edge/internal/openai/stream.go:410`의 `SubmitProviderTunnelRequest.Headers`에 provider target header가 전달된다. + - `apps/edge/internal/openai/server_test.go:4549`, `apps/edge/internal/openai/server_test.go:4592`, `apps/edge/internal/openai/server_test.go:4623`이 SDD S03의 forwarding, missing-required no-dispatch, inbound IOP auth 분리 조건을 검증한다. + - 검증 재실행 결과가 구현 에이전트 기록과 일치했다. 기본 `go test`는 로컬 cache 권한 문제로 setup 전에 실패했고, 같은 package/test arguments에 `GOCACHE=/tmp/iop-go-build-cache`를 지정한 targeted/package tests는 통과했다. +- 다음 단계: PASS이므로 active plan/review를 로그로 아카이브하고 `complete.log`를 작성한 뒤 task directory를 `agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth/`로 이동한다. diff --git a/agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth/complete.log b/agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth/complete.log new file mode 100644 index 0000000..502a2e7 --- /dev/null +++ b/agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth/complete.log @@ -0,0 +1,44 @@ +# Complete - m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth + +## 완료 일시 + +2026-07-11 + +## 요약 + +Chat Completions provider tunnel에 request-time provider auth header forwarding과 missing-required no-dispatch 검증을 추가했고, loop 2에서 최종 판정 PASS로 종료했다. + +## 루프 이력 + +| Plan | Review | Verdict | 메모 | +|------|--------|---------|------| +| `plan_cloud_G07_0.log` | `code_review_cloud_G07_0.log` | FAIL | provider auth forwarding 구현, handler tests, 구현 증거가 없어서 follow-up으로 전환 | +| `plan_cloud_G07_1.log` | `code_review_cloud_G07_1.log` | PASS | SDD S03 구현과 targeted/package 검증 증거 확인 완료 | + +## 구현/정리 내용 + +- `apps/edge/internal/openai/stream.go`에 provider auth helper를 추가해 configured `openai.provider_auth.from_header` 값만 provider tunnel target header로 전달한다. +- missing-required provider auth는 `SubmitProviderTunnel` 호출 전에 `400 invalid_request_error`로 차단하고 raw token을 error/log/metadata에 싣지 않는다. +- `apps/edge/internal/openai/server_test.go`에 provider auth forwarding, missing-required no-dispatch, inbound IOP auth 분리 handler tests를 추가했다. + +## 최종 검증 + +- `go test ./apps/edge/internal/openai -run 'TestProviderTunnelAuthHeaders|TestChatProviderTunnelProviderAuth|TestChatProviderTunnelForwardsProviderAuthHeader' -count=1` - BLOCKED; local default Go cache permission denied before package setup: `open /config/.cache/go-build/... permission denied`. +- `GOCACHE=/tmp/iop-go-build-cache go test ./apps/edge/internal/openai -run 'TestProviderTunnelAuthHeaders|TestChatProviderTunnelProviderAuth|TestChatProviderTunnelForwardsProviderAuthHeader' -count=1 -v` - PASS; provider auth forwarding, missing-required, inbound auth separation tests ran and passed. +- `GOCACHE=/tmp/iop-go-build-cache go test ./apps/edge/internal/openai -count=1` - PASS; `ok iop/apps/edge/internal/openai 1.810s`. + +## Roadmap Completion + +- Milestone: `agent-roadmap/phase/routing-policy-model-orchestration/milestones/seulgivibe-openai-compatible-provider.md` +- Milestone link: [Milestone 문서](agent-roadmap/phase/routing-policy-model-orchestration/milestones/seulgivibe-openai-compatible-provider.md) +- Completed task ids: + - `provider-token-tunnel`: PASS; evidence=`agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth/plan_cloud_G07_1.log`, `agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth/code_review_cloud_G07_1.log`; verification=`GOCACHE=/tmp/iop-go-build-cache go test ./apps/edge/internal/openai -count=1` +- Not completed task ids: 없음 + +## 잔여 Nit + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth/PLAN-cloud-G07.md b/agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth/plan_cloud_G07_0.log similarity index 100% rename from agent-task/m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth/PLAN-cloud-G07.md rename to agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth/plan_cloud_G07_0.log diff --git a/agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth/plan_cloud_G07_1.log b/agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth/plan_cloud_G07_1.log new file mode 100644 index 0000000..43edba2 --- /dev/null +++ b/agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth/plan_cloud_G07_1.log @@ -0,0 +1,171 @@ + + +# Implementation Plan - REVIEW_SEULGI_TUNNEL + +## 이 파일을 읽는 구현 에이전트에게 + +`CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우는 것이 필수 완료 조건이다. 구현 후 검증을 실행하고, 실제 stdout/stderr를 붙이고, active 파일은 그대로 둔 뒤 review ready로 보고한다. selected Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 막는 경우에만 review stub의 `사용자 리뷰 요청`에 정확한 근거를 기록하고 멈춘다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 외부 secret/service 준비, 검증 증거 공백, 일반 범위 조정은 사용자 리뷰 요청이 아니라 후속 plan 또는 검증 기록으로 처리한다. finalization은 code-review-skill 전용이다. + +## 배경 + +직전 리뷰는 `FAIL`이다. `02+01_provider_tunnel_auth`의 계획 파일과 리뷰 스텁은 있었지만, `apps/edge/internal/openai` 변경이 없고, provider auth forwarding/missing-required 테스트도 없으며, 구현 에이전트 소유 리뷰 섹션과 검증 출력도 비어 있었다. 이 follow-up은 SDD S03과 Milestone task `provider-token-tunnel`을 만족하도록 Edge OpenAI Chat Completions provider tunnel에 request-time provider auth header forwarding을 구현하고 증거를 복구한다. + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/routing-policy-model-orchestration/milestones/seulgivibe-openai-compatible-provider.md` +- Milestone link: [Milestone 문서](agent-roadmap/phase/routing-policy-model-orchestration/milestones/seulgivibe-openai-compatible-provider.md) +- Task ids: + - `provider-token-tunnel`: Edge OpenAI Chat Completions provider tunnel이 request-time raw user token을 provider Authorization header로 전달 +- Completion mode: check-on-pass + +## Archive Evidence Snapshot + +- Current archived plan log: `agent-task/m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth/plan_cloud_G07_0.log` +- Current archived review log: `agent-task/m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth/code_review_cloud_G07_0.log` +- Verdict: FAIL +- Review number: 1 +- Required summary: + - Required 1: `apps/edge/internal/openai/stream.go:370` still creates `SubmitProviderTunnelRequest` without `Headers`; provider auth helper/injection/missing-required behavior is not implemented. + - Required 2: `apps/edge/internal/openai/server_test.go:77` captures tunnel requests, but no provider auth forwarding/missing-required/inbound auth separation tests exist. + - Required 3: `CODE_REVIEW-cloud-G07.md` implementation-owned sections and verification output were blank, so completion and verification trust were not judgeable. +- Affected files: + - `apps/edge/internal/openai/stream.go` + - `apps/edge/internal/openai/server_test.go` +- Verification evidence: + - `git diff -- apps/edge/internal/openai` was empty at review time. + - `rg -n "providerTunnelAuthHeaders|errProviderAuthRequired|ProviderAuth|X-IOP-Provider-Authorization|TargetHeader|FromHeader" apps/edge/internal/openai apps/edge/internal/openai/server_test.go` found only unrelated bearer-token helper references and no provider auth implementation/tests. + - `GOCACHE=/tmp/iop-go-build-cache go test ./apps/edge/internal/openai -run 'TestProviderTunnelAuthHeaders|TestChatProviderTunnelProviderAuth|TestChatProviderTunnelForwardsProviderAuthHeader' -count=1` returned `ok iop/apps/edge/internal/openai 0.008s [no tests to run]`. + - Plain `go test ./apps/edge/internal/openai -run 'TestProviderTunnelAuthHeaders|TestChatProviderTunnelProviderAuth|TestChatProviderTunnelForwardsProviderAuthHeader' -count=1` failed before package setup with `/config/.cache/go-build/... permission denied`; if this environment issue persists, record the failure and rerun with a temporary `GOCACHE` while keeping the same package/test arguments. +- Roadmap carryover: + - SDD S03 requires configured provider auth request header raw token to become provider `Authorization`, and missing-required must be blocked before dispatch. + - `agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/01_config_auth_catalog/complete.log` shows predecessor `config-auth-catalog` completed and this split dependency is satisfied. +- Allowed narrow reread: + - `agent-task/m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth/plan_cloud_G07_0.log` + - `agent-task/m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth/code_review_cloud_G07_0.log` + - `agent-task/archive/2026/07/m-seulgivibe-openai-compatible-provider/01_config_auth_catalog/complete.log` + +## 분석 결과 + +### 계약 / SDD 기준 + +- `agent-contract/outer/openai-compatible-api.md`의 Provider auth forwarding 계약은 `openai.provider_auth.enabled=true`일 때 caller가 `from_header`에 담은 raw user token을 provider tunnel request의 `target_header`로 전달한다고 정의한다. +- 같은 계약과 SDD는 IOP inbound auth `Authorization: Bearer `을 provider credential source로 재사용하지 말라고 금지한다. +- SDD Evidence Map의 S03 완료 증거는 Edge OpenAI handler auth forwarding/missing tests와 `go test ./apps/edge/internal/openai -count=1` 결과다. + +### 현재 코드 상태 + +- `packages/go/config/config.go`에는 01에서 추가된 `EdgeOpenAIProviderAuthConf`와 기본값 normalization이 있다. +- `apps/edge/internal/openai/stream.go`의 `submitChatCompletionTunnel`은 아직 `ProviderAuth` 설정을 읽지 않고 `SubmitProviderTunnelRequest.Headers`도 채우지 않는다. +- `apps/edge/internal/openai/server_test.go`의 fake service는 tunnel request를 저장하므로 `Headers` assertion을 추가할 수 있다. + +### 범위 결정 근거 + +- 이 follow-up은 Chat Completions provider tunnel auth만 다룬다. `/v1/responses` passthrough는 sibling task `03+01,02_responses_passthrough` 범위다. +- Node adapter/proto/service tunnel relay는 이미 `SubmitProviderTunnelRequest.Headers`를 전달할 수 있으므로, 이 task에서 변경하지 않는다. +- 실제 token 값, host-local helper, env secret, Codex/Claude local config를 읽거나 기록하지 않는다. + +### 빌드 등급 + +`cloud-G07`. 외부 OpenAI-compatible auth 계약, raw token forwarding 금지/허용 경계, SDD Evidence Map 검증이 핵심이다. + +## 구현 체크리스트 + +- [ ] [REVIEW_SEULGI_TUNNEL-1] Provider auth helper를 추가하고 Chat Completions provider tunnel 요청에 provider auth headers를 주입한다. +- [ ] [REVIEW_SEULGI_TUNNEL-2] provider auth forwarding, missing-required no-dispatch, inbound IOP auth 분리 handler tests를 추가하고 targeted test를 실행한다. +- [ ] [REVIEW_SEULGI_TUNNEL-3] 최종 `go test ./apps/edge/internal/openai -count=1` 결과와 구현 내용을 CODE_REVIEW-cloud-G07.md 구현 에이전트 소유 섹션에 기록한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +### [REVIEW_SEULGI_TUNNEL-1] Provider Auth Helper And Injection + +#### 문제 + +`apps/edge/internal/openai/stream.go:370`의 `SubmitProviderTunnelRequest`는 `Headers` 없이 만들어진다. `openai.provider_auth` 설정은 01에서 생겼지만 Edge OpenAI surface가 request-time provider token을 읽어 provider tunnel header로 변환하지 않는다. + +#### 해결 방법 + +`apps/edge/internal/openai` package에 small helper를 추가한다. helper는 `s.cfg.ProviderAuth`를 읽고: + +- disabled이면 `nil, nil`을 반환한다. +- enabled이면 `FromHeader`에서 raw token을 읽는다. +- raw token이 비어 있고 `Required`이면 sentinel error를 반환한다. +- `Scheme`이 비어 있지 않고 raw token이 이미 같은 scheme으로 시작하지 않으면 ` ` 형식으로 만든다. +- 반환 map은 `{TargetHeader: value}`만 포함한다. + +`submitChatCompletionTunnel`에서는 `SubmitProviderTunnelRequest` 생성 전에 helper를 호출한다. missing-required error는 `SubmitProviderTunnel` 호출 전에 `400 invalid_request_error`로 반환하며, raw token을 error/log/metadata에 쓰지 않는다. 성공 시 `Headers: headers`를 tunnel request에 넣는다. + +#### 체크리스트 + +- [ ] `apps/edge/internal/openai/stream.go` 또는 같은 package의 작은 helper 파일에 provider auth helper를 추가한다. +- [ ] `submitChatCompletionTunnel`에서 helper를 dispatch 전에 호출한다. +- [ ] `SubmitProviderTunnelRequest.Headers`에 helper 결과를 넣는다. +- [ ] missing required provider auth는 `SubmitProviderTunnel` 전에 400으로 끝내고 raw token을 echo하지 않는다. + +#### 중간 검증 + +```bash +go test ./apps/edge/internal/openai -run 'TestProviderTunnelAuthHeaders|TestChatProviderTunnelProviderAuth|TestChatProviderTunnelForwardsProviderAuthHeader' -count=1 +``` + +### [REVIEW_SEULGI_TUNNEL-2] Handler Tests + +#### 문제 + +SDD S03을 증명하는 handler tests가 없다. review-time targeted test는 `[no tests to run]`이었다. + +#### 해결 방법 + +`apps/edge/internal/openai/server_test.go`에 다음 케이스를 추가한다. + +- `TestChatProviderTunnelForwardsProviderAuthHeader`: `openai.provider_auth.enabled=true`, caller header `X-IOP-Provider-Authorization: user-token`, captured tunnel request `Headers["Authorization"] == "Bearer user-token"`. +- `TestChatProviderTunnelProviderAuthRequired`: required provider auth header가 없으면 response 400, fake service의 `tunnelReqsSnapshot()` 길이 0. +- `TestChatProviderTunnelProviderAuthDoesNotReplaceInboundAuth`: `openai.bearer_token`과 provider auth가 함께 설정된 상태에서 inbound `Authorization`은 IOP auth로만 쓰고 provider header는 configured provider auth header에서 만든다. + +필요하면 helper formatting test를 별도로 추가하되, handler-level tests가 raw `Bearer token` already-prefixed value를 보존하는지도 확인해야 한다. + +#### 체크리스트 + +- [ ] provider auth forwarding success test를 추가한다. +- [ ] missing-required no-dispatch test를 추가한다. +- [ ] inbound IOP auth와 outbound provider auth 분리 test를 추가한다. +- [ ] targeted test 출력에 `[no tests to run]`이 남지 않게 한다. + +#### 중간 검증 + +```bash +go test ./apps/edge/internal/openai -run 'TestChatProviderTunnelProviderAuth|TestChatProviderTunnelForwardsProviderAuthHeader|TestChatProviderTunnelProviderAuthDoesNotReplaceInboundAuth' -count=1 +``` + +### [REVIEW_SEULGI_TUNNEL-3] Verification Evidence And Review Stub + +#### 문제 + +이전 `CODE_REVIEW-cloud-G07.md`는 구현 항목별 완료 여부, 구현 체크리스트, 계획 대비 변경 사항, 주요 설계 결정, 검증 결과가 비어 있었다. 구현이 있어도 리뷰가 검증 신뢰를 확보할 수 없는 상태다. + +#### 해결 방법 + +구현 후 active `CODE_REVIEW-cloud-G07.md`에서 구현 에이전트 소유 섹션만 채운다. 검증 결과에는 실제 stdout/stderr를 붙인다. 기본 `go test`가 `/config/.cache/go-build` 권한으로 실패하면 그 실패 출력도 기록하고, 같은 package/test arguments를 유지한 채 temporary `GOCACHE`로 재실행한 출력도 함께 기록한다. + +#### 체크리스트 + +- [ ] 구현 항목별 완료 여부 table을 실제 완료 상태로 체크한다. +- [ ] 구현 체크리스트를 실제 완료 상태로 체크한다. +- [ ] 계획 대비 변경 사항과 주요 설계 결정을 placeholder 없이 채운다. +- [ ] targeted test와 final test의 실제 stdout/stderr를 기록한다. + +#### 중간 검증 + +```bash +go test ./apps/edge/internal/openai -count=1 +``` + +## 최종 검증 + +```bash +go test ./apps/edge/internal/openai -count=1 +``` + +Expected: + +- provider auth 관련 targeted tests가 실제로 실행된다. +- 전체 `apps/edge/internal/openai` package test가 통과한다. +- 검증 출력과 구현 내용이 active `CODE_REVIEW-cloud-G07.md`에 기록되어 있다. diff --git a/apps/edge/internal/node/mapper.go b/apps/edge/internal/node/mapper.go index 4ea9008..491627f 100644 --- a/apps/edge/internal/node/mapper.go +++ b/apps/edge/internal/node/mapper.go @@ -241,7 +241,7 @@ func openAICompatProviderLabel(p config.NodeProviderConf) string { return provider } switch strings.TrimSpace(p.Type) { - case "vllm", "lemonade", "sglang", "openai_api": + case "vllm", "lemonade", "sglang", "openai_api", "seulgivibe_claude", "seulgivibe_openai": return strings.TrimSpace(p.Type) default: return "" diff --git a/apps/edge/internal/node/mapper_test.go b/apps/edge/internal/node/mapper_test.go index d359db9..6736c3a 100644 --- a/apps/edge/internal/node/mapper_test.go +++ b/apps/edge/internal/node/mapper_test.go @@ -594,6 +594,8 @@ func TestBuildConfigPayload_ProviderFirstOpenAICompatDefaultsProviderFromType(t {name: "lemonade", typ: "lemonade", want: "lemonade"}, {name: "sglang", typ: "sglang", want: "sglang"}, {name: "openai api", typ: "openai_api", want: "openai_api"}, + {name: "seulgivibe claude", typ: "seulgivibe_claude", want: "seulgivibe_claude"}, + {name: "seulgivibe openai", typ: "seulgivibe_openai", want: "seulgivibe_openai"}, {name: "explicit override", typ: "openai_compat", provider: "vllm-mlx", want: "vllm-mlx"}, } for _, tc := range cases { @@ -639,6 +641,72 @@ func TestBuildConfigPayload_ProviderFirstOpenAICompatDefaultsProviderFromType(t } } +func TestBuildConfigPayload_ProviderFirstSeulgivibeDefaultsProviderFromType(t *testing.T) { + rec := &edgenode.NodeRecord{ + ID: "node-seulgivibe", + Alias: "seulgivibe", + Token: "token", + Providers: []config.NodeProviderConf{ + { + ID: "seulgivibe-claude", + Type: "seulgivibe_claude", + Category: config.CategoryAPI, + Endpoint: "https://seulgivibe.example.invalid/anthropic/v1", + Capacity: 4, + MaxQueue: 10, + QueueTimeoutMS: 2000, + RequestTimeoutMS: 60000, + Models: []string{"claude-sonnet-4-5"}, + }, + { + ID: "seulgivibe-openai", + Type: "seulgivibe_openai", + Category: config.CategoryAPI, + Endpoint: "https://seulgivibe.example.invalid/openai/v1", + Capacity: 4, + MaxQueue: 10, + QueueTimeoutMS: 2000, + RequestTimeoutMS: 60000, + Models: []string{"gpt-5.1"}, + }, + }, + } + + payload, err := edgenode.BuildConfigPayload(rec) + if err != nil { + t.Fatalf("BuildConfigPayload failed: %v", err) + } + byName := map[string]*iop.OpenAICompatAdapterConfig{} + for _, a := range payload.Adapters { + if a.Type == "openai_compat" { + byName[a.Name] = a.GetOpenaiCompat() + } + } + if len(byName) != 2 { + t.Fatalf("expected 2 openai_compat adapters, got %d", len(byName)) + } + claude := byName["seulgivibe-claude"] + if claude == nil { + t.Fatal("expected seulgivibe-claude adapter") + } + if claude.GetProvider() != "seulgivibe_claude" { + t.Fatalf("claude provider label: got %q, want %q", claude.GetProvider(), "seulgivibe_claude") + } + if claude.GetEndpoint() != "https://seulgivibe.example.invalid/anthropic/v1" { + t.Fatalf("claude endpoint: got %q", claude.GetEndpoint()) + } + openai := byName["seulgivibe-openai"] + if openai == nil { + t.Fatal("expected seulgivibe-openai adapter") + } + if openai.GetProvider() != "seulgivibe_openai" { + t.Fatalf("openai provider label: got %q, want %q", openai.GetProvider(), "seulgivibe_openai") + } + if openai.GetEndpoint() != "https://seulgivibe.example.invalid/openai/v1" { + t.Fatalf("openai endpoint: got %q", openai.GetEndpoint()) + } +} + func TestBuildConfigPayload_ProviderFirstCLIProfile(t *testing.T) { rec := &edgenode.NodeRecord{ ID: "node-1", diff --git a/apps/edge/internal/openai/server_test.go b/apps/edge/internal/openai/server_test.go index e5f8af2..8621ace 100644 --- a/apps/edge/internal/openai/server_test.go +++ b/apps/edge/internal/openai/server_test.go @@ -4521,6 +4521,137 @@ func TestChatCompletionsPassthroughDoesNotExposeSideband(t *testing.T) { } } +// chatProviderAuthServer builds a provider-pool tunnel server with the given +// provider auth forwarding config, mirroring chatSidebandServer's catalog so +// pool-model routes over the provider tunnel path. +func chatProviderAuthServer(frames chan *iop.ProviderTunnelFrame, auth config.EdgeOpenAIProviderAuthConf) (*Server, *fakeRunService) { + fake := &fakeRunService{tunnelFrames: frames} + srv := NewServer(config.EdgeOpenAIConf{ProviderAuth: auth}, fake, nil) + srv.SetModelCatalog([]config.ModelCatalogEntry{ + {ID: "pool-model", Providers: map[string]string{"prov-1": "served-model"}}, + }) + return srv, fake +} + +func staticOKTunnelFrames(body string) chan *iop.ProviderTunnelFrame { + frames := make(chan *iop.ProviderTunnelFrame, 3) + frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_RESPONSE_START, StatusCode: 200} + frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_BODY, Body: []byte(body)} + frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END, End: true} + close(frames) + return frames +} + +// TestChatProviderTunnelForwardsProviderAuthHeader verifies SDD S03: a raw user +// token supplied in the configured provider auth request header is forwarded to +// the provider tunnel request as the configured target header, formatted with +// the configured scheme, while already-prefixed values are preserved. +func TestChatProviderTunnelForwardsProviderAuthHeader(t *testing.T) { + auth := config.EdgeOpenAIProviderAuthConf{ + Enabled: true, + FromHeader: "X-IOP-Provider-Authorization", + TargetHeader: "Authorization", + Scheme: "Bearer", + Required: true, + } + for _, tc := range []struct { + name string + callerAuth string + wantHeader string + }{ + {name: "raw token gets scheme", callerAuth: "user-token", wantHeader: "Bearer user-token"}, + {name: "already prefixed preserved", callerAuth: "Bearer user-token", wantHeader: "Bearer user-token"}, + } { + t.Run(tc.name, func(t *testing.T) { + srv, fake := chatProviderAuthServer(staticOKTunnelFrames(`{"ok":true}`), auth) + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ + "model":"pool-model", + "messages":[{"role":"user","content":"hello"}] + }`)) + req.Header.Set("X-IOP-Provider-Authorization", tc.callerAuth) + w := httptest.NewRecorder() + srv.handleChatCompletions(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) + } + reqs := fake.tunnelReqsSnapshot() + if len(reqs) != 1 { + t.Fatalf("expected 1 tunnel dispatch, got %d", len(reqs)) + } + if got := reqs[0].Headers["Authorization"]; got != tc.wantHeader { + t.Fatalf("provider Authorization header: got %q, want %q", got, tc.wantHeader) + } + }) + } +} + +// TestChatProviderTunnelProviderAuthRequired verifies SDD S03: a missing +// required provider auth header is rejected before dispatch, so no tunnel +// request is submitted. +func TestChatProviderTunnelProviderAuthRequired(t *testing.T) { + auth := config.EdgeOpenAIProviderAuthConf{ + Enabled: true, + FromHeader: "X-IOP-Provider-Authorization", + TargetHeader: "Authorization", + Scheme: "Bearer", + Required: true, + } + srv, fake := chatProviderAuthServer(staticOKTunnelFrames(`{"ok":true}`), auth) + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ + "model":"pool-model", + "messages":[{"role":"user","content":"hello"}] + }`)) + w := httptest.NewRecorder() + srv.handleChatCompletions(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) + } + if strings.Contains(w.Body.String(), "user-token") { + t.Fatalf("error body must not echo a raw token: %s", w.Body.String()) + } + if got := len(fake.tunnelReqsSnapshot()); got != 0 { + t.Fatalf("missing required provider auth must not dispatch: got %d tunnel requests", got) + } +} + +// TestChatProviderTunnelProviderAuthDoesNotReplaceInboundAuth verifies the +// provider token is sourced only from the configured provider auth header, not +// from the inbound IOP Authorization header, keeping IOP inbound auth and +// outbound provider auth separate. +func TestChatProviderTunnelProviderAuthDoesNotReplaceInboundAuth(t *testing.T) { + auth := config.EdgeOpenAIProviderAuthConf{ + Enabled: true, + FromHeader: "X-IOP-Provider-Authorization", + TargetHeader: "Authorization", + Scheme: "Bearer", + Required: true, + } + srv, fake := chatProviderAuthServer(staticOKTunnelFrames(`{"ok":true}`), auth) + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ + "model":"pool-model", + "messages":[{"role":"user","content":"hello"}] + }`)) + // Inbound IOP auth and the configured provider auth header carry different + // tokens; the provider header must come from the provider auth header only. + req.Header.Set("Authorization", "Bearer iop-inbound-token") + req.Header.Set("X-IOP-Provider-Authorization", "provider-token") + w := httptest.NewRecorder() + srv.handleChatCompletions(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) + } + reqs := fake.tunnelReqsSnapshot() + if len(reqs) != 1 { + t.Fatalf("expected 1 tunnel dispatch, got %d", len(reqs)) + } + if got := reqs[0].Headers["Authorization"]; got != "Bearer provider-token" { + t.Fatalf("provider Authorization must derive from provider auth header, got %q", got) + } +} + // TestChatCompletionsTransformedModeLabelsIOPOutput verifies SDD S07: explicit // transformed output uses the normalized path and is labeled as IOP // transformed output in both the response header and the run metadata. diff --git a/apps/edge/internal/openai/stream.go b/apps/edge/internal/openai/stream.go index 6ead533..4a57cb5 100644 --- a/apps/edge/internal/openai/stream.go +++ b/apps/edge/internal/openai/stream.go @@ -3,6 +3,7 @@ package openai import ( "bytes" "encoding/json" + "errors" "fmt" "net/http" "os" @@ -353,10 +354,49 @@ func (s *Server) tunnelChatCompletionPassthroughSideband(w http.ResponseWriter, s.writeProviderTunnelSidebandResponse(w, r, handle) } +// errProviderAuthRequired signals that provider auth forwarding is configured +// as required but the caller did not supply the configured provider token +// header. The raw token is never part of this error. +var errProviderAuthRequired = errors.New("provider auth token is required") + +// providerTunnelAuthHeaders builds the provider tunnel request headers that +// forward a request-time raw user token to the provider (SDD S03). It reads the +// token only from the configured `openai.provider_auth.from_header` and never +// reuses the inbound IOP `Authorization` header. Returns nil when provider auth +// is disabled or the (non-required) token header is absent, and +// errProviderAuthRequired when a required token header is missing. +func (s *Server) providerTunnelAuthHeaders(r *http.Request) (map[string]string, error) { + auth := s.cfg.ProviderAuth + if !auth.Enabled { + return nil, nil + } + raw := strings.TrimSpace(r.Header.Get(auth.FromHeader)) + if raw == "" { + if auth.Required { + return nil, errProviderAuthRequired + } + return nil, nil + } + value := raw + if scheme := strings.TrimSpace(auth.Scheme); scheme != "" && + !strings.HasPrefix(strings.ToLower(raw), strings.ToLower(scheme)+" ") { + value = scheme + " " + raw + } + return map[string]string{auth.TargetHeader: value}, nil +} + // submitChatCompletionTunnel dispatches a Chat Completions provider tunnel // request with the given response mode. On failure it writes the dispatch // error to the caller and returns ok=false. func (s *Server) submitChatCompletionTunnel(w http.ResponseWriter, r *http.Request, req chatCompletionRequest, dispatch routeDispatch, runMeta map[string]string, rawBody []byte, estimate int, contextClass, responseMode string) (edgeservice.ProviderTunnelResult, bool) { + providerAuthHeaders, err := s.providerTunnelAuthHeaders(r) + if err != nil { + // Missing required provider auth is rejected before dispatch; the raw + // token is never echoed into the error surface. + writeError(w, http.StatusBadRequest, "invalid_request_error", "provider auth token is required") + return nil, false + } + metadata := make(map[string]string, len(runMeta)+5) for k, v := range runMeta { metadata[k] = v @@ -375,6 +415,7 @@ func (s *Server) submitChatCompletionTunnel(w http.ResponseWriter, r *http.Reque SessionID: dispatch.SessionID, Method: http.MethodPost, Path: "/v1/chat/completions", + Headers: providerAuthHeaders, BuildBody: func(target string) ([]byte, error) { return rewriteChatCompletionModel(rawBody, target, req) }, diff --git a/configs/edge.yaml b/configs/edge.yaml index ab69809..bcbe714 100644 --- a/configs/edge.yaml +++ b/configs/edge.yaml @@ -72,6 +72,16 @@ openai: # principal_ref: "user:alice" # principal_alias: "alice" principal_tokens: [] + # provider_auth forwards a caller-supplied raw provider token to the selected + # OpenAI-compatible provider tunnel. The raw token is read only from the + # request header and must not be stored in config, docs, or logs. + provider_auth: + enabled: false + # Defaults when enabled: + # from_header: "X-IOP-Provider-Authorization" + # target_header: "Authorization" + # scheme: "Bearer" + # required: true # Chat Completions provider routes default to metadata.iop_response_mode=passthrough. # Callers can opt into passthrough+sideband or transformed per request metadata; # this config has no global response-mode switch. @@ -108,7 +118,8 @@ openai: # # nodes[].providers[] defines each provider candidate with catalog and execution fields (Provider-First). # nodes[].providers[].id is the stable provider identity referenced by models[].providers keys. -# nodes[].providers[].type — runtime type (vllm, ollama, lemonade, sglang, openai_api, cli). +# nodes[].providers[].type — runtime type (vllm, ollama, lemonade, sglang, openai_api, +# seulgivibe_claude, seulgivibe_openai, cli). # nodes[].providers[].category — "api", "cli", or "local_inference". # nodes[].providers[].endpoint / base_url / command — type-specific execution fields. # nodes[].providers[].models — served model names this provider can serve. @@ -151,6 +162,23 @@ models: # providers: # codex: "app-server" # codex-exec: "exec" + # Example: Seulgivibe static catalog. Endpoint and raw token values belong to + # environment-specific private config/request headers, not tracked files. + # - id: "claude-sonnet-4-5" + # providers: + # seulgivibe-claude: "claude-sonnet-4-5" + # - id: "claude-opus-4-8" + # providers: + # seulgivibe-claude: "claude-opus-4-8" + # - id: "claude-fable-5" + # providers: + # seulgivibe-claude: "claude-fable-5" + # - id: "gpt-5.1" + # providers: + # seulgivibe-openai: "gpt-5.1" + # - id: "gpt-5.5" + # providers: + # seulgivibe-openai: "gpt-5.5" nodes: # id is the stable node identity; omitting it falls back to an auto UUID (dev only). @@ -211,6 +239,25 @@ nodes: # is the long-slot count, not the normal capacity (2); do not conflate them. total_context_tokens: 262144 long_context_capacity: 1 + # Seulgivibe OpenAI-compatible provider examples. Keep endpoint values + # illustrative and provide user tokens per request via openai.provider_auth. + # - id: "seulgivibe-claude" + # type: "seulgivibe_claude" + # category: "api" + # endpoint: "https://seulgivibe.example.invalid/anthropic/v1" + # models: + # - "claude-sonnet-4-5" + # - "claude-opus-4-8" + # - "claude-fable-5" + # capacity: 4 + # - id: "seulgivibe-openai" + # type: "seulgivibe_openai" + # category: "api" + # endpoint: "https://seulgivibe.example.invalid/openai/v1" + # models: + # - "gpt-5.1" + # - "gpt-5.5" + # capacity: 4 # Legacy adapters configuration (compat override example): # adapters: # cli: diff --git a/packages/go/config/config.go b/packages/go/config/config.go index e0df0cf..e23c6db 100644 --- a/packages/go/config/config.go +++ b/packages/go/config/config.go @@ -27,7 +27,7 @@ func NormalizeAgentKind(kind string) (string, error) { // NormalizeProviderType returns the canonical driver name for provider type. func NormalizeProviderType(t string) string { switch strings.ToLower(strings.TrimSpace(t)) { - case "openai_compat", "openai_api", "vllm", "lemonade", "sglang": + case "openai_compat", "openai_api", "vllm", "lemonade", "sglang", "seulgivibe_claude", "seulgivibe_openai": return "openai_compat" case "ollama": return "ollama" @@ -379,6 +379,7 @@ type EdgeOpenAIConf struct { Listen string `mapstructure:"listen" yaml:"listen"` BearerToken string `mapstructure:"bearer_token" yaml:"bearer_token"` PrincipalTokens []OpenAIPrincipalTokenConf `mapstructure:"principal_tokens" yaml:"principal_tokens,omitempty"` + ProviderAuth EdgeOpenAIProviderAuthConf `mapstructure:"provider_auth" yaml:"provider_auth,omitempty"` NodeRef string `mapstructure:"node" yaml:"node"` Adapter string `mapstructure:"adapter" yaml:"adapter"` Target string `mapstructure:"target" yaml:"target"` @@ -390,6 +391,17 @@ type EdgeOpenAIConf struct { StrictStreamBuffer bool `mapstructure:"strict_stream_buffer" yaml:"strict_stream_buffer"` } +// EdgeOpenAIProviderAuthConf configures request-time provider token forwarding. +// It only describes which caller header to forward and never stores raw token +// values in config. +type EdgeOpenAIProviderAuthConf struct { + Enabled bool `mapstructure:"enabled" yaml:"enabled,omitempty"` + FromHeader string `mapstructure:"from_header" yaml:"from_header,omitempty"` + TargetHeader string `mapstructure:"target_header" yaml:"target_header,omitempty"` + Scheme string `mapstructure:"scheme" yaml:"scheme,omitempty"` + Required bool `mapstructure:"required" yaml:"required,omitempty"` +} + // OpenAIPrincipalTokenConf maps a hashed OpenAI-compatible bearer token to the // IOP bearer-token-operation identity it authenticates as. Raw tokens are // never stored in config; only the SHA-256 hash is persisted. @@ -624,6 +636,9 @@ func LoadEdge(cfgFile string) (*EdgeConfig, error) { if err := validateOpenAIPrincipalTokens(cfg.OpenAI.PrincipalTokens); err != nil { return nil, err } + if err := normalizeOpenAIProviderAuth(v, &cfg.OpenAI.ProviderAuth); err != nil { + return nil, err + } if cfg.LongContextThresholdTokens <= 0 { return nil, fmt.Errorf("long_context_threshold_tokens must be positive") } @@ -952,6 +967,36 @@ func validateOpenAIPrincipalTokens(tokens []OpenAIPrincipalTokenConf) error { return nil } +func normalizeOpenAIProviderAuth(v *viper.Viper, auth *EdgeOpenAIProviderAuthConf) error { + if !auth.Enabled { + return nil + } + if v.InConfig("openai.provider_auth.from_header") { + auth.FromHeader = strings.TrimSpace(auth.FromHeader) + if auth.FromHeader == "" { + return fmt.Errorf("openai.provider_auth.from_header must not be empty when provider_auth is enabled") + } + } else { + auth.FromHeader = "X-IOP-Provider-Authorization" + } + if v.InConfig("openai.provider_auth.target_header") { + auth.TargetHeader = strings.TrimSpace(auth.TargetHeader) + if auth.TargetHeader == "" { + return fmt.Errorf("openai.provider_auth.target_header must not be empty when provider_auth is enabled") + } + } else { + auth.TargetHeader = "Authorization" + } + auth.Scheme = strings.TrimSpace(auth.Scheme) + if auth.Scheme == "" { + auth.Scheme = "Bearer" + } + if !v.InConfig("openai.provider_auth.required") { + auth.Required = true + } + return nil +} + func checkUniqueNames(field string, name func(int) string, n int) error { seen := make(map[string]struct{}, n) for i := 0; i < n; i++ { diff --git a/packages/go/config/config_test.go b/packages/go/config/config_test.go index 040e4bb..6a5a37f 100644 --- a/packages/go/config/config_test.go +++ b/packages/go/config/config_test.go @@ -124,6 +124,12 @@ func TestLoadEdge_OpenAIDefaults(t *testing.T) { if cfg.OpenAI.BearerToken != "" { t.Fatalf("expected openai.bearer_token empty by default, got %q", cfg.OpenAI.BearerToken) } + if cfg.OpenAI.ProviderAuth.Enabled { + t.Fatal("expected openai.provider_auth.enabled=false by default") + } + if cfg.OpenAI.ProviderAuth.FromHeader != "" || cfg.OpenAI.ProviderAuth.TargetHeader != "" || cfg.OpenAI.ProviderAuth.Scheme != "" || cfg.OpenAI.ProviderAuth.Required { + t.Fatalf("expected openai.provider_auth fields empty/false by default, got %+v", cfg.OpenAI.ProviderAuth) + } if cfg.OpenAI.Adapter != "ollama" { t.Fatalf("expected openai.adapter=%q, got %q", "ollama", cfg.OpenAI.Adapter) } @@ -248,6 +254,107 @@ openai: } } +func TestLoadEdge_OpenAIProviderAuthEnabledDefaults(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "edge.yaml") + yaml := ` +server: + listen: "0.0.0.0:9090" +openai: + provider_auth: + enabled: true +` + if err := os.WriteFile(f, []byte(yaml), 0o600); err != nil { + t.Fatalf("write yaml: %v", err) + } + + cfg, err := config.LoadEdge(f) + if err != nil { + t.Fatalf("load: %v", err) + } + auth := cfg.OpenAI.ProviderAuth + if !auth.Enabled { + t.Fatal("expected provider_auth.enabled=true") + } + if auth.FromHeader != "X-IOP-Provider-Authorization" { + t.Fatalf("expected default from_header, got %q", auth.FromHeader) + } + if auth.TargetHeader != "Authorization" { + t.Fatalf("expected default target_header, got %q", auth.TargetHeader) + } + if auth.Scheme != "Bearer" { + t.Fatalf("expected default scheme Bearer, got %q", auth.Scheme) + } + if !auth.Required { + t.Fatal("expected required=true by default when provider_auth is enabled") + } +} + +func TestLoadEdge_OpenAIProviderAuthOverride(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "edge.yaml") + yaml := ` +server: + listen: "0.0.0.0:9090" +openai: + provider_auth: + enabled: true + from_header: "X-Seulgivibe-Token" + target_header: "X-Provider-Authorization" + scheme: "Token" + required: false +` + if err := os.WriteFile(f, []byte(yaml), 0o600); err != nil { + t.Fatalf("write yaml: %v", err) + } + + cfg, err := config.LoadEdge(f) + if err != nil { + t.Fatalf("load: %v", err) + } + auth := cfg.OpenAI.ProviderAuth + if !auth.Enabled { + t.Fatal("expected provider_auth.enabled=true") + } + if auth.FromHeader != "X-Seulgivibe-Token" || auth.TargetHeader != "X-Provider-Authorization" || auth.Scheme != "Token" || auth.Required { + t.Fatalf("unexpected provider_auth override: %+v", auth) + } +} + +func TestLoadEdge_OpenAIProviderAuthRejectsBlankHeaders(t *testing.T) { + for _, tc := range []struct { + name string + field string + wantMsg string + }{ + {name: "from_header", field: "from_header", wantMsg: "from_header"}, + {name: "target_header", field: "target_header", wantMsg: "target_header"}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "edge.yaml") + yaml := fmt.Sprintf(` +server: + listen: "0.0.0.0:9090" +openai: + provider_auth: + enabled: true + %s: " " +`, tc.field) + if err := os.WriteFile(f, []byte(yaml), 0o600); err != nil { + t.Fatalf("write yaml: %v", err) + } + _, err := config.LoadEdge(f) + if err == nil { + t.Fatal("expected blank provider_auth header error") + } + if !strings.Contains(err.Error(), tc.wantMsg) { + t.Fatalf("expected error mentioning %q, got %v", tc.wantMsg, err) + } + }) + } +} + func TestLoadEdge_OpenAIPrincipalTokens(t *testing.T) { dir := t.TempDir() f := filepath.Join(dir, "edge.yaml") @@ -424,6 +531,24 @@ func TestOpenAIPrincipalTokenConf_NoRawTokenField(t *testing.T) { } } +func TestEdgeOpenAIProviderAuthConf_NoRawTokenField(t *testing.T) { + allowed := map[string]struct{}{ + "enabled": {}, + "from_header": {}, + "target_header": {}, + "scheme": {}, + "required": {}, + } + typ := reflect.TypeOf(config.EdgeOpenAIProviderAuthConf{}) + for i := 0; i < typ.NumField(); i++ { + tag := typ.Field(i).Tag.Get("yaml") + key := strings.SplitN(tag, ",", 2)[0] + if _, ok := allowed[key]; !ok { + t.Fatalf("unexpected field %q (yaml tag %q) on EdgeOpenAIProviderAuthConf; raw provider tokens must not be stored in config", typ.Field(i).Name, tag) + } + } +} + func TestLoadEdge_OllamaContextSize(t *testing.T) { dir := t.TempDir() f := filepath.Join(dir, "edge.yaml") @@ -2112,6 +2237,82 @@ nodes: } } +func TestLoadEdge_SeulgivibeProviderFirstAliases(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "edge.yaml") + yaml := ` +server: + listen: "0.0.0.0:9090" +openai: + provider_auth: + enabled: true +models: + - id: "claude-sonnet-4-5" + providers: + seulgivibe-claude: "claude-sonnet-4-5" + - id: "claude-opus-4-8" + providers: + seulgivibe-claude: "claude-opus-4-8" + - id: "claude-fable-5" + providers: + seulgivibe-claude: "claude-fable-5" + - id: "gpt-5.1" + providers: + seulgivibe-openai: "gpt-5.1" + - id: "gpt-5.5" + providers: + seulgivibe-openai: "gpt-5.5" +nodes: + - id: "node-seulgivibe" + alias: "seulgivibe-node" + providers: + - id: "seulgivibe-claude" + type: "seulgivibe_claude" + category: "api" + endpoint: "https://seulgivibe.example.invalid/anthropic/v1" + models: + - "claude-sonnet-4-5" + - "claude-opus-4-8" + - "claude-fable-5" + capacity: 4 + - id: "seulgivibe-openai" + type: "seulgivibe_openai" + category: "api" + endpoint: "https://seulgivibe.example.invalid/openai/v1" + models: + - "gpt-5.1" + - "gpt-5.5" + capacity: 4 +` + if err := os.WriteFile(f, []byte(yaml), 0o600); err != nil { + t.Fatalf("write yaml: %v", err) + } + cfg, err := config.LoadEdge(f) + if err != nil { + t.Fatalf("load: %v", err) + } + if len(cfg.Models) != 5 { + t.Fatalf("expected 5 Seulgivibe model catalog entries, got %d", len(cfg.Models)) + } + if got := config.NormalizeProviderType(cfg.Nodes[0].Providers[0].Type); got != "openai_compat" { + t.Fatalf("seulgivibe_claude should normalize to openai_compat, got %q", got) + } + if got := config.NormalizeProviderType(cfg.Nodes[0].Providers[1].Type); got != "openai_compat" { + t.Fatalf("seulgivibe_openai should normalize to openai_compat, got %q", got) + } + auth := cfg.OpenAI.ProviderAuth + if !auth.Enabled || auth.FromHeader != "X-IOP-Provider-Authorization" || auth.TargetHeader != "Authorization" || auth.Scheme != "Bearer" || !auth.Required { + t.Fatalf("unexpected provider_auth defaults: %+v", auth) + } + for _, m := range cfg.Models { + for providerID, servedModel := range m.Providers { + if strings.TrimSpace(servedModel) == "" { + t.Fatalf("model %q provider %q has empty served model", m.ID, providerID) + } + } + } +} + func TestLoadEdge_ModelCatalogEmptyModelIDRejected(t *testing.T) { // No models[] on node, so serve model check is skipped (legacy compat). dir := t.TempDir()