feat: update edge config runtime refresh, openai compatible provider, and mapper
This commit is contained in:
parent
c836f1e562
commit
b38f978652
17 changed files with 1118 additions and 42 deletions
|
|
@ -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.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` deep diff는 restart-required로 분류한다. `openai.principal_tokens[]` 변경은 credential/hash 변경으로 restart-required classifier에 포함된다.
|
||||||
- `openai.model_routes[]`는 외부 OpenAI-compatible `model` id를 내부 `adapter + target` route로 매핑하는 compatibility catalog다.
|
- `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에서 거부한다.
|
- `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다.
|
- `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[]`는 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[].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[].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)은 수행된다.
|
- `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과 불일치하게 두지 않는다.
|
- 설정 파일만 바꾸고 `packages/go/config` loading/default/validation과 불일치하게 두지 않는다.
|
||||||
- provider id, model id, route id의 의미를 섞지 않는다. 외부 `model`은 OpenAI-compatible 경계의 route key이고, 내부 실행은 `adapter + target`이다.
|
- provider id, model id, route id의 의미를 섞지 않는다. 외부 `model`은 OpenAI-compatible 경계의 route key이고, 내부 실행은 `adapter + target`이다.
|
||||||
- credential, bearer token, private endpoint 원문을 tracked docs/roadmap/contract에 기록하지 않는다.
|
- 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 값을 명령줄로 넣어야 하는 계약을 만들지 않는다.
|
- Node bootstrap 기본 경로에서 사용자가 직접 node config를 작성하거나 adapter/provider 값을 명령줄로 넣어야 하는 계약을 만들지 않는다.
|
||||||
|
|
||||||
## 변경 시 확인할 코드/테스트
|
## 변경 시 확인할 코드/테스트
|
||||||
|
|
|
||||||
|
|
@ -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`를 반환한다.
|
`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 <token>`과 분리된다. `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
|
## Responses API
|
||||||
|
|
||||||
Endpoint:
|
Endpoint:
|
||||||
|
|
@ -297,3 +310,5 @@ Edge 설정이 `openai.model_routes[]`를 제공하면 `model`은 먼저 route c
|
||||||
매칭 route가 없으면 기존 fallback 규칙에 따라 `openai.target` 또는 요청의 `model`을 내부 target으로 사용한다.
|
매칭 route가 없으면 기존 fallback 규칙에 따라 `openai.target` 또는 요청의 `model`을 내부 target으로 사용한다.
|
||||||
|
|
||||||
CLI agent를 OpenAI-compatible API로 노출할 때는 route catalog에서 해당 `model`을 명시적으로 `adapter: "cli"`와 target profile로 매핑하는 방식을 우선한다.
|
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은 담지 않는다.
|
||||||
|
|
|
||||||
|
|
@ -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-1] Provider type alias | [x] |
|
||||||
| [SEULGI_CONFIG-2] Provider auth config schema | [ ] |
|
| [SEULGI_CONFIG-2] Provider auth config schema | [x] |
|
||||||
| [SEULGI_CONFIG-3] Static catalog and contract notes | [ ] |
|
| [SEULGI_CONFIG-3] Static catalog and contract notes | [x] |
|
||||||
|
|
||||||
## 구현 체크리스트
|
## 구현 체크리스트
|
||||||
|
|
||||||
- [ ] [SEULGI_CONFIG-1] `seulgivibe_claude`, `seulgivibe_openai` provider type을 `openai_compat` runtime으로 정규화하고 mapper provider label에 보존한다.
|
- [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를 참조하지 않는다.
|
- [x] [SEULGI_CONFIG-2] `openai.provider_auth` config schema/default/validation을 추가하되 실제 token 값이나 local helper path를 참조하지 않는다.
|
||||||
- [ ] [SEULGI_CONFIG-3] Seulgivibe 정적 model catalog/계약 예시를 추가하고 실제 endpoint secret/token은 문서에 쓰지 않는다.
|
- [x] [SEULGI_CONFIG-3] Seulgivibe 정적 model catalog/계약 예시를 추가하고 실제 endpoint secret/token은 문서에 쓰지 않는다.
|
||||||
- [ ] `go test ./packages/go/config -count=1`와 `go test ./apps/edge/internal/node -count=1`를 실행한다.
|
- [x] `go test ./packages/go/config -count=1`와 `go test ./apps/edge/internal/node -count=1`를 실행한다.
|
||||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||||
|
|
||||||
## 코드리뷰 전용 체크리스트
|
## 코드리뷰 전용 체크리스트
|
||||||
|
|
||||||
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
|
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
|
||||||
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
|
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
|
||||||
|
|
||||||
- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
|
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
|
||||||
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
|
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
|
||||||
- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G06_N.log`로 아카이브한다.
|
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G06_N.log`로 아카이브한다.
|
||||||
- [ ] active `PLAN-*-G??.md`를 `plan_cloud_G06_M.log`로 아카이브한다.
|
- [x] 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하는지 확인한다.
|
- [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` 파일을 남기지 않는다.
|
- [x] 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 경로에서 이 체크리스트를 갱신한다.
|
- [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 경로에서 이 체크리스트를 갱신한다.
|
||||||
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
|
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
|
||||||
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-seulgivibe-openai-compatible-provider/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
|
- [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`를 작성하지 않는다.
|
- [ ] 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이면 `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로 이동한다.
|
- [ ] 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 중간 검증
|
### SEULGI_CONFIG-1 중간 검증
|
||||||
```
|
```
|
||||||
$ go test ./packages/go/config -run 'TestLoadEdge_SeulgivibeProviderFirstAliases' -count=1
|
$ 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
|
||||||
(output)
|
ok iop/packages/go/config 0.032s
|
||||||
$ go test ./apps/edge/internal/node -run 'TestBuildConfigPayload_ProviderFirstSeulgivibeDefaultsProviderFromType' -count=1
|
$ 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
|
||||||
(output)
|
ok iop/apps/edge/internal/node 0.027s
|
||||||
```
|
```
|
||||||
|
|
||||||
### SEULGI_CONFIG-2 중간 검증
|
### SEULGI_CONFIG-2 중간 검증
|
||||||
```
|
```
|
||||||
$ go test ./packages/go/config -run 'TestLoadEdge_OpenAIProviderAuth|TestLoadEdge_OpenAIDefaults|TestLoadEdge_OpenAIOverride' -count=1
|
$ 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
|
||||||
(output)
|
ok iop/packages/go/config 0.054s
|
||||||
```
|
```
|
||||||
|
|
||||||
### SEULGI_CONFIG-3 중간 검증
|
### SEULGI_CONFIG-3 중간 검증
|
||||||
```
|
```
|
||||||
$ rg --sort path -n 'e[y]J|secret -|OPENAI_API_KEY=.*e[y]J' agent-contract configs
|
$ rg --sort path -n 'e[y]J|secret -|OPENAI_API_KEY=.*e[y]J' agent-contract configs
|
||||||
(output)
|
|
||||||
$ test $? -eq 1
|
$ test $? -eq 1
|
||||||
(output)
|
(no output; `rg` produced no matches and exited 1, `test` passed)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 최종 검증
|
### 최종 검증
|
||||||
```
|
```
|
||||||
$ go test ./packages/go/config -count=1
|
$ 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
|
||||||
(output)
|
ok iop/packages/go/config 0.417s
|
||||||
$ go test ./apps/edge/internal/node -count=1
|
$ 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
|
||||||
(output)
|
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
|
$ rg --sort path -n 'e[y]J|secret -|OPENAI_API_KEY=.*e[y]J' agent-contract configs
|
||||||
(output)
|
|
||||||
$ test $? -eq 1
|
$ 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?**
|
> **[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.
|
> If anything is blank, go back and fill it in before saving this file.
|
||||||
> Leave review-agent-only sections unchanged.
|
> 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/`로 이동한다.
|
||||||
|
|
@ -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로 진행한다.
|
||||||
|
|
@ -58,16 +58,16 @@ task=m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth, plan=0,
|
||||||
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
|
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
|
||||||
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
|
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
|
||||||
|
|
||||||
- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
|
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
|
||||||
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
|
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
|
||||||
- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G07_N.log`로 아카이브한다.
|
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G07_N.log`로 아카이브한다.
|
||||||
- [ ] active `PLAN-*-G??.md`를 `plan_cloud_G07_M.log`로 아카이브한다.
|
- [x] 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] `.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이면 `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이면 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-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
|
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
|
||||||
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-seulgivibe-openai-compatible-provider/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
|
- [ ] 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이면 `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로 이동한다.
|
- [ ] 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?**
|
> **[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.
|
> If anything is blank, go back and fill it in before saving this file.
|
||||||
> Leave review-agent-only sections unchanged.
|
> 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`는 트리거하지 않는다.
|
||||||
|
|
@ -0,0 +1,219 @@
|
||||||
|
<!-- task=m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth plan=1 tag=REVIEW_SEULGI_TUNNEL -->
|
||||||
|
|
||||||
|
# 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-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. 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-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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 <token>`으로 온 값은 이중 접두(`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/`로 이동한다.
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
- 없음
|
||||||
|
|
||||||
|
## 후속 작업
|
||||||
|
|
||||||
|
- 없음
|
||||||
|
|
@ -0,0 +1,171 @@
|
||||||
|
<!-- task=m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth plan=1 tag=REVIEW_SEULGI_TUNNEL -->
|
||||||
|
|
||||||
|
# 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 <token>`을 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으로 시작하지 않으면 `<scheme> <raw>` 형식으로 만든다.
|
||||||
|
- 반환 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`에 기록되어 있다.
|
||||||
|
|
@ -241,7 +241,7 @@ func openAICompatProviderLabel(p config.NodeProviderConf) string {
|
||||||
return provider
|
return provider
|
||||||
}
|
}
|
||||||
switch strings.TrimSpace(p.Type) {
|
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)
|
return strings.TrimSpace(p.Type)
|
||||||
default:
|
default:
|
||||||
return ""
|
return ""
|
||||||
|
|
|
||||||
|
|
@ -594,6 +594,8 @@ func TestBuildConfigPayload_ProviderFirstOpenAICompatDefaultsProviderFromType(t
|
||||||
{name: "lemonade", typ: "lemonade", want: "lemonade"},
|
{name: "lemonade", typ: "lemonade", want: "lemonade"},
|
||||||
{name: "sglang", typ: "sglang", want: "sglang"},
|
{name: "sglang", typ: "sglang", want: "sglang"},
|
||||||
{name: "openai api", typ: "openai_api", want: "openai_api"},
|
{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"},
|
{name: "explicit override", typ: "openai_compat", provider: "vllm-mlx", want: "vllm-mlx"},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
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) {
|
func TestBuildConfigPayload_ProviderFirstCLIProfile(t *testing.T) {
|
||||||
rec := &edgenode.NodeRecord{
|
rec := &edgenode.NodeRecord{
|
||||||
ID: "node-1",
|
ID: "node-1",
|
||||||
|
|
|
||||||
|
|
@ -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
|
// TestChatCompletionsTransformedModeLabelsIOPOutput verifies SDD S07: explicit
|
||||||
// transformed output uses the normalized path and is labeled as IOP
|
// transformed output uses the normalized path and is labeled as IOP
|
||||||
// transformed output in both the response header and the run metadata.
|
// transformed output in both the response header and the run metadata.
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package openai
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
|
@ -353,10 +354,49 @@ func (s *Server) tunnelChatCompletionPassthroughSideband(w http.ResponseWriter,
|
||||||
s.writeProviderTunnelSidebandResponse(w, r, handle)
|
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
|
// submitChatCompletionTunnel dispatches a Chat Completions provider tunnel
|
||||||
// request with the given response mode. On failure it writes the dispatch
|
// request with the given response mode. On failure it writes the dispatch
|
||||||
// error to the caller and returns ok=false.
|
// 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) {
|
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)
|
metadata := make(map[string]string, len(runMeta)+5)
|
||||||
for k, v := range runMeta {
|
for k, v := range runMeta {
|
||||||
metadata[k] = v
|
metadata[k] = v
|
||||||
|
|
@ -375,6 +415,7 @@ func (s *Server) submitChatCompletionTunnel(w http.ResponseWriter, r *http.Reque
|
||||||
SessionID: dispatch.SessionID,
|
SessionID: dispatch.SessionID,
|
||||||
Method: http.MethodPost,
|
Method: http.MethodPost,
|
||||||
Path: "/v1/chat/completions",
|
Path: "/v1/chat/completions",
|
||||||
|
Headers: providerAuthHeaders,
|
||||||
BuildBody: func(target string) ([]byte, error) {
|
BuildBody: func(target string) ([]byte, error) {
|
||||||
return rewriteChatCompletionModel(rawBody, target, req)
|
return rewriteChatCompletionModel(rawBody, target, req)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,16 @@ openai:
|
||||||
# principal_ref: "user:alice"
|
# principal_ref: "user:alice"
|
||||||
# principal_alias: "alice"
|
# principal_alias: "alice"
|
||||||
principal_tokens: []
|
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.
|
# Chat Completions provider routes default to metadata.iop_response_mode=passthrough.
|
||||||
# Callers can opt into passthrough+sideband or transformed per request metadata;
|
# Callers can opt into passthrough+sideband or transformed per request metadata;
|
||||||
# this config has no global response-mode switch.
|
# 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[] 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[].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[].category — "api", "cli", or "local_inference".
|
||||||
# nodes[].providers[].endpoint / base_url / command — type-specific execution fields.
|
# nodes[].providers[].endpoint / base_url / command — type-specific execution fields.
|
||||||
# nodes[].providers[].models — served model names this provider can serve.
|
# nodes[].providers[].models — served model names this provider can serve.
|
||||||
|
|
@ -151,6 +162,23 @@ models:
|
||||||
# providers:
|
# providers:
|
||||||
# codex: "app-server"
|
# codex: "app-server"
|
||||||
# codex-exec: "exec"
|
# 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:
|
nodes:
|
||||||
# id is the stable node identity; omitting it falls back to an auto UUID (dev only).
|
# 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.
|
# is the long-slot count, not the normal capacity (2); do not conflate them.
|
||||||
total_context_tokens: 262144
|
total_context_tokens: 262144
|
||||||
long_context_capacity: 1
|
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):
|
# Legacy adapters configuration (compat override example):
|
||||||
# adapters:
|
# adapters:
|
||||||
# cli:
|
# cli:
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ func NormalizeAgentKind(kind string) (string, error) {
|
||||||
// NormalizeProviderType returns the canonical driver name for provider type.
|
// NormalizeProviderType returns the canonical driver name for provider type.
|
||||||
func NormalizeProviderType(t string) string {
|
func NormalizeProviderType(t string) string {
|
||||||
switch strings.ToLower(strings.TrimSpace(t)) {
|
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"
|
return "openai_compat"
|
||||||
case "ollama":
|
case "ollama":
|
||||||
return "ollama"
|
return "ollama"
|
||||||
|
|
@ -379,6 +379,7 @@ type EdgeOpenAIConf struct {
|
||||||
Listen string `mapstructure:"listen" yaml:"listen"`
|
Listen string `mapstructure:"listen" yaml:"listen"`
|
||||||
BearerToken string `mapstructure:"bearer_token" yaml:"bearer_token"`
|
BearerToken string `mapstructure:"bearer_token" yaml:"bearer_token"`
|
||||||
PrincipalTokens []OpenAIPrincipalTokenConf `mapstructure:"principal_tokens" yaml:"principal_tokens,omitempty"`
|
PrincipalTokens []OpenAIPrincipalTokenConf `mapstructure:"principal_tokens" yaml:"principal_tokens,omitempty"`
|
||||||
|
ProviderAuth EdgeOpenAIProviderAuthConf `mapstructure:"provider_auth" yaml:"provider_auth,omitempty"`
|
||||||
NodeRef string `mapstructure:"node" yaml:"node"`
|
NodeRef string `mapstructure:"node" yaml:"node"`
|
||||||
Adapter string `mapstructure:"adapter" yaml:"adapter"`
|
Adapter string `mapstructure:"adapter" yaml:"adapter"`
|
||||||
Target string `mapstructure:"target" yaml:"target"`
|
Target string `mapstructure:"target" yaml:"target"`
|
||||||
|
|
@ -390,6 +391,17 @@ type EdgeOpenAIConf struct {
|
||||||
StrictStreamBuffer bool `mapstructure:"strict_stream_buffer" yaml:"strict_stream_buffer"`
|
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
|
// OpenAIPrincipalTokenConf maps a hashed OpenAI-compatible bearer token to the
|
||||||
// IOP bearer-token-operation identity it authenticates as. Raw tokens are
|
// IOP bearer-token-operation identity it authenticates as. Raw tokens are
|
||||||
// never stored in config; only the SHA-256 hash is persisted.
|
// 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 {
|
if err := validateOpenAIPrincipalTokens(cfg.OpenAI.PrincipalTokens); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := normalizeOpenAIProviderAuth(v, &cfg.OpenAI.ProviderAuth); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if cfg.LongContextThresholdTokens <= 0 {
|
if cfg.LongContextThresholdTokens <= 0 {
|
||||||
return nil, fmt.Errorf("long_context_threshold_tokens must be positive")
|
return nil, fmt.Errorf("long_context_threshold_tokens must be positive")
|
||||||
}
|
}
|
||||||
|
|
@ -952,6 +967,36 @@ func validateOpenAIPrincipalTokens(tokens []OpenAIPrincipalTokenConf) error {
|
||||||
return nil
|
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 {
|
func checkUniqueNames(field string, name func(int) string, n int) error {
|
||||||
seen := make(map[string]struct{}, n)
|
seen := make(map[string]struct{}, n)
|
||||||
for i := 0; i < n; i++ {
|
for i := 0; i < n; i++ {
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,12 @@ func TestLoadEdge_OpenAIDefaults(t *testing.T) {
|
||||||
if cfg.OpenAI.BearerToken != "" {
|
if cfg.OpenAI.BearerToken != "" {
|
||||||
t.Fatalf("expected openai.bearer_token empty by default, got %q", 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" {
|
if cfg.OpenAI.Adapter != "ollama" {
|
||||||
t.Fatalf("expected openai.adapter=%q, got %q", "ollama", cfg.OpenAI.Adapter)
|
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) {
|
func TestLoadEdge_OpenAIPrincipalTokens(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
f := filepath.Join(dir, "edge.yaml")
|
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) {
|
func TestLoadEdge_OllamaContextSize(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
f := filepath.Join(dir, "edge.yaml")
|
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) {
|
func TestLoadEdge_ModelCatalogEmptyModelIDRejected(t *testing.T) {
|
||||||
// No models[] on node, so serve model check is skipped (legacy compat).
|
// No models[] on node, so serve model check is skipped (legacy compat).
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue