sync: edge openai raw tunnel sideband passthrough - contracts, specs, implementation, and tests

This commit is contained in:
toki 2026-07-08 07:51:23 +09:00
parent 1bf5eec31a
commit 893aad6edb
21 changed files with 1529 additions and 33 deletions

View file

@ -12,14 +12,14 @@
| id | 읽는 조건 | 원본 경로 | path |
|----|-----------|-----------|------|
| `iop.openai-compatible-api` | OpenAI-compatible API, Responses API, Chat Completions, legacy Completions, `model` route, Codex/CLI workspace, generic authoring metadata, `metadata.workspace`, `metadata.task_id` | `apps/edge/internal/openai/*`, `packages/go/config/config.go`, `configs/edge.yaml` | `agent-contract/outer/openai-compatible-api.md` |
| `iop.openai-compatible-api` | OpenAI-compatible API, Responses API, Chat Completions, legacy Completions, `model` route, Codex/CLI workspace, generic authoring metadata, `metadata.workspace`, `metadata.task_id`, `metadata.iop_response_mode`, provider raw passthrough/sideband/transformed response modes | `apps/edge/internal/openai/*`, `packages/go/config/config.go`, `configs/edge.yaml` | `agent-contract/outer/openai-compatible-api.md` |
| `iop.a2a-json-rpc-api` | A2A JSON-RPC API, `message/send`, `tasks/get`, `tasks/cancel`, A2A task state, agent card, `a2a.bearer_token`, Edge A2A input surface | `apps/edge/internal/input/a2a/*`, `packages/go/config/config.go`, `configs/edge.yaml` | `agent-contract/outer/a2a-json-rpc-api.md` |
## Inner Contracts
| id | 읽는 조건 | 원본 경로 | path |
|----|-----------|-----------|------|
| `iop.edge-node-runtime-wire` | Edge-Node TCP/protobuf, proto-socket transport, `RegisterRequest`, `RegisterResponse`, `RunRequest`, `RunEvent`, `CancelRequest`, `NodeCommandRequest`, `NodeConfigPayload`, `NodeConfigRefreshRequest` | `proto/iop/runtime.proto`, `apps/edge/internal/transport/*`, `apps/node/internal/transport/*`, `apps/edge/internal/node/mapper.go`, `apps/node/internal/adapters/config_set.go` | `agent-contract/inner/edge-node-runtime-wire.md` |
| `iop.edge-node-runtime-wire` | Edge-Node TCP/protobuf, proto-socket transport, `RegisterRequest`, `RegisterResponse`, `RunRequest`, `RunEvent`, `ProviderTunnelRequest`, `ProviderTunnelFrame`, `CancelRequest`, `NodeCommandRequest`, `NodeConfigPayload`, `NodeConfigRefreshRequest` | `proto/iop/runtime.proto`, `apps/edge/internal/transport/*`, `apps/node/internal/transport/*`, `apps/edge/internal/node/mapper.go`, `apps/node/internal/adapters/config_set.go` | `agent-contract/inner/edge-node-runtime-wire.md` |
| `iop.control-plane-edge-wire` | Control Plane-Edge wire, `EdgeHelloRequest`, `EdgeStatusRequest`, `EdgeStatusResponse`, `EdgeCommandRequest`, `EdgeCommandEvent`, Edge connection registry | `proto/iop/control.proto`, `apps/control-plane/internal/wire/*`, `apps/edge/internal/controlplane/*` | `agent-contract/inner/control-plane-edge-wire.md` |
| `iop.client-control-plane-wire` | Client-Control Plane wire, `/client` WebSocket, proto-socket WS, `ClientHelloRequest`, `ClientHelloResponse`, Flutter client wire | `proto/iop/control.proto`, `apps/control-plane/internal/wire/client.go`, `apps/client/lib/iop_wire/*` | `agent-contract/inner/client-control-plane-wire.md` |
| `iop.edge-config-runtime-refresh` | Edge config schema, `configs/edge.yaml`, `packages/go/config`, provider pool, `models[]`, `nodes[].providers[]`, `openai.model_routes`, config refresh, restart/applied classification | `packages/go/config/config.go`, `configs/edge.yaml`, `apps/edge/internal/configrefresh/*`, `proto/iop/runtime.proto` | `agent-contract/inner/edge-config-runtime-refresh.md` |

View file

@ -19,8 +19,8 @@
## 읽는 조건
- Edge-Node TCP/protobuf transport, register handshake, run stream, cancel, node command, node config refresh를 바꿀 때
- `RunRequest`, `RunEvent`, `CancelRequest`, `NodeCommandRequest`, `NodeCommandResponse`, `NodeConfigPayload`, `NodeConfigRefresh*` 필드를 바꿀 때
- Edge-Node TCP/protobuf transport, register handshake, run stream, provider raw tunnel, cancel, node command, node config refresh를 바꿀 때
- `RunRequest`, `RunEvent`, `ProviderTunnelRequest`, `ProviderTunnelFrame`, `CancelRequest`, `NodeCommandRequest`, `NodeCommandResponse`, `NodeConfigPayload`, `NodeConfigRefresh*` 필드를 바꿀 때
- node adapter 설정 payload나 runtime config가 Edge에서 Node로 전달되는 방식을 바꿀 때
## 범위
@ -33,7 +33,7 @@ Edge는 Node 연결을 수락하고, Node는 연결 직후 등록 요청을 보
- register: Node가 `RegisterRequest`를 보내고 Edge가 `RegisterResponse`로 수락 여부와 `NodeConfigPayload`를 돌려준다.
- execution: Edge가 `RunRequest`를 보내고 Node가 `RunEvent` stream으로 실행 상태를 보낸다.
- provider raw tunnel: Edge가 `ProviderTunnelRequest`를 보내고 Node가 `ProviderTunnelFrame` stream으로 provider status/header/body/end/error/usage 후보를 sequence와 함께 돌려준다. 이 경로는 OpenAI-compatible provider passthrough용이며 `RunEvent` 실행 stream과 분리된다.
- provider raw tunnel: Edge가 기존 Edge-Node socket으로 `ProviderTunnelRequest`를 보내고 Node가 provider HTTP/SSE 요청을 연 뒤 `ProviderTunnelFrame` stream으로 provider status/header/body/end/error/usage 후보를 sequence와 함께 돌려준다. 이 경로는 OpenAI-compatible provider `passthrough`와 `passthrough+sideband`용이며 `RunEvent` 실행 stream과 분리된다.
- cancel: Edge가 `CancelRequest`를 보내며 `CANCEL_RUN``TERMINATE_SESSION`을 구분한다.
- command: Edge가 `NodeCommandRequest`를 보내고 Node가 `NodeCommandResponse`로 usage/capabilities/session/transport/provider 상태를 응답한다.
- refresh: Edge가 `NodeConfigRefreshRequest`로 새 config payload를 보내고 Node가 `NodeConfigRefreshResponse`로 적용/재시작 필요/실패를 응답한다.
@ -45,8 +45,9 @@ Edge는 Node 연결을 수락하고, Node는 연결 직후 등록 요청을 보
- `RunRequest.input`: adapter가 해석할 실행 입력이다. CLI 실행에서는 prompt 계열 입력으로 변환된다.
- `RunRequest.metadata`: caller-defined 실행 metadata다. workspace 자체는 별도 `workspace` 필드로 전달한다.
- `RunEvent.type`: `start`, `delta`, `complete`, `error`, `cancelled` 같은 실행 이벤트 종류다.
- `ProviderTunnelRequest`: 기존 Edge-Node socket 위에서 provider HTTP request를 열기 위한 요청이다. `adapter`, `target`, `method`, `path`, `headers`, `body`, `stream`, `timeout_sec`, `metadata`, `session_id`를 싣되 normalized adapter execution인 `RunRequest`와 분리된다.
- `ProviderTunnelFrame`: Node가 provider response를 Edge로 돌려주는 ordered frame이다. `kind`, `sequence`, `status_code`, `headers`, `body`, `end`, `error`, `usage`, `metadata`를 싣는다. `body`는 passthrough source of truth이며 `RunEvent.delta`나 Edge `events.Bus` fanout payload로 보내지 않는다.
- `ProviderTunnelRequest`: 기존 Edge-Node socket 위에서 provider HTTP request를 열기 위한 요청이다. `adapter`, `target`, `method`, `path`, `headers`, `body`, `stream`, `timeout_sec`, `metadata`, `session_id`를 싣되 normalized adapter execution인 `RunRequest`와 분리된다. Chat Completions response mode는 `metadata["iop_response_mode"]``passthrough` 또는 `passthrough+sideband`로 전달될 수 있다.
- `ProviderTunnelFrame`: Node가 provider response를 Edge로 돌려주는 ordered frame이다. `kind`, `sequence`, `status_code`, `headers`, `body`, `end`, `error`, `usage`, `metadata`를 싣는다. `body`는 passthrough source of truth이며 `RunEvent.delta`나 Edge `events.Bus` fanout payload로 보내지 않는다. `usage``metadata`는 sideband observation 후보이고 pure passthrough body에 합쳐지지 않는다.
- tunnel cancellation: HTTP caller disconnect, response wait timeout, 또는 Edge write failure가 발생하면 Edge는 같은 run id에 대한 `CancelRequest(CANCEL_RUN)`을 보내 upstream provider request 중단을 요청한다. Node adapter는 provider request context cancellation을 관측하고 ordered error/end semantics를 유지해야 한다.
- `RunEvent.metadata["openai_tool_calls"]`: OpenAI-compatible provider adapter가 native `tool_calls`를 반환했을 때 완료 이벤트에 싣는 JSON 배열이다. Edge OpenAI-compatible 표면은 이 값을 `message.tool_calls` 또는 stream `delta.tool_calls`로 복원한다. provider assistant content 텍스트를 이 값으로 파싱/합성하지 않는다.
- `RunEvent.metadata["openai_text_tool_fallback"]`: OpenAI-compatible provider adapter가 backend native tool API 거부 후 `tools`/`tool_choice`를 제거하고 text tool-call instruction으로 재시도했을 때 `"true"`를 싣는다. 이 instruction은 backend가 system role 위치를 거부하지 않도록 leading system message에 병합한다. Edge는 이 표시가 있는 실행에서만 assistant content의 text tool-call을 OpenAI-compatible `tool_calls`로 복원할 수 있다.
- `NodeCommandRequest.type`: 실행이 아닌 조회/제어성 명령이다. adapter execution 요청과 섞지 않는다.
@ -68,6 +69,9 @@ Edge는 Node 연결을 수락하고, Node는 연결 직후 등록 요청을 보
- `proto/iop/runtime.proto`
- `apps/edge/internal/transport/*_test.go`
- `apps/node/internal/transport/*_test.go`
- `apps/edge/internal/openai/server_test.go`
- `apps/node/internal/adapters/openai_compat/*_test.go`
- `apps/node/internal/adapters/vllm/*_test.go`
- `apps/edge/internal/node/mapper_test.go`
- `apps/node/internal/adapters/config_set_test.go`
- `apps/node/internal/adapters/adapters_blackbox_test.go`

View file

@ -170,6 +170,17 @@ Workspace-bound route는 workspace가 없거나 상대 경로이면 OpenAI-compa
- `thinking_token_budget`
- `include_reasoning`
### Chat Completions response mode
OpenAI-compatible inference provider route는 요청 metadata의 `iop_response_mode`로 응답 경로를 고른다.
- `metadata.iop_response_mode` 생략 또는 `passthrough`: provider HTTP status/header/body bytes를 Node가 열어 기존 Edge-Node tunnel로 relay하고, Edge가 caller에게 그대로 쓴다. pure `passthrough` 응답 body에는 IOP sideband field/event를 섞지 않고, `X-IOP-Response-Mode` header도 붙이지 않는다.
- `metadata.iop_response_mode="passthrough+sideband"`: provider body와 IOP route/usage/assembled observation을 명시적 IOP extension surface로 함께 노출한다. streaming 응답은 provider SSE event 경계 사이에 `event: iop.sideband`를 추가하고, non-streaming 응답은 `iop.chat.passthrough_sideband` envelope로 provider body와 `iop_sideband`를 함께 반환한다. 이 모드는 provider-original byte-identical response로 표시하지 않는다.
- `metadata.iop_response_mode="transformed"`: raw tunnel을 쓰지 않고 normalized IOP output path를 사용하며 `X-IOP-Response-Mode: transformed`로 라벨링한다. 이 응답은 provider-original byte identity 검증 대상이 아니다.
알 수 없는 `metadata.iop_response_mode` 값은 silent fallback 없이 `400 invalid_request_error`로 거부한다.
현재 raw passthrough/sideband/transformed mode는 Chat Completions provider route 계약이다. `/v1/responses` raw passthrough parity는 별도 구현/계약 갱신 대상이다.
Think 제어 field:
- `think` (bool, optional): thinking/reasoning 생성 활성화 여부. 생략하면 provider 기본값을 유지한다. `false`는 thinking 생성을 끄도록 요청하고, `true`는 provider가 지원하면 thinking 생성을 명시 활성화한다.
@ -235,6 +246,7 @@ text tool-call을 구조화할 때 Edge는 route와 무관하게 요청의 `tool
금지:
- `metadata.source`, `metadata.cli`, `metadata.inference`, `metadata.nomadcode`
- `metadata.iop_response_mode``passthrough`, `passthrough+sideband`, `transformed` 외 값을 넣는 방식
- `options`, `chat_template_kwargs`, `format`, `keep_alive` 같은 provider/Ollama 전용 request field
- `session_id`, `timeout_sec` 같은 IOP 실행 제어 field

View file

@ -60,9 +60,9 @@ OpenAI-compatible provider의 원본 응답을 정규화/재조립하지 않고
원본 passthrough, sideband 포함 mode, IOP 변환 mode를 요청에서 선택할 수 있게 하되 기본 경로의 provider-original 보장을 훼손하지 않는다.
- [ ] [mode-contract] OpenAI-compatible request metadata/config 계약에 `metadata.iop_response_mode``passthrough`, `passthrough+sideband`, `transformed` 모드를 정의하고 omitted mode는 `passthrough`로 해석한다. 검증: mode 누락, explicit sideband, explicit transformed routing test가 각각 기대 경로를 타고 unknown mode는 fail-fast error가 된다.
- [ ] [sideband-overlay] `passthrough+sideband`는 provider original stream과 IOP sideband를 명시적 IOP extension stream/schema로 함께 노출하되 pure `passthrough` body에는 IOP field/event를 주입하지 않는다. 검증: pure passthrough 응답에 IOP sideband가 없고 opt-in sideband mode에서 같은 response stream 또는 계약된 extension surface로 route/provider/assembled observation이 확인된다.
- [ ] [transform-output] `transformed`는 provider raw stream을 IOP schema/output으로 수정할 수 있지만 byte-identical provider stream으로 표시하지 않는다. 검증: transformed response가 IOP output으로 라벨링되고 original passthrough 검증과 분리된다.
- [x] [mode-contract] OpenAI-compatible request metadata/config 계약에 `metadata.iop_response_mode``passthrough`, `passthrough+sideband`, `transformed` 모드를 정의하고 omitted mode는 `passthrough`로 해석한다. 검증: mode 누락, explicit sideband, explicit transformed routing test가 각각 기대 경로를 타고 unknown mode는 fail-fast error가 된다.
- [x] [sideband-overlay] `passthrough+sideband`는 provider original stream과 IOP sideband를 명시적 IOP extension stream/schema로 함께 노출하되 pure `passthrough` body에는 IOP field/event를 주입하지 않는다. 검증: pure passthrough 응답에 IOP sideband가 없고 opt-in sideband mode에서 같은 response stream 또는 계약된 extension surface로 route/provider/assembled observation이 확인된다.
- [x] [transform-output] `transformed`는 provider raw stream을 IOP schema/output으로 수정할 수 있지만 byte-identical provider stream으로 표시하지 않는다. 검증: transformed response가 IOP output으로 라벨링되고 original passthrough 검증과 분리된다.
### Epic: [observability] Assembled Output and Flow Control
@ -70,7 +70,7 @@ raw bytes는 전송 source of truth로 쓰되, 운영 관측과 저장은 사람
- [ ] [assembled-log] 실행 로그에는 raw chunk payload 대신 assembled `content`, `reasoning`, `tool_calls`, sideband summary, transformed output을 남긴다. 검증: log fixture가 readable assembled string과 구조화 관측값을 포함하고 raw body chunk를 기본 저장하지 않는다.
- [ ] [backpressure-cancel] tunnel path는 기존 socket 위에서 ordered, lossless, backpressure-aware stream을 유지하고 cancel/control frame을 지연시키지 않는다. 검증: slow client, cancel, provider error test에서 chunk drop/reorder 없이 upstream request가 중단된다.
- [ ] [contract-docs] agent-contract, agent-spec, config/docs 예시에 raw tunnel, response mode, sideband/transformed semantics, legacy normalized path migration을 반영한다. 검증: contract/spec 링크와 코드 test fixture가 같은 mode 이름과 default를 사용한다.
- [x] [contract-docs] agent-contract, agent-spec, config/docs 예시에 raw tunnel, response mode, sideband/transformed semantics, legacy normalized path migration을 반영한다. 검증: contract/spec 링크와 코드 test fixture가 같은 mode 이름과 default를 사용한다.
## 완료 리뷰

View file

@ -22,18 +22,18 @@ AI agent가 작업 전에 읽는 지도이기도 하지만, 사람도 "지금
## 영역별 요약
- 실행 경로: Edge와 Node 사이의 등록, 실행, 이벤트, 취소, command 흐름은 `runtime/edge-node-execution`에서 본다.
- 실행 경로: Edge와 Node 사이의 등록, 실행, 이벤트, provider raw tunnel, 취소, command 흐름은 `runtime/edge-node-execution`에서 본다.
- 런타임 라우팅/설정: provider-pool, `models[]`, `nodes[].providers[]`, live config refresh는 `runtime/provider-pool-config-refresh`에서 본다.
- 외부 HTTP 입력: OpenAI-compatible 호출은 `input/openai-compatible-surface`, A2A JSON-RPC 호출은 `input/a2a-json-rpc-surface`에서 본다.
- 외부 HTTP 입력: OpenAI-compatible 호출, response mode/raw tunnel`input/openai-compatible-surface`, A2A JSON-RPC 호출은 `input/a2a-json-rpc-surface`에서 본다.
- 운영 제어: Control Plane, Edge enrollment, fleet/edge status, Flutter Client 상태 소비는 `control/control-plane-operations`에서 본다.
## 스펙 목록
| id | 상태 | 언제 읽나 | path | 주요 근거 |
|----|------|-----------|------|-----------|
| `runtime/edge-node-execution` | 부분 | Edge-Node TCP/protobuf transport, Node 등록, run/cancel/command, adapter 실행, Node local run store를 확인할 때 | `agent-spec/runtime/edge-node-execution.md` | `agent-contract/inner/edge-node-runtime-wire.md`, `apps/edge/internal/service/run_dispatch.go`, `apps/node/internal/node/node.go` |
| `runtime/edge-node-execution` | 부분 | Edge-Node TCP/protobuf transport, Node 등록, run/cancel/command, provider raw tunnel, adapter 실행, Node local run store를 확인할 때 | `agent-spec/runtime/edge-node-execution.md` | `agent-contract/inner/edge-node-runtime-wire.md`, `apps/edge/internal/service/run_dispatch.go`, `apps/node/internal/node/node.go` |
| `runtime/provider-pool-config-refresh` | 부분 | `models[]`, `nodes[].providers[]`, provider-pool dispatch, long-context admission, Edge/Node config refresh를 확인할 때 | `agent-spec/runtime/provider-pool-config-refresh.md` | `agent-contract/inner/edge-config-runtime-refresh.md`, `packages/go/config/config.go`, `apps/edge/internal/configrefresh/classify.go` |
| `input/openai-compatible-surface` | 부분 | `/v1/models`, `/v1/chat/completions`, `/v1/responses`, OpenAI-compatible auth/metadata/workspace/tool handling, 외부 `model` route를 확인할 때 | `agent-spec/input/openai-compatible-surface.md` | `agent-contract/outer/openai-compatible-api.md`, `apps/edge/internal/openai/chat_handler.go`, `apps/edge/internal/openai/responses_handler.go` |
| `input/openai-compatible-surface` | 부분 | `/v1/models`, `/v1/chat/completions`, `/v1/responses`, OpenAI-compatible auth/metadata/workspace/tool handling, response mode/raw tunnel, 외부 `model` route를 확인할 때 | `agent-spec/input/openai-compatible-surface.md` | `agent-contract/outer/openai-compatible-api.md`, `apps/edge/internal/openai/chat_handler.go`, `apps/edge/internal/openai/stream.go` |
| `input/a2a-json-rpc-surface` | 부분 | Edge A2A JSON-RPC, `message/send`, `tasks/get`, `tasks/cancel`, A2A task store와 bearer auth를 확인할 때 | `agent-spec/input/a2a-json-rpc-surface.md` | `agent-contract/outer/a2a-json-rpc-api.md`, `apps/edge/internal/input/a2a/server.go`, `apps/edge/internal/input/a2a/task_store.go` |
| `control/control-plane-operations` | 부분 | Control Plane-Edge wire, Client-Control Plane wire, Control Plane HTTP Edge/fleet status view, Flutter Client status consumer를 확인할 때 | `agent-spec/control/control-plane-operations.md` | `agent-contract/inner/control-plane-edge-wire.md`, `agent-contract/inner/client-control-plane-wire.md`, `apps/control-plane/internal/wire/edge_server.go` |

View file

@ -12,6 +12,9 @@ source_evidence:
- type: code
path: apps/edge/internal/openai/chat_handler.go
notes: Chat Completions request validation, route dispatch, tool/reasoning 정책
- type: code
path: apps/edge/internal/openai/stream.go
notes: Chat Completions streaming, provider raw tunnel passthrough, sideband/transformed response mode 처리
- type: code
path: apps/edge/internal/openai/responses_handler.go
notes: Responses API request validation, metadata/workspace 처리, non-stream completion
@ -41,6 +44,9 @@ Edge가 OpenAI-compatible HTTP 요청을 받아 내부 `adapter + target` 실행
| legacy route 변환 | legacy route는 외부 `model`을 route entry의 `adapter`, `target`, `node`, `session_id`, queue policy로 변환한다. |
| metadata/workspace 처리 | `metadata.workspace``RunRequest.workspace`로 분리하고, 일반 metadata는 최대 16개 string key/value만 허용한다. |
| Chat Completions | `/v1/chat/completions`는 non-streaming과 streaming SSE를 지원한다. |
| Chat Completions response mode | OpenAI-compatible provider route는 `metadata.iop_response_mode` 생략 시 `passthrough`로 동작하고, 명시적으로 `passthrough+sideband` 또는 `transformed`를 선택할 수 있다. |
| provider raw passthrough | `passthrough`는 provider status/header/body bytes를 기존 Edge-Node tunnel로 relay하고 pure response body에 IOP sideband를 섞지 않는다. |
| sideband extension | `passthrough+sideband`는 provider body와 IOP route/usage/assembled observation을 명시적 extension stream/envelope로 함께 노출하며 provider-original byte identity로 표시하지 않는다. |
| Responses API | `/v1/responses`는 현재 string input의 non-streaming 요청만 지원한다. |
| strict output | strict output이 켜져 있으면 XML completion contract 기반 instruction 또는 prompt prefix를 추가할 수 있다. |
| tool call 처리 | Chat Completions `tools`는 provider native metadata 복원 또는 text tool-call synthesis/validation 경로를 사용한다. |
@ -62,11 +68,19 @@ sequenceDiagram
Caller->>OpenAI: chat/responses request(model)
OpenAI->>OpenAI: auth, metadata, route 검증
OpenAI->>Service: SubmitRun(adapter/target or ProviderPool)
Service->>Runtime: RunRequest
Runtime-->>Service: RunEvent stream
Service-->>OpenAI: run stream
OpenAI-->>Caller: OpenAI-compatible response or SSE
alt provider route + passthrough mode
OpenAI->>Service: SubmitProviderTunnel(ProviderPool/direct)
Service->>Runtime: ProviderTunnelRequest
Runtime-->>Service: ProviderTunnelFrame stream
Service-->>OpenAI: tunnel frames
OpenAI-->>Caller: provider-original bytes or sideband extension
else transformed/normalized path
OpenAI->>Service: SubmitRun(adapter/target or ProviderPool)
Service->>Runtime: RunRequest
Runtime-->>Service: RunEvent stream
Service-->>OpenAI: run stream
OpenAI-->>Caller: OpenAI-compatible response or SSE
end
```
## 계약
@ -80,7 +94,9 @@ sequenceDiagram
- `configs/edge.yaml``openai` 섹션이 listener, bearer token, legacy adapter/target, model routes, strict output을 제공한다.
- top-level `models[]`가 있으면 OpenAI model list와 provider-pool dispatch에서 legacy route보다 우선한다.
- OpenAI request의 `metadata.workspace`는 absolute path가 필요한 route에서만 필수 검증된다.
- OpenAI Chat Completions request의 `metadata.iop_response_mode``passthrough`, `passthrough+sideband`, `transformed`만 허용한다. provider route에서 생략하면 `passthrough`다.
- run metadata에는 `openai_model`, `openai_stream`, `strict_output`, `estimated_input_tokens`, `context_class`가 들어갈 수 있다.
- provider tunnel metadata에는 response mode와 routing context가 들어가고, sideband mode는 route/usage/assembled observation을 확장 surface로 만든다.
- Node complete event metadata의 `openai_tool_calls``openai_text_tool_fallback`은 response tool call 복원에 쓰인다.
## 검증
@ -96,6 +112,8 @@ sequenceDiagram
- `/v1/completions`는 제공하지 않는다.
- OpenAI-compatible request에 provider/Ollama 전용 root field를 추가하지 않는다.
- workspace는 prompt 본문에 섞지 않고 metadata에서 분리한다.
- pure `passthrough` body는 provider-original byte stream이며 IOP sideband나 transformed label을 포함하지 않는다.
- `passthrough+sideband``transformed`는 provider-original byte identity로 취급하지 않는다.
- text tool-call synthesis는 요청 `tools[]` schema를 기준으로만 수행한다. 자연어 추론으로 tool call을 만들지 않는다.
- private token이나 endpoint 원문은 tracked spec/docs에 남기지 않는다.
@ -103,3 +121,4 @@ sequenceDiagram
- 2026-07-07: 현재 코드와 OpenAI-compatible 계약 기준으로 bootstrap spec 작성.
- 2026-07-07: 기능 목록 중심으로 축소하고 주요 흐름을 Mermaid sequence diagram으로 정리.
- 2026-07-08: Chat Completions provider raw tunnel, response mode, sideband/transformed semantics를 현재 코드와 계약 기준으로 반영.

View file

@ -14,7 +14,7 @@ source_evidence:
notes: Edge TCP proto-socket server, node register handshake, event relay
- type: code
path: apps/edge/internal/service/run_dispatch.go
notes: surface-neutral SubmitRun, direct dispatch, cancel request 생성
notes: surface-neutral SubmitRun, provider tunnel dispatch, cancel request 생성
- type: code
path: apps/node/internal/node/node.go
notes: Node transport handler, adapter 실행, cancel, command, config refresh 처리
@ -42,6 +42,7 @@ Edge와 Node 사이에 현재 구현된 실행 기능을 기능 단위로 정리
| 실행 요청 전달 | Edge service가 `SubmitRun` 요청을 `RunRequest`로 만들어 선택된 Node에 보낸다. 명시 node가 없고 연결 node가 1개면 single-node fallback을 사용한다. |
| adapter 실행 | Node가 `RunRequest.adapter`로 adapter instance를 찾고 adapter `Execute`를 호출한다. admission은 adapter `Capabilities().MaxConcurrency` 기준이다. |
| 실행 이벤트 스트림 | Node adapter가 낸 start, delta, reasoning_delta, complete, error, cancelled 이벤트를 `RunEvent`로 Edge에 relay한다. |
| provider raw tunnel | Edge가 `ProviderTunnelRequest`를 보내면 Node가 provider HTTP/SSE response를 열고 ordered `ProviderTunnelFrame`으로 status/header/body/end/error/usage 후보를 relay한다. |
| terminal event 합성 | adapter가 terminal event 없이 종료하면 Node가 terminal event를 합성한다. |
| run cancel | Edge가 `CancelRequest`를 보내면 Node run manager가 active run context를 cancel한다. |
| logical session 종료 | adapter가 `SessionTerminator`를 구현한 경우 `TERMINATE_SESSION`으로 session을 종료한다. 모든 adapter 공통 기능은 아니다. |
@ -51,7 +52,7 @@ Edge와 Node 사이에 현재 구현된 실행 기능을 기능 단위로 정리
## 범위
- 포함: Edge-Node TCP/protobuf transport, register handshake, run/cancel/command, Node adapter execution, Edge event bus fanout, Node local run store.
- 포함: Edge-Node TCP/protobuf transport, register handshake, run/cancel/command, provider raw tunnel, Node adapter execution, Edge event bus fanout, Node local run store.
- 제외: OpenAI-compatible/A2A HTTP request shape, Control Plane 운영 wire, provider-pool config refresh 상세, durable global history/audit.
## 주요 흐름
@ -97,6 +98,23 @@ sequenceDiagram
EdgeTransport-->>Caller: run stream
```
### Provider raw tunnel
```mermaid
sequenceDiagram
participant OpenAI as Edge OpenAI surface
participant EdgeService as Edge service
participant Node
participant Provider
OpenAI->>EdgeService: SubmitProviderTunnel
EdgeService->>Node: ProviderTunnelRequest
Node->>Provider: HTTP/SSE request
Provider-->>Node: status/header/body
Node-->>EdgeService: ProviderTunnelFrame sequence
EdgeService-->>OpenAI: request-bound frame stream
```
### 취소와 session 종료
```mermaid
@ -123,6 +141,8 @@ sequenceDiagram
- Edge의 node source of truth는 `configs/edge.yaml``packages/go/config``nodes[]` 구조다.
- `RunEvent`는 adapter execution stream이고, `EdgeNodeEvent`는 node lifecycle/control event다.
- `ProviderTunnelFrame.body`는 OpenAI-compatible provider passthrough의 source of truth이며 `RunEvent.delta`나 Edge event bus payload로 보내지 않는다.
- `ProviderTunnelFrame.usage``metadata`는 sideband observation 후보이며 pure passthrough body에 합쳐지지 않는다.
- Node local DB는 기본 `file:iop.db?cache=shared&mode=rwc`로 열린다.
- heartbeat는 Edge와 Node transport 양쪽에서 30초 interval, 45초 wait 기준을 사용한다.
@ -137,8 +157,10 @@ sequenceDiagram
- mTLS helper는 존재하지만 현재 Edge-Node transport 설정에는 연결되어 있지 않다.
- `TERMINATE_SESSION`은 모든 adapter에 공통으로 보장되는 기능이 아니다.
- Node store는 전역 query/audit API가 아니다. 상위 운영 이력은 별도 설계가 필요하다.
- provider raw tunnel은 기존 socket 위 request-bound stream이다. 별도 Node stream server나 Edge의 provider direct access 경로가 아니다.
## 변경 기록
- 2026-07-07: 현재 코드, 계약, README 기준으로 bootstrap spec 작성.
- 2026-07-07: 기능 목록 중심으로 축소하고 주요 흐름을 Mermaid sequence diagram으로 정리.
- 2026-07-08: Provider raw tunnel 실행 흐름과 passthrough event/data 경계를 현재 계약 기준으로 반영.

View file

@ -0,0 +1,160 @@
<!-- task=m-openai-compatible-raw-tunnel-sideband-passthrough/04_assembled_log plan=0 tag=OBSLOG -->
# Code Review Reference - OBSLOG
> **[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 linked evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Environment/secret/service blockers, generic scope changes, repeated failures, 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 Milestone lock decisions 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-08
task=m-openai-compatible-raw-tunnel-sideband-passthrough/04_assembled_log, plan=0, tag=OBSLOG
## Roadmap Targets
- Milestone: `agent-roadmap/phase/routing-policy-model-orchestration/milestones/openai-compatible-raw-tunnel-sideband-passthrough.md`
- Milestone link: [Milestone 문서](agent-roadmap/phase/routing-policy-model-orchestration/milestones/openai-compatible-raw-tunnel-sideband-passthrough.md)
- Task ids:
- `assembled-log`: 실행 로그에는 raw chunk payload 대신 assembled `content`, `reasoning`, `tool_calls`, sideband summary, transformed output을 남긴다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-local-G06.md` -> `code_review_local_G06_N.log`, `PLAN-local-G06.md` -> `plan_local_G06_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-openai-compatible-raw-tunnel-sideband-passthrough/04_assembled_log/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [OBSLOG-1] Assembled Observation Logging | [x] |
| [OBSLOG-2] Raw Payload Negative Guard | [x] |
## 구현 체크리스트
- [x] `providerChatAssembler` 또는 새 helper를 통해 pure passthrough, sideband, transformed/normalized output에서 content/reasoning/tool call summary를 일관된 observation으로 만들고 raw provider chunk/body를 zap field로 저장하지 않는다.
- [x] `apps/edge/internal/openai/server_test.go`에 `assembled-log` fixture를 추가해 readable assembled summary, tool call names/count, sideband summary, transformed output label을 확인한다.
- [x] `apps/edge/internal/openai/log_safety_test.go`를 갱신해 새 log line이 raw chunk payload, provider body, preview field를 남기지 않는다는 negative assertion을 추가한다.
- [x] `go test -count=1 ./apps/edge/internal/openai -run 'TestChatCompletions.*(Assembled|Passthrough|Sideband|Transformed)|TestLog'`를 통과시킨다.
- [x] `go test -count=1 ./apps/edge/internal/openai`를 통과시킨다.
- [x] `git diff --check`를 통과시킨다.
- [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_local_G06_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_local_G06_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/m-openai-compatible-raw-tunnel-sideband-passthrough/04_assembled_log/`를 `agent-task/archive/YYYY/MM/m-openai-compatible-raw-tunnel-sideband-passthrough/04_assembled_log/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-openai-compatible-raw-tunnel-sideband-passthrough/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-{build_lane}-GNN.md`와 `CODE_REVIEW-{review_lane}-GNN.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로 이동한다.
## 계획 대비 변경 사항
- `writeProviderTunnelResponse` 함수에 `reqStream bool` 파라미터를 명시적으로 추가하여, `service.RunDispatch`에 `Metadata` 필드가 정의되어 있지 않아 메타데이터 조회가 불가했던 제약을 해결했습니다.
## 주요 설계 결정
- `run_result.go`에 공통 헬퍼 `extractToolCallNames`를 정의하여 다양한 형식의 tool_calls 배열에서 안전하게 함수명을 추출하도록 설계했습니다.
- `chatCompletionOutput` 구조체에 `responseMode` 필드를 추가함으로써 public 시그니처 변경 없이 `collectChatCompletionOutput` 단계에서 결정된 response mode를 `completeChatCompletion` 및 `writeBufferedStreamOutput` 로그에 매끄럽게 전달하도록 설계했습니다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- Raw provider chunk/body payload가 zap field나 store schema에 새로 저장되지 않았는지 확인한다.
- S08 evidence가 `assembled-log` Task id에 직접 대응하는지 확인한다.
- Log safety 테스트가 새 로그 필드를 포함하는지 확인한다.
## 검증 결과
### OBSLOG-1 중간 검증
```bash
$ go test -count=1 ./apps/edge/internal/openai -run 'TestChatCompletions.*(Assembled|Passthrough|Sideband|Transformed)'
ok iop/apps/edge/internal/openai 0.008s
```
### OBSLOG-2 중간 검증
```bash
$ go test -count=1 ./apps/edge/internal/openai -run 'TestLog'
ok iop/apps/edge/internal/openai 0.005s
```
### 최종 검증
```bash
$ go test -count=1 ./apps/edge/internal/openai -run 'TestChatCompletions.*(Assembled|Passthrough|Sideband|Transformed)|TestLog'
ok iop/apps/edge/internal/openai 0.008s
$ go test -count=1 ./apps/edge/internal/openai
ok iop/apps/edge/internal/openai 1.580s
$ rg --sort path -n 'zap\.(String|ByteString|Any)\("(raw_body|provider_body|raw_chunk|body|content_preview|reasoning_preview|delta_preview)"' apps/edge/internal/openai
(출력 없음 - 금지된 페이로드 필드가 사용되지 않음 확인)
$ git diff --check
(출력 없음 - 공백 검증 성공)
```
---
> **[IMPLEMENTING AGENT - BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- Correctness: Fail
- Completeness: Fail
- Test coverage: Fail
- API contract: Pass
- Code quality: Pass
- Implementation deviation: Fail
- Verification trust: Pass
- Spec conformance: Fail
- 발견된 문제:
- Required: `apps/edge/internal/openai/stream.go:668`의 `providerChatAssembler.observation()`은 non-streaming일 때 호출마다 `a.pending`을 다시 unmarshal하고 `consumeDelta`로 내부 builder에 누적한다. 같은 non-streaming sideband response에서 `stream.go:928`이 envelope용 observation을 만들고 `stream.go:940`이 로그용으로 다시 호출하므로, 실행 로그의 `assembled_content`, `assembled_reasoning`, `assembled_tool_calls`가 두 배로 기록될 수 있다. `observation()`을 idempotent하게 만들거나 END 처리에서 observation을 한 번만 계산해 envelope와 log에 재사용하고, non-streaming sideband response 로그가 중복되지 않는 테스트를 추가해야 한다.
- Required: `apps/edge/internal/openai/server_test.go:5588` 이하의 assembled-log fixtures는 content와 zero tool count만 확인한다. 계획과 SDD S08은 readable assembled content/reasoning/tool_calls, sideband summary, transformed output을 실행 로그 evidence로 요구했지만 현재 테스트는 `assembled_reasoning`, non-empty `assembled_tool_calls`, transformed mode의 `response_mode=transformed` log를 직접 검증하지 않는다. fixture를 확장해 reasoning, tool call names/count, sideband response/summary, transformed output label을 실제 log field로 assertion해야 한다.
- 다음 단계: FAIL 후속 plan/review 파일을 작성한다. 사용자 리뷰 요청은 없음.
- 리뷰 검증:
- `go test -count=1 ./apps/edge/internal/openai -run 'TestChatCompletions.*(Assembled|Passthrough|Sideband|Transformed)|TestLog'`: PASS
- `go test -count=1 ./apps/edge/internal/openai`: PASS
- `rg --sort path -n 'zap\.(String|ByteString|Any)\("(raw_body|provider_body|raw_chunk|body|content_preview|reasoning_preview|delta_preview)"' apps/edge/internal/openai`: 출력 없음
- `git diff --check`: PASS

View file

@ -0,0 +1,174 @@
<!-- task=m-openai-compatible-raw-tunnel-sideband-passthrough/04_assembled_log plan=1 tag=REVIEW_OBSLOG -->
# Code Review Reference - REVIEW_OBSLOG
> **[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-08
task=m-openai-compatible-raw-tunnel-sideband-passthrough/04_assembled_log, plan=1, tag=REVIEW_OBSLOG
## Roadmap Targets
- Milestone: `agent-roadmap/phase/routing-policy-model-orchestration/milestones/openai-compatible-raw-tunnel-sideband-passthrough.md`
- Milestone link: [Milestone 문서](agent-roadmap/phase/routing-policy-model-orchestration/milestones/openai-compatible-raw-tunnel-sideband-passthrough.md)
- Task ids:
- `assembled-log`: 실행 로그에는 raw chunk payload 대신 assembled `content`, `reasoning`, `tool_calls`, sideband summary, transformed output을 남긴다.
- Completion mode: check-on-pass
## Archive Evidence Snapshot
- Archived plan: `agent-task/m-openai-compatible-raw-tunnel-sideband-passthrough/04_assembled_log/plan_local_G06_0.log`
- Archived review: `agent-task/m-openai-compatible-raw-tunnel-sideband-passthrough/04_assembled_log/code_review_local_G06_0.log`
- Verdict: FAIL
- Issue summary: Required 2, Suggested 0, Nit 0
- Required findings:
- `apps/edge/internal/openai/stream.go:668`: non-streaming `providerChatAssembler.observation()` mutates builder state on every call; `stream.go:928` and `stream.go:940` call it twice in sideband response, so execution log can duplicate assembled content/reasoning/tool names.
- `apps/edge/internal/openai/server_test.go:5588`: assembled-log fixtures only check content and zero tool count; SDD S08 evidence still needs reasoning, non-empty tool call names/count, sideband log/summary, and transformed response-mode log assertions.
- Affected files:
- `apps/edge/internal/openai/stream.go`
- `apps/edge/internal/openai/server_test.go`
- `apps/edge/internal/openai/log_safety_test.go`
- Verification evidence from failed loop:
- `go test -count=1 ./apps/edge/internal/openai -run 'TestChatCompletions.*(Assembled|Passthrough|Sideband|Transformed)|TestLog'`: PASS
- `go test -count=1 ./apps/edge/internal/openai`: PASS
- `rg --sort path -n 'zap\.(String|ByteString|Any)\("(raw_body|provider_body|raw_chunk|body|content_preview|reasoning_preview|delta_preview)"' apps/edge/internal/openai`: output empty
- `git diff --check`: PASS
- Allowed archive reread: read only the archived plan/review paths above if exact prior wording is needed. Do not search `agent-task/archive/**`.
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-local-G06.md` -> `code_review_local_G06_N.log`, `PLAN-local-G06.md` -> `plan_local_G06_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-openai-compatible-raw-tunnel-sideband-passthrough/04_assembled_log/`로 이동한다. 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_OBSLOG-1] Idempotent Non-streaming Observation | [x] |
| [REVIEW_OBSLOG-2] S08 Log Evidence Hardening | [x] |
## 구현 체크리스트
- [x] `providerChatAssembler.observation()`을 idempotent하게 만들거나 non-streaming sideband END 처리에서 observation을 한 번만 계산해 envelope와 log에 재사용한다.
- [x] `apps/edge/internal/openai/server_test.go`의 assembled-log fixture를 확장해 `assembled_reasoning`, non-empty `assembled_tool_calls`, `assembled_tool_call_count`, non-streaming sideband response log/summary, transformed `response_mode=transformed` log를 assertion한다.
- [x] `apps/edge/internal/openai/log_safety_test.go` 또는 기존 forbidden-field 검증이 새 assembled log field 추가 후에도 raw provider body/chunk zap field를 금지하는지 확인하고 필요 시 보강한다.
- [x] `go test -count=1 ./apps/edge/internal/openai -run 'TestChatCompletions.*(Assembled|Passthrough|Sideband|Transformed)|TestLog'`를 통과시킨다.
- [x] `go test -count=1 ./apps/edge/internal/openai`를 통과시킨다.
- [x] `rg --sort path -n 'zap\.(String|ByteString|Any)\("(raw_body|provider_body|raw_chunk|body|content_preview|reasoning_preview|delta_preview)"' apps/edge/internal/openai`가 출력 없음임을 확인한다.
- [x] `git diff --check`를 통과시킨다.
- [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_local_G06_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_local_G06_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [x] PASS이면 active task 디렉터리 `agent-task/m-openai-compatible-raw-tunnel-sideband-passthrough/04_assembled_log/`를 `agent-task/archive/YYYY/MM/m-openai-compatible-raw-tunnel-sideband-passthrough/04_assembled_log/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-openai-compatible-raw-tunnel-sideband-passthrough/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-{build_lane}-GNN.md`와 `CODE_REVIEW-{review_lane}-GNN.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로 이동한다.
## 계획 대비 변경 사항
- 없음
## 주요 설계 결정
- `providerChatAssembler`에 `nonStreamingParsed` boolean flag 필드를 사용하여, non-streaming observation이 처음 호출될 때 한 번만 body parsing을 수행하고 이후 재호출 시에는 캐시된 결과를 그대로 반환하게 하여 idempotency를 보장하였습니다.
- `server_test.go`에서 `TestChatCompletionsAssembledLogsSidebandNonStreaming`, `TestChatCompletionsAssembledLogsPassthrough`, `TestChatCompletionsAssembledLogsSidebandStream` 등의 테스트 케이스를 확장하여 `assembled_reasoning`, non-empty `assembled_tool_calls`, `assembled_tool_call_count` 및 sideband response의 route/usage envelope와 SSE sideband 이벤트를 직접 검증하도록 테스트 커버리지를 보강하였습니다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- `providerChatAssembler.observation()` 또는 sideband response END 처리에서 non-streaming assembled output이 여러 번 누적되지 않는지 확인한다.
- SDD S08 evidence가 content, reasoning, tool call names/count, sideband summary, transformed output label을 실행 로그에서 직접 검증하는지 확인한다.
- Raw provider body/chunk payload가 zap field로 저장되지 않는지 확인한다.
## 검증 결과
### REVIEW_OBSLOG-1 중간 검증
```bash
$ go test -count=1 ./apps/edge/internal/openai -run 'TestChatCompletions.*(Assembled|Sideband)'
ok iop/apps/edge/internal/openai 0.006s
```
### REVIEW_OBSLOG-2 중간 검증
```bash
$ go test -count=1 ./apps/edge/internal/openai -run 'TestChatCompletions.*(Assembled|Passthrough|Sideband|Transformed)|TestLog'
ok iop/apps/edge/internal/openai 0.017s
```
### 최종 검증
```bash
$ go test -count=1 ./apps/edge/internal/openai -run 'TestChatCompletions.*(Assembled|Passthrough|Sideband|Transformed)|TestLog'
ok iop/apps/edge/internal/openai 0.017s
$ go test -count=1 ./apps/edge/internal/openai
ok iop/apps/edge/internal/openai 1.580s
$ rg --sort path -n 'zap\.(String|ByteString|Any)\("(raw_body|provider_body|raw_chunk|body|content_preview|reasoning_preview|delta_preview)"' apps/edge/internal/openai
(출력 없음 - Exit Code 1)
$ git diff --check
(출력 없음)
```
---
> **[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
- 발견된 문제: 없음
- 다음 단계: PASS 종결. active plan/review를 로그로 아카이브하고 `complete.log` 작성 후 task directory를 archive로 이동한다.

View file

@ -0,0 +1,45 @@
# Complete - m-openai-compatible-raw-tunnel-sideband-passthrough/04_assembled_log
## 완료 일시
2026-07-08
## 요약
OpenAI-compatible assembled execution log follow-up을 2회 루프에서 PASS로 종결했다.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_local_G06_0.log` | `code_review_local_G06_0.log` | FAIL | non-streaming assembler idempotency와 SDD S08 log evidence 보강 필요 |
| `plan_local_G06_1.log` | `code_review_local_G06_1.log` | PASS | assembled content/reasoning/tool_calls, sideband summary, transformed log evidence 검증 완료 |
## 구현/정리 내용
- `providerChatAssembler.observation()`의 non-streaming parse를 1회로 제한해 envelope/log 이중 호출 시 assembled content, reasoning, tool call name이 중복 누적되지 않게 했다.
- OpenAI-compatible Chat Completions assembled log fixture를 content, reasoning, non-empty tool call names/count, sideband non-stream/stream summary, transformed response mode까지 검증하도록 보강했다.
- forbidden log field 검증에 raw provider body/chunk 계열 zap field를 추가해 raw payload 저장 회귀를 막았다.
## 최종 검증
- `go test -count=1 ./apps/edge/internal/openai -run 'TestChatCompletions.*(Assembled|Passthrough|Sideband|Transformed)|TestLog'` - PASS; `ok iop/apps/edge/internal/openai 0.012s`
- `go test -count=1 ./apps/edge/internal/openai` - PASS; `ok iop/apps/edge/internal/openai 1.580s`
- `rg --sort path -n 'zap\.(String|ByteString|Any)\("(raw_body|provider_body|raw_chunk|body|content_preview|reasoning_preview|delta_preview)"' apps/edge/internal/openai` - PASS; 출력 없음, exit 1
- `git diff --check` - PASS; 출력 없음
## Roadmap Completion
- Milestone: `agent-roadmap/phase/routing-policy-model-orchestration/milestones/openai-compatible-raw-tunnel-sideband-passthrough.md`
- Milestone link: [Milestone 문서](agent-roadmap/phase/routing-policy-model-orchestration/milestones/openai-compatible-raw-tunnel-sideband-passthrough.md)
- Completed task ids:
- `assembled-log`: PASS; evidence=`agent-task/archive/2026/07/m-openai-compatible-raw-tunnel-sideband-passthrough/04_assembled_log/plan_local_G06_1.log`, `agent-task/archive/2026/07/m-openai-compatible-raw-tunnel-sideband-passthrough/04_assembled_log/code_review_local_G06_1.log`; verification=`go test -count=1 ./apps/edge/internal/openai -run 'TestChatCompletions.*(Assembled|Passthrough|Sideband|Transformed)|TestLog'`, `go test -count=1 ./apps/edge/internal/openai`, raw zap field `rg`, `git diff --check`
- Not completed task ids: 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -0,0 +1,160 @@
<!-- task=m-openai-compatible-raw-tunnel-sideband-passthrough/04_assembled_log plan=0 tag=OBSLOG -->
# Plan - OBSLOG
## 이 파일을 읽는 구현 에이전트에게
구현 후 `CODE_REVIEW-local-G06.md`의 구현 에이전트 소유 섹션을 반드시 실제 구현 내용과 검증 출력으로 채운다. 검증을 실행하고, active 파일은 그대로 두고, 리뷰 준비 상태를 보고한다. 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단하면 review stub의 `사용자 리뷰 요청` 섹션에 정확한 근거를 적고 멈춘다. 구현 중 사용자에게 직접 묻거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 환경/secret/service blocker, 일반 범위 변경, evidence gap은 사용자 리뷰 요청이 아니라 검증 결과나 후속 plan으로 처리한다.
## 배경
현재 raw tunnel은 provider bytes를 passthrough하고 sideband assembler도 갖고 있지만, pure passthrough와 transformed/normalized path의 실행 로그에는 assembled output observation이 충분히 남지 않는다. Milestone S08은 raw chunk payload를 기본 저장하지 않으면서 readable assembled content, reasoning, tool call, sideband summary를 검증 가능한 fixture로 남기는 것을 요구한다.
## 사용자 리뷰 요청 흐름
사용자 리뷰 요청은 선택된 Milestone lock decision이 구현을 차단할 때만 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. direct chat prompt는 금지이며, code-review가 이 요청을 검증하고 필요한 경우에만 실제 `USER_REVIEW.md`를 작성한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/routing-policy-model-orchestration/milestones/openai-compatible-raw-tunnel-sideband-passthrough.md`
- Milestone link: [Milestone 문서](agent-roadmap/phase/routing-policy-model-orchestration/milestones/openai-compatible-raw-tunnel-sideband-passthrough.md)
- Task ids:
- `assembled-log`: 실행 로그에는 raw chunk payload 대신 assembled `content`, `reasoning`, `tool_calls`, sideband summary, transformed output을 남긴다.
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `agent-roadmap/current.md`
- `agent-roadmap/phase/routing-policy-model-orchestration/milestones/openai-compatible-raw-tunnel-sideband-passthrough.md`
- `agent-roadmap/sdd/routing-policy-model-orchestration/openai-compatible-raw-tunnel-sideband-passthrough/SDD.md`
- `agent-ops/rules/project/domain/edge/rules.md`
- `agent-ops/rules/project/domain/node/rules.md`
- `agent-ops/rules/project/domain/testing/rules.md`
- `agent-test/local/rules.md`
- `agent-test/local/edge-smoke.md`
- `agent-test/local/node-smoke.md`
- `apps/edge/internal/openai/chat_handler.go`
- `apps/edge/internal/openai/stream.go`
- `apps/edge/internal/openai/run_result.go`
- `apps/edge/internal/openai/log_safety_test.go`
- `apps/edge/internal/openai/server_test.go`
- `apps/node/internal/store/store.go`
- `apps/node/internal/store/store_test.go`
### SDD 기준
- SDD: `agent-roadmap/sdd/routing-policy-model-orchestration/openai-compatible-raw-tunnel-sideband-passthrough/SDD.md`
- 상태: `[승인됨]`, SDD 잠금 `해제`, 사용자 리뷰 없음.
- Target scenario: S08 -> Milestone Task `assembled-log`.
- Evidence Map row: execution log fixture에서 assembled content/reasoning/tool_calls 확인과 raw chunk 미저장 확인. 구현 체크리스트와 검증은 이 row를 그대로 따라 `apps/edge/internal/openai` log fixture와 raw payload negative test를 요구한다.
### 테스트 환경 규칙
- test_env: local.
- 읽은 규칙: `agent-test/local/rules.md`, `agent-test/local/edge-smoke.md`, `agent-test/local/node-smoke.md`.
- 적용 명령: local Go package tests 우선. `apps/edge/internal/openai` 변경이므로 `go test -count=1 ./apps/edge/internal/openai`를 필수로 둔다.
- 원격/field preflight: 이 plan은 local source/test 구현만 대상으로 하므로 원격 runner preflight는 사용하지 않는다. 외부 provider runtime smoke는 code-review가 남은 위험으로 보고할 수 있으나 PASS 조건은 S08 local fixture 충족이다.
### 테스트 커버리지 공백
- Pure passthrough: 현재 `TestChatCompletionsPassthroughDoesNotExposeSideband`는 sideband 부재와 byte identity를 검증하지만 assembled 실행 로그를 보지 않는다.
- Sideband: 현재 sideband response fixture는 assembled observation을 response body에서 확인하지만 실행 로그 fixture와 raw payload 미저장을 별도 검증하지 않는다.
- Transformed/normalized: 현재 `TestChatCompletionsTransformedModeLabelsIOPOutput`는 label/path만 확인하고 transformed output observation log를 보지 않는다.
- Log safety: 현재 `log_safety_test.go`는 preview field 부재를 검증하므로 새 assembled log는 raw chunk/full body leak 없이 구조화되어야 한다.
### 심볼 참조
- renamed/removed symbols: none.
### 분할 판단
- split policy 평가 완료.
- 공유 task group: `agent-task/m-openai-compatible-raw-tunnel-sideband-passthrough/`.
- sibling plan:
- `04_assembled_log`: 이 plan, `assembled-log` 전용.
- `05_backpressure_cancel`: 별도 plan, `backpressure-cancel` 전용.
- 의존 관계: 없음. 두 작업은 로그/관측과 flow-control 검증으로 소유 경계와 실패 양상이 다르다.
### 범위 결정 근거
- 포함: `apps/edge/internal/openai`의 assembled observation logging, log safety tests, focused OpenAI handler fixtures.
- 제외: Node SQLite schema migration. SDD S08은 raw chunk 장기 저장 금지가 핵심이고 현재 Node store는 output payload column이 없으므로 schema 변경 없이 negative evidence로 충분하다.
- 제외: provider runtime field smoke. 외부 provider endpoint가 없어도 S08은 deterministic tunnel frame fixture로 검증 가능하다.
### 빌드 등급
- `local-G06`: bounded edge/openai observability change이며 local unit/log fixtures로 검증 가능하지만 log-safety 정책과 passthrough/sideband/transformed 세 경로를 함께 다룬다.
## 구현 체크리스트
- [ ] `providerChatAssembler` 또는 새 helper를 통해 pure passthrough, sideband, transformed/normalized output에서 content/reasoning/tool call summary를 일관된 observation으로 만들고 raw provider chunk/body를 zap field로 저장하지 않는다.
- [ ] `apps/edge/internal/openai/server_test.go`에 `assembled-log` fixture를 추가해 readable assembled summary, tool call names/count, sideband summary, transformed output label을 확인한다.
- [ ] `apps/edge/internal/openai/log_safety_test.go`를 갱신해 새 log line이 raw chunk payload, provider body, preview field를 남기지 않는다는 negative assertion을 추가한다.
- [ ] `go test -count=1 ./apps/edge/internal/openai -run 'TestChatCompletions.*(Assembled|Passthrough|Sideband|Transformed)|TestLog'`를 통과시킨다.
- [ ] `go test -count=1 ./apps/edge/internal/openai`를 통과시킨다.
- [ ] `git diff --check`를 통과시킨다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [OBSLOG-1] Assembled Observation Logging
문제: [stream.go](/config/workspace/iop/apps/edge/internal/openai/stream.go:414)의 pure passthrough close log는 `body_bytes`만 남기고, [chat_handler.go](/config/workspace/iop/apps/edge/internal/openai/chat_handler.go:452)의 normalized output log도 content/reasoning length 중심이다. S08의 assembled output evidence가 없다.
해결 방법: provider response parsing은 기존 `providerChatAssembler`를 재사용하거나 작은 공통 observation helper로 분리한다. Pure passthrough에서는 body bytes를 caller에게 그대로 쓰면서 assembler에는 관찰만 시킨다. Transformed/normalized path는 `chatCompletionOutput`에서 assembled summary를 만들되 raw content 전문을 zap field로 저장하지 말고, readable-but-safe policy를 명시한다. 기존 log safety가 full preview를 금지하므로 full text 저장이 필요하다고 판단되면 code-review 전에 Milestone lock decision이 아니라 일반 follow-up risk로 기록한다.
수정 파일 및 체크리스트:
- [ ] `apps/edge/internal/openai/stream.go`: passthrough/sideband close log에 assembled summary fields 추가. raw `body`/`provider_body` payload field 금지.
- [ ] `apps/edge/internal/openai/chat_handler.go`: transformed/normalized output log에 response mode와 assembled/tool call observation 추가.
- [ ] `apps/edge/internal/openai/run_result.go`: 필요 시 observation DTO helper만 추가하고 public contract는 바꾸지 않는다.
테스트 작성: 작성. `server_test.go`에 pure passthrough, sideband, transformed fixture를 추가하거나 기존 fixture를 확장한다.
중간 검증:
```bash
go test -count=1 ./apps/edge/internal/openai -run 'TestChatCompletions.*(Assembled|Passthrough|Sideband|Transformed)'
```
### [OBSLOG-2] Raw Payload Negative Guard
문제: [log_safety_test.go](/config/workspace/iop/apps/edge/internal/openai/log_safety_test.go:16)는 preview field 부재를 검증하지만 raw provider chunk/body를 저장하지 않는다는 S08 전용 assertion이 없다.
해결 방법: 새 log messages와 fields를 observer 기반 fixture에 추가하고, `raw_body`, `provider_body`, `body`, `delta_preview`, `content_preview`, `reasoning_preview` 같은 payload/preview field 금지를 검증한다. `body_bytes`, `content_len`, `reasoning_len`, `tool_call_count`, `tool_call_names` 같은 operational fields는 허용한다.
수정 파일 및 체크리스트:
- [ ] `apps/edge/internal/openai/log_safety_test.go`: 새 assembled log line fixture와 forbidden field 목록 추가.
- [ ] 필요하면 `rg --sort path` 검증 명령으로 production zap payload field를 보조 확인한다.
테스트 작성: 작성. Log safety regression test가 이 plan의 핵심 acceptance evidence다.
중간 검증:
```bash
go test -count=1 ./apps/edge/internal/openai -run 'TestLog'
```
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `apps/edge/internal/openai/stream.go` | OBSLOG-1 |
| `apps/edge/internal/openai/chat_handler.go` | OBSLOG-1 |
| `apps/edge/internal/openai/run_result.go` | OBSLOG-1 |
| `apps/edge/internal/openai/server_test.go` | OBSLOG-1 |
| `apps/edge/internal/openai/log_safety_test.go` | OBSLOG-2 |
## 최종 검증
```bash
go test -count=1 ./apps/edge/internal/openai -run 'TestChatCompletions.*(Assembled|Passthrough|Sideband|Transformed)|TestLog'
go test -count=1 ./apps/edge/internal/openai
rg --sort path -n 'zap\.(String|ByteString|Any)\("(raw_body|provider_body|raw_chunk|body|content_preview|reasoning_preview|delta_preview)"' apps/edge/internal/openai
git diff --check
```
`rg` 명령은 forbidden raw payload zap field가 나오지 않아야 한다. 출력이 있으면 필드명을 operational summary로 바꾸거나 test exception을 명확히 해야 한다.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,107 @@
<!-- task=m-openai-compatible-raw-tunnel-sideband-passthrough/04_assembled_log plan=1 tag=REVIEW_OBSLOG -->
# Plan - REVIEW_OBSLOG
## 이 파일을 읽는 구현 에이전트에게
이 follow-up은 이전 코드리뷰 `FAIL`의 Required 2건만 닫는다. 구현 후 `CODE_REVIEW-local-G06.md`의 구현 에이전트 소유 섹션을 반드시 실제 구현 내용과 검증 출력으로 채운다. 검증을 실행하고, active 파일은 그대로 두고, 리뷰 준비 상태를 보고한다.
선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단하면 review stub의 `사용자 리뷰 요청` 섹션에 정확한 근거를 적고 멈춘다. 구현 중 사용자에게 직접 묻거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 환경/secret/service blocker, 일반 범위 변경, evidence gap은 사용자 리뷰 요청이 아니라 검증 결과나 후속 plan으로 처리한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/routing-policy-model-orchestration/milestones/openai-compatible-raw-tunnel-sideband-passthrough.md`
- Milestone link: [Milestone 문서](agent-roadmap/phase/routing-policy-model-orchestration/milestones/openai-compatible-raw-tunnel-sideband-passthrough.md)
- Task ids:
- `assembled-log`: 실행 로그에는 raw chunk payload 대신 assembled `content`, `reasoning`, `tool_calls`, sideband summary, transformed output을 남긴다.
- Completion mode: check-on-pass
## Archive Evidence Snapshot
- Archived plan: `agent-task/m-openai-compatible-raw-tunnel-sideband-passthrough/04_assembled_log/plan_local_G06_0.log`
- Archived review: `agent-task/m-openai-compatible-raw-tunnel-sideband-passthrough/04_assembled_log/code_review_local_G06_0.log`
- Verdict: FAIL
- Issue summary: Required 2, Suggested 0, Nit 0
- Required findings:
- `apps/edge/internal/openai/stream.go:668`: non-streaming `providerChatAssembler.observation()` mutates builder state on every call; `stream.go:928` and `stream.go:940` call it twice in sideband response, so execution log can duplicate assembled content/reasoning/tool names.
- `apps/edge/internal/openai/server_test.go:5588`: assembled-log fixtures only check content and zero tool count; SDD S08 evidence still needs reasoning, non-empty tool call names/count, sideband log/summary, and transformed response-mode log assertions.
- Affected files:
- `apps/edge/internal/openai/stream.go`
- `apps/edge/internal/openai/server_test.go`
- `apps/edge/internal/openai/log_safety_test.go`
- Verification evidence from failed loop:
- `go test -count=1 ./apps/edge/internal/openai -run 'TestChatCompletions.*(Assembled|Passthrough|Sideband|Transformed)|TestLog'`: PASS
- `go test -count=1 ./apps/edge/internal/openai`: PASS
- `rg --sort path -n 'zap\.(String|ByteString|Any)\("(raw_body|provider_body|raw_chunk|body|content_preview|reasoning_preview|delta_preview)"' apps/edge/internal/openai`: output empty
- `git diff --check`: PASS
- Allowed archive reread: read only the archived plan/review paths above if exact prior wording is needed. Do not search `agent-task/archive/**`.
## 범위 결정 근거
포함 범위는 S08 assembled execution log evidence를 정확히 닫는 최소 수정이다. 이미 추가된 raw tunnel/sideband/transformed routing 자체, 계약/spec/docs/config 변경, provider-pool dispatch, backpressure/cancel은 이 follow-up 범위가 아니다.
## 구현 체크리스트
- [ ] `providerChatAssembler.observation()`을 idempotent하게 만들거나 non-streaming sideband END 처리에서 observation을 한 번만 계산해 envelope와 log에 재사용한다.
- [ ] `apps/edge/internal/openai/server_test.go`의 assembled-log fixture를 확장해 `assembled_reasoning`, non-empty `assembled_tool_calls`, `assembled_tool_call_count`, non-streaming sideband response log/summary, transformed `response_mode=transformed` log를 assertion한다.
- [ ] `apps/edge/internal/openai/log_safety_test.go` 또는 기존 forbidden-field 검증이 새 assembled log field 추가 후에도 raw provider body/chunk zap field를 금지하는지 확인하고 필요 시 보강한다.
- [ ] `go test -count=1 ./apps/edge/internal/openai -run 'TestChatCompletions.*(Assembled|Passthrough|Sideband|Transformed)|TestLog'`를 통과시킨다.
- [ ] `go test -count=1 ./apps/edge/internal/openai`를 통과시킨다.
- [ ] `rg --sort path -n 'zap\.(String|ByteString|Any)\("(raw_body|provider_body|raw_chunk|body|content_preview|reasoning_preview|delta_preview)"' apps/edge/internal/openai`가 출력 없음임을 확인한다.
- [ ] `git diff --check`를 통과시킨다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [REVIEW_OBSLOG-1] Idempotent Non-streaming Observation
문제: non-streaming assembler observation이 호출마다 내부 state를 다시 누적한다. Sideband non-streaming response는 envelope 작성과 log 작성에서 observation을 두 번 호출하므로 로그가 실제 provider assembled output보다 크게 기록될 수 있다.
해결 방법: 둘 중 더 작은 변경을 선택한다.
- `providerChatAssembler`에 non-streaming parse 완료 상태를 두어 `observation()`을 여러 번 호출해도 같은 값을 반환하게 한다.
- 또는 `writeProviderTunnelSidebandResponse` END 처리에서 `obs := assembler.observation()`을 한 번만 계산하고 envelope와 log가 같은 `obs`를 재사용하게 한다. 단, 다른 caller가 나중에 같은 문제를 만들지 않도록 함수 idempotence가 더 선호된다.
테스트 작성: `TestChatCompletionsAssembledLogsPassthrough` 또는 별도 non-streaming sideband log test에서 provider body에 content/reasoning/tool call을 넣고, log `assembled_content`와 `assembled_reasoning`이 중복되지 않는지 확인한다.
중간 검증:
```bash
go test -count=1 ./apps/edge/internal/openai -run 'TestChatCompletions.*(Assembled|Sideband)'
```
### [REVIEW_OBSLOG-2] S08 Log Evidence Hardening
문제: 현재 assembled-log fixture는 content와 zero tool count 중심이라 SDD S08의 reasoning/tool_calls/transformed output evidence를 충분히 검증하지 못한다.
해결 방법: 기존 fixture를 확장하거나 새 fixture를 추가한다.
- normalized/transformed path: `metadata.iop_response_mode="transformed"` 요청의 log에서 `response_mode=transformed`, `assembled_content`, 필요 시 `assembled_reasoning`을 확인한다.
- pure passthrough 또는 sideband path: provider body/SSE에 `reasoning_content`와 `tool_calls[].function.name`을 넣고 log의 `assembled_reasoning`, `assembled_tool_calls`, `assembled_tool_call_count`를 확인한다.
- sideband path: streaming 또는 non-streaming sideband response가 route/usage/assembled summary를 response surface와 execution log 양쪽에서 검증 가능한 상태인지 확인한다.
- raw payload guard: forbidden zap field 목록에는 raw provider body/chunk field가 남지 않아야 하며, assembled fields는 `assembled_*` 이름으로만 남긴다.
테스트 작성: 작성. 테스트명은 기존 `TestChatCompletionsAssembledLogs*` 계열을 유지해 final `-run` 정규식에 포함되게 한다.
중간 검증:
```bash
go test -count=1 ./apps/edge/internal/openai -run 'TestChatCompletions.*(Assembled|Passthrough|Sideband|Transformed)|TestLog'
```
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `apps/edge/internal/openai/stream.go` | REVIEW_OBSLOG-1 |
| `apps/edge/internal/openai/server_test.go` | REVIEW_OBSLOG-1, REVIEW_OBSLOG-2 |
| `apps/edge/internal/openai/log_safety_test.go` | REVIEW_OBSLOG-2 |
## 최종 검증
```bash
go test -count=1 ./apps/edge/internal/openai -run 'TestChatCompletions.*(Assembled|Passthrough|Sideband|Transformed)|TestLog'
go test -count=1 ./apps/edge/internal/openai
rg --sort path -n 'zap\.(String|ByteString|Any)\("(raw_body|provider_body|raw_chunk|body|content_preview|reasoning_preview|delta_preview)"' apps/edge/internal/openai
git diff --check
```
`rg` 명령은 출력이 없어야 한다. 출력이 있으면 raw payload zap field를 operational summary field로 바꾸거나 테스트의 금지 목록을 정확히 갱신한다.

View file

@ -0,0 +1,138 @@
<!-- task=m-openai-compatible-raw-tunnel-sideband-passthrough/05_backpressure_cancel plan=0 tag=FLOWCTRL -->
# Code Review Reference - FLOWCTRL
> **[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 linked evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Environment/secret/service blockers, generic scope changes, repeated failures, 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 Milestone lock decisions 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-08
task=m-openai-compatible-raw-tunnel-sideband-passthrough/05_backpressure_cancel, plan=0, tag=FLOWCTRL
## Roadmap Targets
- Milestone: `agent-roadmap/phase/routing-policy-model-orchestration/milestones/openai-compatible-raw-tunnel-sideband-passthrough.md`
- Milestone link: [Milestone 문서](agent-roadmap/phase/routing-policy-model-orchestration/milestones/openai-compatible-raw-tunnel-sideband-passthrough.md)
- Task ids:
- `backpressure-cancel`: tunnel path는 기존 socket 위에서 ordered, lossless, backpressure-aware stream을 유지하고 cancel/control frame을 지연시키지 않는다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-local-G08.md` -> `code_review_local_G08_N.log`, `PLAN-local-G08.md` -> `plan_local_G08_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-openai-compatible-raw-tunnel-sideband-passthrough/05_backpressure_cancel/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [FLOWCTRL-1] Edge HTTP Tunnel Cancel And Ordering | [ ] |
| [FLOWCTRL-2] Service Tunnel Backpressure | [ ] |
| [FLOWCTRL-3] Node Provider Cancellation | [ ] |
## 구현 체크리스트
- [ ] `writeProviderTunnelResponse`, `writeProviderTunnelSidebandStream`, `writeProviderTunnelSidebandResponse`가 caller disconnect, write failure, timeout에서 exactly-once cancel/close를 수행하는지 보강한다.
- [ ] `providerTunnelRouter``openProviderTunnel`의 full-buffer/backpressure와 terminal release 동작을 focused service test로 검증한다.
- [ ] `openai_compat``vllm` tunnel provider cancellation/body error tests가 ordered ERROR/END semantics와 upstream request cancellation을 충분히 검증하도록 보강한다.
- [ ] slow client/write failure/provider error fixture에서 chunk drop/reorder가 없고 cancel/control frame이 지연되지 않는지 검증한다.
- [ ] `go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/service`를 통과시킨다.
- [ ] `go test -count=1 ./apps/node/internal/adapters/openai_compat ./apps/node/internal/adapters/vllm`를 통과시킨다.
- [ ] `go test -count=1 -race ./apps/edge/internal/openai ./apps/edge/internal/service`를 통과시킨다.
- [ ] `git diff --check`를 통과시킨다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [ ] `코드리뷰 결과``PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [ ] active `CODE_REVIEW-*-G??.md``code_review_local_G08_N.log`로 아카이브한다.
- [ ] active `PLAN-*-G??.md``plan_local_G08_M.log`로 아카이브한다.
- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md``agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/m-openai-compatible-raw-tunnel-sideband-passthrough/05_backpressure_cancel/``agent-task/archive/YYYY/MM/m-openai-compatible-raw-tunnel-sideband-passthrough/05_backpressure_cancel/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-openai-compatible-raw-tunnel-sideband-passthrough/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-{build_lane}-GNN.md``CODE_REVIEW-{review_lane}-GNN.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로 이동한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- Slow/write-failure/provider-error tests가 S09 Evidence Map에 직접 대응하는지 확인한다.
- Tunnel router full-buffer behavior가 drop이 아니라 block/release임을 검증하는지 확인한다.
- CancelRun이 caller disconnect/write failure/timeout에서 중복 없이 전파되는지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
### FLOWCTRL-1 중간 검증
```bash
$ go test -count=1 ./apps/edge/internal/openai -run 'TestChatCompletionsPassthrough.*(Cancel|WriteFailure|ProviderError|ByteIdentity)|TestChatCompletionsPassthroughSideband.*(Cancel|WriteFailure)'
```
### FLOWCTRL-2 중간 검증
```bash
$ go test -count=1 ./apps/edge/internal/service -run 'TestProviderTunnel.*(Backpressure|Release|Close)'
```
### FLOWCTRL-3 중간 검증
```bash
$ go test -count=1 ./apps/node/internal/adapters/openai_compat ./apps/node/internal/adapters/vllm -run 'Test(OpenAICompat|Vllm)TunnelProvider'
```
### 최종 검증
```bash
$ go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/service
$ go test -count=1 ./apps/node/internal/adapters/openai_compat ./apps/node/internal/adapters/vllm
$ go test -count=1 -race ./apps/edge/internal/openai ./apps/edge/internal/service
$ git diff --check
```
---
> **[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.

View file

@ -0,0 +1,181 @@
<!-- task=m-openai-compatible-raw-tunnel-sideband-passthrough/05_backpressure_cancel plan=0 tag=FLOWCTRL -->
# Plan - FLOWCTRL
## 이 파일을 읽는 구현 에이전트에게
구현 후 `CODE_REVIEW-local-G08.md`의 구현 에이전트 소유 섹션을 반드시 실제 구현 내용과 검증 출력으로 채운다. 검증을 실행하고, active 파일은 그대로 두고, 리뷰 준비 상태를 보고한다. 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단하면 review stub의 `사용자 리뷰 요청` 섹션에 정확한 근거를 적고 멈춘다. 구현 중 사용자에게 직접 묻거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 환경/secret/service blocker, 일반 범위 변경, evidence gap은 사용자 리뷰 요청이 아니라 검증 결과나 후속 plan으로 처리한다.
## 배경
Raw tunnel은 provider body bytes를 기존 Edge-Node socket으로 전달한다. 현재 코드에는 request-bound tunnel router, channel backpressure, HTTP caller cancel 전파, Node adapter context cancellation이 부분적으로 있지만, slow client/write failure/provider error/ordered stream을 S09 evidence로 한 번에 묶는 검증이 부족하다.
## 사용자 리뷰 요청 흐름
사용자 리뷰 요청은 선택된 Milestone lock decision이 구현을 차단할 때만 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. direct chat prompt는 금지이며, code-review가 이 요청을 검증하고 필요한 경우에만 실제 `USER_REVIEW.md`를 작성한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/routing-policy-model-orchestration/milestones/openai-compatible-raw-tunnel-sideband-passthrough.md`
- Milestone link: [Milestone 문서](agent-roadmap/phase/routing-policy-model-orchestration/milestones/openai-compatible-raw-tunnel-sideband-passthrough.md)
- Task ids:
- `backpressure-cancel`: tunnel path는 기존 socket 위에서 ordered, lossless, backpressure-aware stream을 유지하고 cancel/control frame을 지연시키지 않는다.
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `agent-roadmap/current.md`
- `agent-roadmap/phase/routing-policy-model-orchestration/milestones/openai-compatible-raw-tunnel-sideband-passthrough.md`
- `agent-roadmap/sdd/routing-policy-model-orchestration/openai-compatible-raw-tunnel-sideband-passthrough/SDD.md`
- `agent-ops/rules/project/domain/edge/rules.md`
- `agent-ops/rules/project/domain/node/rules.md`
- `agent-ops/rules/project/domain/testing/rules.md`
- `agent-test/local/rules.md`
- `agent-test/local/edge-smoke.md`
- `agent-test/local/node-smoke.md`
- `apps/edge/internal/openai/stream.go`
- `apps/edge/internal/openai/server_test.go`
- `apps/edge/internal/service/run_dispatch.go`
- `apps/edge/internal/service/model_queue_test.go`
- `apps/node/internal/adapters/openai_compat/openai_compat.go`
- `apps/node/internal/adapters/openai_compat/openai_compat_test.go`
- `apps/node/internal/adapters/vllm/vllm.go`
- `apps/node/internal/adapters/vllm/vllm_test.go`
- `apps/node/internal/node/node.go`
- `apps/node/internal/node/node_test.go`
### SDD 기준
- SDD: `agent-roadmap/sdd/routing-policy-model-orchestration/openai-compatible-raw-tunnel-sideband-passthrough/SDD.md`
- 상태: `[승인됨]`, SDD 잠금 `해제`, 사용자 리뷰 없음.
- Target scenario: S09 -> Milestone Task `backpressure-cancel`.
- Evidence Map row: slow client, cancel, provider error, socket backpressure test. 구현 체크리스트는 Edge HTTP writer, Edge service tunnel router, Node adapter cancellation tests를 모두 요구한다.
### 테스트 환경 규칙
- test_env: local.
- 읽은 규칙: `agent-test/local/rules.md`, `agent-test/local/edge-smoke.md`, `agent-test/local/node-smoke.md`.
- 적용 명령: local Go package tests, race test for Edge service/openai, Node adapter tunnel provider tests.
- 원격/field preflight: 이 plan은 deterministic local tests로 S09를 닫는다. 실제 provider field smoke는 별도 dev-runtime evidence로 보강 가능하지만 PASS 필수 조건은 아니다.
### 테스트 커버리지 공백
- Edge OpenAI: `TestChatCompletionsPassthroughContextCancelSendsCancelRun`은 pre-cancel case만 확인한다. write failure after body start, sideband writer cancel, slow frame consumption ordering이 부족하다.
- Edge service: tunnel router는 full buffer에서 block하도록 설계됐지만 backpressure/blocking release behavior의 focused test가 부족하다.
- Node adapters: `openai_compat``vllm`는 cancellation/body read error tests가 있으나 Edge write failure -> CancelRun -> upstream context cancel end-to-end fixture가 연결되어 있지 않다.
### 심볼 참조
- renamed/removed symbols: none.
### 분할 판단
- split policy 평가 완료.
- 공유 task group: `agent-task/m-openai-compatible-raw-tunnel-sideband-passthrough/`.
- sibling plan:
- `04_assembled_log`: `assembled-log`, 독립.
- `05_backpressure_cancel`: 이 plan, `backpressure-cancel` 전용.
- 의존 관계: 없음. 기존 archived `01_node_relay`, `02+01_edge_stream`, `03+02_response_modes_semantics`는 배경 evidence지만 이 subtask의 runtime predecessor는 아니다.
### 범위 결정 근거
- 포함: Edge OpenAI tunnel writers, Edge service tunnel router/release tests, Node provider tunnel cancellation/error tests.
- 제외: 새로운 transport, separate stream server, gRPC/WebSocket 전환. Milestone 범위 제외와 domain rule 금지 사항에 해당한다.
- 제외: raw payload persistence. Flow-control task는 payload storage를 추가하지 않는다.
### 빌드 등급
- `local-G08`: concurrency/backpressure/cancel path가 Edge HTTP, Edge service, Node adapters를 가로지르지만 deterministic local tests와 race test로 검증 가능하다.
## 구현 체크리스트
- [ ] `writeProviderTunnelResponse`, `writeProviderTunnelSidebandStream`, `writeProviderTunnelSidebandResponse`가 caller disconnect, write failure, timeout에서 exactly-once cancel/close를 수행하는지 보강한다.
- [ ] `providerTunnelRouter``openProviderTunnel`의 full-buffer/backpressure와 terminal release 동작을 focused service test로 검증한다.
- [ ] `openai_compat``vllm` tunnel provider cancellation/body error tests가 ordered ERROR/END semantics와 upstream request cancellation을 충분히 검증하도록 보강한다.
- [ ] slow client/write failure/provider error fixture에서 chunk drop/reorder가 없고 cancel/control frame이 지연되지 않는지 검증한다.
- [ ] `go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/service`를 통과시킨다.
- [ ] `go test -count=1 ./apps/node/internal/adapters/openai_compat ./apps/node/internal/adapters/vllm`를 통과시킨다.
- [ ] `go test -count=1 -race ./apps/edge/internal/openai ./apps/edge/internal/service`를 통과시킨다.
- [ ] `git diff --check`를 통과시킨다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [FLOWCTRL-1] Edge HTTP Tunnel Cancel And Ordering
문제: [stream.go](/config/workspace/iop/apps/edge/internal/openai/stream.go:401)는 context cancel/timeout/write error에서 cancel을 보내지만 write failure와 sideband/non-streaming buffering까지 S09로 묶는 검증이 부족하다.
해결 방법: fake `ResponseWriter` 또는 cancellable request fixture를 추가해 body write failure after response start, sideband writer write failure, timeout, provider ERROR before/after start를 검증한다. Body chunks는 sequence대로 caller에 쓰이고, error/cancel 후 추가 chunk가 쓰이지 않아야 한다.
수정 파일 및 체크리스트:
- [ ] `apps/edge/internal/openai/stream.go`: 필요 시 exactly-once close/cancel helper를 도입하되 response mode semantics는 유지한다.
- [ ] `apps/edge/internal/openai/server_test.go`: write failure, sideband cancel, provider error/order tests 추가.
테스트 작성: 작성. S09 직접 evidence다.
중간 검증:
```bash
go test -count=1 ./apps/edge/internal/openai -run 'TestChatCompletionsPassthrough.*(Cancel|WriteFailure|ProviderError|ByteIdentity)|TestChatCompletionsPassthroughSideband.*(Cancel|WriteFailure)'
```
### [FLOWCTRL-2] Service Tunnel Backpressure
문제: [run_dispatch.go](/config/workspace/iop/apps/edge/internal/service/run_dispatch.go:719)의 `providerTunnelRouter`는 full buffer에서 block하도록 설계됐지만, 이 behavior와 handle close release가 테스트로 고정되어 있지 않다.
해결 방법: service-level test를 추가해 subscriber channel이 가득 찬 동안 route goroutine이 drop 없이 block하고, `Close()` 또는 terminal frame에서 unblock/release하는지 확인한다. Provider-pool tracked tunnel은 END/ERROR/Close 각각에서 slot이 한 번만 release되어야 한다.
수정 파일 및 체크리스트:
- [ ] `apps/edge/internal/service/run_dispatch.go`: 필요 시 testability를 위한 작은 helper만 추가한다.
- [ ] `apps/edge/internal/service/run_dispatch_internal_test.go` 또는 `service_internal_test.go`: backpressure/release tests 추가.
테스트 작성: 작성. Concurrency path이므로 race-friendly deterministic channel test로 만든다.
중간 검증:
```bash
go test -count=1 ./apps/edge/internal/service -run 'TestProviderTunnel.*(Backpressure|Release|Close)'
```
### [FLOWCTRL-3] Node Provider Cancellation
문제: [openai_compat.go](/config/workspace/iop/apps/node/internal/adapters/openai_compat/openai_compat.go:80)와 [vllm.go](/config/workspace/iop/apps/node/internal/adapters/vllm/vllm.go:63)는 context 기반 provider request를 사용하지만 Edge cancel과 upstream request cancel의 S09 evidence가 adapter별로 충분히 묶여 있지 않다.
해결 방법: adapter tests에 server-side cancel observation, ordered sequence assertion, body read error 후 single ERROR terminal assertion을 강화한다. Existing tests를 확장하되 duplicated ERROR frame을 허용하지 않는다.
수정 파일 및 체크리스트:
- [ ] `apps/node/internal/adapters/openai_compat/openai_compat_test.go`: cancellation/body read error ordering tests 보강.
- [ ] `apps/node/internal/adapters/vllm/vllm_test.go`: cancellation/body read error ordering tests 보강.
테스트 작성: 작성. Existing `TunnelProvider_Cancel``TunnelProvider_BodyReadError`를 강화하거나 새 test를 추가한다.
중간 검증:
```bash
go test -count=1 ./apps/node/internal/adapters/openai_compat ./apps/node/internal/adapters/vllm -run 'Test(OpenAICompat|Vllm)TunnelProvider'
```
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `apps/edge/internal/openai/stream.go` | FLOWCTRL-1 |
| `apps/edge/internal/openai/server_test.go` | FLOWCTRL-1 |
| `apps/edge/internal/service/run_dispatch.go` | FLOWCTRL-2 |
| `apps/edge/internal/service/run_dispatch_internal_test.go` | FLOWCTRL-2 |
| `apps/node/internal/adapters/openai_compat/openai_compat_test.go` | FLOWCTRL-3 |
| `apps/node/internal/adapters/vllm/vllm_test.go` | FLOWCTRL-3 |
## 최종 검증
```bash
go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/service
go test -count=1 ./apps/node/internal/adapters/openai_compat ./apps/node/internal/adapters/vllm
go test -count=1 -race ./apps/edge/internal/openai ./apps/edge/internal/service
git diff --check
```
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -449,14 +449,20 @@ func (s *Server) completeChatCompletion(w http.ResponseWriter, r *http.Request,
return
}
handle.Close()
toolCallNames := extractToolCallNames(result.toolCalls)
s.logger.Info("openai chat completion output",
zap.String("run_id", handle.Dispatch().RunID),
zap.Bool("strict_output", outputPolicy.Strict),
zap.Bool("strict_stream_buffer", outputPolicy.StreamBuffer),
zap.String("xml_completion_tool", outputPolicy.XMLCompletionTool),
zap.Bool("normalized", result.normalized),
zap.String("response_mode", result.responseMode),
zap.Int("content_len", result.contentLen),
zap.Int("reasoning_len", result.reasoningLen),
zap.String("assembled_content", result.message.Content),
zap.String("assembled_reasoning", result.message.ReasoningContent),
zap.Strings("assembled_tool_calls", toolCallNames),
zap.Int("assembled_tool_call_count", len(toolCallNames)),
)
created := time.Now().Unix()
writeJSON(w, http.StatusOK, chatCompletionResponse{
@ -480,6 +486,7 @@ type chatCompletionOutput struct {
finishReason string
usage *openAIUsage
normalized bool
responseMode string
contentLen int
reasoningLen int
toolCalls []any
@ -532,11 +539,19 @@ func collectChatCompletionOutput(ctx context.Context, req chatCompletionRequest,
message.Content = hiddenReasoningFallbackContent(finishReason)
}
}
runMeta, _, _ := parseOpenAIMetadata(req.Metadata)
mode, _ := parseResponseMode(runMeta)
if mode != responseModeTransformed {
mode = "normalized"
}
return chatCompletionOutput{
message: message,
finishReason: finishReason,
usage: usage,
normalized: normalized,
responseMode: mode,
contentLen: len(message.Content),
reasoningLen: len(reasoning),
toolCalls: toolCalls,

View file

@ -109,9 +109,13 @@ func TestLogNoPreviewFields(t *testing.T) {
"reasoning_preview",
"source_preview",
"delta_preview",
"raw_body",
"provider_body",
"body",
"raw_chunk",
} {
if _, ok := fieldMap[name]; ok {
t.Errorf("log line %q unexpectedly contains preview field %q", entry.Message, name)
t.Errorf("log line %q unexpectedly contains forbidden field %q", entry.Message, name)
}
}
}
@ -245,9 +249,13 @@ func TestNoPreviewFieldsOnSensitiveValues(t *testing.T) {
"reasoning_preview",
"source_preview",
"delta_preview",
"raw_body",
"provider_body",
"body",
"raw_chunk",
} {
if _, ok := fieldMap[name]; ok {
t.Errorf("observed preview field %q in line %q", name, entry.Message)
t.Errorf("observed forbidden field %q in line %q", name, entry.Message)
}
}
}

View file

@ -114,3 +114,25 @@ func toolCallsFromRunMetadata(metadata map[string]string) []any {
func isTextToolFallback(metadata map[string]string) bool {
return strings.EqualFold(strings.TrimSpace(metadata[runtimeMetadataOpenAITextToolFallback]), "true")
}
func extractToolCallNames(toolCalls []any) []string {
var names []string
for _, tc := range toolCalls {
if tc == nil {
continue
}
b, err := json.Marshal(tc)
if err != nil {
continue
}
var parsed struct {
Function struct {
Name string `json:"name"`
} `json:"function"`
}
if err := json.Unmarshal(b, &parsed); err == nil && parsed.Function.Name != "" {
names = append(names, parsed.Function.Name)
}
}
return names
}

View file

@ -13,6 +13,9 @@ import (
"testing"
"time"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
edgeservice "iop/apps/edge/internal/service"
"iop/packages/go/config"
iop "iop/proto/gen/iop"
@ -5580,3 +5583,394 @@ func TestChatCompletionsMetadataIncludesTypedEstimateAndClassification(t *testin
t.Fatalf("ContextClass: got %q, want normal", fake.req.ContextClass)
}
}
func TestChatCompletionsAssembledLogs(t *testing.T) {
core, observed := observer.New(zap.DebugLevel)
logger := zap.New(core)
fake := &fakeRunService{events: make(chan *iop.RunEvent, 4)}
fake.events <- &iop.RunEvent{Type: "delta", Delta: "hello"}
fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "reasoning..."}
fake.events <- &iop.RunEvent{Type: "complete", Metadata: map[string]string{
"openai_tool_calls": `[{"id":"call_001","type":"function","function":{"name":"get_weather","arguments":"{}"}}]`,
}}
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, logger)
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
"model":"client-model",
"messages":[{"role":"user","content":"hi"}],
"tools":[{"type":"function","function":{"name":"get_weather"}}]
}`))
w := httptest.NewRecorder()
srv.handleChatCompletions(w, req)
found := false
for _, entry := range observed.All() {
if entry.Message == "openai chat completion output" {
found = true
m := entry.ContextMap()
if m["response_mode"] != "normalized" {
t.Errorf("expected response_mode=normalized, got %v", m["response_mode"])
}
if m["assembled_content"] != "hello" {
t.Errorf("expected assembled_content=hello, got %v", m["assembled_content"])
}
if m["assembled_reasoning"] != "reasoning..." {
t.Errorf("expected assembled_reasoning=reasoning..., got %v", m["assembled_reasoning"])
}
tc, ok := m["assembled_tool_calls"].([]interface{})
if !ok || len(tc) != 1 || tc[0] != "get_weather" {
t.Errorf("expected assembled_tool_calls=[get_weather], got %v", m["assembled_tool_calls"])
}
if count, ok := m["assembled_tool_call_count"].(int64); !ok || count != 1 {
t.Errorf("expected assembled_tool_call_count=1, got %v", m["assembled_tool_call_count"])
}
}
}
if !found {
t.Error("expected log message 'openai chat completion output' not found")
}
}
func TestChatCompletionsAssembledLogsTransformed(t *testing.T) {
core, observed := observer.New(zap.DebugLevel)
logger := zap.New(core)
fake := &fakeRunService{events: make(chan *iop.RunEvent, 4)}
fake.events <- &iop.RunEvent{Type: "delta", Delta: "hello transformed"}
fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "reasoning transformed..."}
fake.events <- &iop.RunEvent{Type: "complete", Metadata: map[string]string{
"openai_tool_calls": `[{"id":"call_002","type":"function","function":{"name":"run_code","arguments":"{}"}}]`,
}}
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, logger)
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
"model":"client-model",
"messages":[{"role":"user","content":"hi"}],
"tools":[{"type":"function","function":{"name":"run_code"}}],
"metadata":{"iop_response_mode":"transformed"}
}`))
w := httptest.NewRecorder()
srv.handleChatCompletions(w, req)
found := false
for _, entry := range observed.All() {
if entry.Message == "openai chat completion output" {
found = true
m := entry.ContextMap()
if m["response_mode"] != "transformed" {
t.Errorf("expected response_mode=transformed, got %v", m["response_mode"])
}
if m["assembled_content"] != "hello transformed" {
t.Errorf("expected assembled_content=hello transformed, got %v", m["assembled_content"])
}
if m["assembled_reasoning"] != "reasoning transformed..." {
t.Errorf("expected assembled_reasoning=reasoning transformed..., got %v", m["assembled_reasoning"])
}
tc, ok := m["assembled_tool_calls"].([]interface{})
if !ok || len(tc) != 1 || tc[0] != "run_code" {
t.Errorf("expected assembled_tool_calls=[run_code], got %v", m["assembled_tool_calls"])
}
if count, ok := m["assembled_tool_call_count"].(int64); !ok || count != 1 {
t.Errorf("expected assembled_tool_call_count=1, got %v", m["assembled_tool_call_count"])
}
}
}
if !found {
t.Error("expected log message 'openai chat completion output' not found")
}
}
func TestChatCompletionsAssembledLogsSidebandNonStreaming(t *testing.T) {
core, observed := observer.New(zap.DebugLevel)
logger := zap.New(core)
providerBody := `{"choices":[{"message":{"content":"non-stream content","reasoning_content":"non-stream reasoning","tool_calls":[{"function":{"name":"call_non_stream"}}]}}]}`
frames := make(chan *iop.ProviderTunnelFrame, 4)
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(providerBody)}
frames <- &iop.ProviderTunnelFrame{
Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_USAGE,
Usage: &iop.Usage{
InputTokens: 10,
OutputTokens: 20,
},
}
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END, End: true}
close(frames)
fake := &fakeRunService{tunnelFrames: frames}
catalog := []config.ModelCatalogEntry{
{ID: "pool-model", Providers: map[string]string{"prov-1": "served-model"}},
}
srv := NewServer(config.EdgeOpenAIConf{}, fake, logger)
srv.SetModelCatalog(catalog)
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
"model":"pool-model",
"messages":[{"role":"user","content":"hello"}],
"metadata":{"iop_response_mode":"passthrough+sideband"}
}`))
w := httptest.NewRecorder()
srv.handleChatCompletions(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d body=%s", w.Code, w.Body.String())
}
// response body check
var envelope struct {
Object string `json:"object"`
Sideband struct {
Route *struct {
ResponseMode string `json:"response_mode"`
} `json:"route"`
Usage *struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
} `json:"usage"`
Assembled *struct {
Content string `json:"content"`
Reasoning string `json:"reasoning"`
ToolCallNames []string `json:"tool_call_names"`
BodyBytes int `json:"body_bytes"`
} `json:"assembled"`
} `json:"iop_sideband"`
}
if err := json.Unmarshal(w.Body.Bytes(), &envelope); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if envelope.Sideband.Route == nil {
t.Fatalf("expected sideband.route to be non-nil")
}
if envelope.Sideband.Route.ResponseMode != "passthrough+sideband" {
t.Errorf("expected route response_mode 'passthrough+sideband', got %q", envelope.Sideband.Route.ResponseMode)
}
if envelope.Sideband.Usage == nil {
t.Fatalf("expected sideband.usage to be non-nil")
}
if envelope.Sideband.Usage.InputTokens != 10 || envelope.Sideband.Usage.OutputTokens != 20 {
t.Errorf("expected usage input=10 output=20, got input=%d output=%d", envelope.Sideband.Usage.InputTokens, envelope.Sideband.Usage.OutputTokens)
}
if envelope.Sideband.Assembled == nil {
t.Fatalf("expected sideband.assembled to be non-nil")
}
if envelope.Sideband.Assembled.Content != "non-stream content" {
t.Errorf("expected sideband content 'non-stream content', got %q", envelope.Sideband.Assembled.Content)
}
if envelope.Sideband.Assembled.Reasoning != "non-stream reasoning" {
t.Errorf("expected sideband reasoning 'non-stream reasoning', got %q", envelope.Sideband.Assembled.Reasoning)
}
if len(envelope.Sideband.Assembled.ToolCallNames) != 1 || envelope.Sideband.Assembled.ToolCallNames[0] != "call_non_stream" {
t.Errorf("expected sideband tool_call_names [call_non_stream], got %v", envelope.Sideband.Assembled.ToolCallNames)
}
found := false
for _, entry := range observed.All() {
if entry.Message == "openai chat completion sideband response" {
found = true
m := entry.ContextMap()
if m["assembled_content"] != "non-stream content" {
t.Errorf("expected assembled_content='non-stream content', got %v", m["assembled_content"])
}
if m["assembled_reasoning"] != "non-stream reasoning" {
t.Errorf("expected assembled_reasoning='non-stream reasoning', got %v", m["assembled_reasoning"])
}
tc, ok := m["assembled_tool_calls"].([]interface{})
if !ok || len(tc) != 1 || tc[0] != "call_non_stream" {
t.Errorf("expected assembled_tool_calls=[call_non_stream], got %v", m["assembled_tool_calls"])
}
if count, ok := m["assembled_tool_call_count"].(int64); !ok || count != 1 {
t.Errorf("expected assembled_tool_call_count=1, got %v", m["assembled_tool_call_count"])
}
}
}
if !found {
t.Error("expected log message 'openai chat completion sideband response' not found")
}
}
func TestChatCompletionsAssembledLogsPassthrough(t *testing.T) {
core, observed := observer.New(zap.DebugLevel)
logger := zap.New(core)
providerBody := `{"choices":[{"message":{"content":"passthrough content","reasoning_content":"passthrough reasoning","tool_calls":[{"function":{"name":"call_passthrough"}}]}}]}`
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(providerBody)}
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END, End: true}
close(frames)
fake := &fakeRunService{tunnelFrames: frames}
catalog := []config.ModelCatalogEntry{
{ID: "pool-model", Providers: map[string]string{"prov-1": "served-model"}},
}
srv := NewServer(config.EdgeOpenAIConf{}, fake, logger)
srv.SetModelCatalog(catalog)
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
"model":"pool-model",
"messages":[{"role":"user","content":"hello"}],
"metadata":{"iop_response_mode":"passthrough"}
}`))
w := httptest.NewRecorder()
srv.handleChatCompletions(w, req)
found := false
for _, entry := range observed.All() {
if entry.Message == "openai chat completion passthrough closed" {
found = true
m := entry.ContextMap()
if m["assembled_content"] != "passthrough content" {
t.Errorf("expected assembled_content='passthrough content', got %v", m["assembled_content"])
}
if m["assembled_reasoning"] != "passthrough reasoning" {
t.Errorf("expected assembled_reasoning='passthrough reasoning', got %v", m["assembled_reasoning"])
}
tc, ok := m["assembled_tool_calls"].([]interface{})
if !ok || len(tc) != 1 || tc[0] != "call_passthrough" {
t.Errorf("expected assembled_tool_calls=[call_passthrough], got %v", m["assembled_tool_calls"])
}
if count, ok := m["assembled_tool_call_count"].(int64); !ok || count != 1 {
t.Errorf("expected assembled_tool_call_count=1, got %v", m["assembled_tool_call_count"])
}
}
}
if !found {
t.Error("expected log message 'openai chat completion passthrough closed' not found")
}
}
func TestChatCompletionsAssembledLogsSidebandStream(t *testing.T) {
core, observed := observer.New(zap.DebugLevel)
logger := zap.New(core)
roleEvent := "data: {\"choices\":[{\"delta\":{\"role\":\"assistant\"}}]}\n\n"
contentEvent := "data: {\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\n"
reasoningEvent := "data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"stream reasoning\"}}]}\n\n"
toolCallEvent := "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"name\":\"call_stream\"}}]}}]}\n\n"
doneEvent := "data: [DONE]\n\n"
frames := make(chan *iop.ProviderTunnelFrame, 8)
frames <- &iop.ProviderTunnelFrame{
Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_RESPONSE_START,
StatusCode: 200,
Headers: map[string]string{"Content-Type": "text/event-stream"},
}
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_BODY, Body: []byte(roleEvent)}
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_BODY, Body: []byte(contentEvent)}
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_BODY, Body: []byte(reasoningEvent)}
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_BODY, Body: []byte(toolCallEvent)}
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_BODY, Body: []byte(doneEvent)}
frames <- &iop.ProviderTunnelFrame{
Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_USAGE,
Usage: &iop.Usage{
InputTokens: 15,
OutputTokens: 25,
},
}
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END, End: true}
close(frames)
fake := &fakeRunService{tunnelFrames: frames}
catalog := []config.ModelCatalogEntry{
{ID: "pool-model", Providers: map[string]string{"prov-1": "served-model"}},
}
srv := NewServer(config.EdgeOpenAIConf{}, fake, logger)
srv.SetModelCatalog(catalog)
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
"model":"pool-model",
"stream":true,
"messages":[{"role":"user","content":"hello"}],
"metadata":{"iop_response_mode":"passthrough+sideband"}
}`))
w := httptest.NewRecorder()
srv.handleChatCompletions(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d body=%s", w.Code, w.Body.String())
}
// Verify SSE observations from response surface
bodyStr := w.Body.String()
lines := strings.Split(bodyStr, "\n")
var parsedObs []sidebandObservation
for i := 0; i < len(lines); i++ {
if strings.HasPrefix(lines[i], "event: iop.sideband") {
if i+1 < len(lines) && strings.HasPrefix(lines[i+1], "data: ") {
dataJSON := strings.TrimPrefix(lines[i+1], "data: ")
var obs sidebandObservation
if err := json.Unmarshal([]byte(dataJSON), &obs); err == nil {
parsedObs = append(parsedObs, obs)
}
}
}
}
// We expect 3 sideband events: route, usage, assembled
if len(parsedObs) != 3 {
t.Errorf("expected 3 sideband events, got %d", len(parsedObs))
} else {
// Verify Route
if parsedObs[0].Kind != "route" || parsedObs[0].Route == nil {
t.Errorf("expected first sideband event to be route, got %+v", parsedObs[0])
} else if parsedObs[0].Route.ResponseMode != "passthrough+sideband" {
t.Errorf("expected route response_mode 'passthrough+sideband', got %q", parsedObs[0].Route.ResponseMode)
}
// Verify Usage
if parsedObs[1].Kind != "usage" || parsedObs[1].Usage == nil {
t.Errorf("expected second sideband event to be usage, got %+v", parsedObs[1])
} else if parsedObs[1].Usage.InputTokens != 15 || parsedObs[1].Usage.OutputTokens != 25 {
t.Errorf("expected usage input=15, output=25, got input=%d, output=%d", parsedObs[1].Usage.InputTokens, parsedObs[1].Usage.OutputTokens)
}
// Verify Assembled
if parsedObs[2].Kind != "assembled" || parsedObs[2].Assembled == nil {
t.Errorf("expected third sideband event to be assembled, got %+v", parsedObs[2])
} else {
if parsedObs[2].Assembled.Content != "hello" {
t.Errorf("expected assembled content 'hello', got %q", parsedObs[2].Assembled.Content)
}
if parsedObs[2].Assembled.Reasoning != "stream reasoning" {
t.Errorf("expected assembled reasoning 'stream reasoning', got %q", parsedObs[2].Assembled.Reasoning)
}
if len(parsedObs[2].Assembled.ToolCallNames) != 1 || parsedObs[2].Assembled.ToolCallNames[0] != "call_stream" {
t.Errorf("expected assembled tool call names [call_stream], got %v", parsedObs[2].Assembled.ToolCallNames)
}
}
}
found := false
for _, entry := range observed.All() {
if entry.Message == "openai chat completion sideband stream closed" {
found = true
m := entry.ContextMap()
if m["assembled_content"] != "hello" {
t.Errorf("expected assembled_content=hello, got %v", m["assembled_content"])
}
if m["assembled_reasoning"] != "stream reasoning" {
t.Errorf("expected assembled_reasoning='stream reasoning', got %v", m["assembled_reasoning"])
}
tc, ok := m["assembled_tool_calls"].([]interface{})
if !ok || len(tc) != 1 || tc[0] != "call_stream" {
t.Errorf("expected assembled_tool_calls=[call_stream], got %v", m["assembled_tool_calls"])
}
if count, ok := m["assembled_tool_call_count"].(int64); !ok || count != 1 {
t.Errorf("expected assembled_tool_call_count=1, got %v", m["assembled_tool_call_count"])
}
}
}
if !found {
t.Error("expected log message 'openai chat completion sideband stream closed' not found")
}
}

View file

@ -317,7 +317,7 @@ func (s *Server) tunnelChatCompletionPassthrough(w http.ResponseWriter, r *http.
return
}
defer handle.Close()
s.writeProviderTunnelResponse(w, r, handle)
s.writeProviderTunnelResponse(w, r, handle, req.Stream)
}
// tunnelChatCompletionPassthroughSideband serves a Chat Completions request
@ -398,7 +398,7 @@ func (s *Server) submitChatCompletionTunnel(w http.ResponseWriter, r *http.Reque
// The response-start frame sets status/headers, body frames are written and
// flushed in order, and END terminates the response. Caller disconnect and
// wait timeout propagate cancellation to the Node cancel path.
func (s *Server) writeProviderTunnelResponse(w http.ResponseWriter, r *http.Request, handle edgeservice.ProviderTunnelResult) {
func (s *Server) writeProviderTunnelResponse(w http.ResponseWriter, r *http.Request, handle edgeservice.ProviderTunnelResult, reqStream bool) {
frames := handle.Stream().Frames
if frames == nil {
writeError(w, http.StatusBadGateway, "provider_tunnel_error", "tunnel stream unavailable")
@ -408,13 +408,20 @@ func (s *Server) writeProviderTunnelResponse(w http.ResponseWriter, r *http.Requ
timer := time.NewTimer(handle.WaitTimeout())
defer timer.Stop()
assembler := &providerChatAssembler{streaming: reqStream}
wroteHeader := false
bodyBytes := 0
defer func() {
obs := assembler.observation()
s.logger.Info("openai chat completion passthrough closed",
zap.String("run_id", handle.Dispatch().RunID),
zap.Bool("wrote_header", wroteHeader),
zap.Int("body_bytes", bodyBytes),
zap.String("assembled_content", obs.Content),
zap.String("assembled_reasoning", obs.Reasoning),
zap.Strings("assembled_tool_calls", obs.ToolCallNames),
zap.Int("assembled_tool_call_count", len(obs.ToolCallNames)),
)
}()
@ -468,6 +475,7 @@ func (s *Server) writeProviderTunnelResponse(w http.ResponseWriter, r *http.Requ
return
}
bodyBytes += len(body)
assembler.Write(body)
if flusher != nil {
flusher.Flush()
}
@ -588,12 +596,13 @@ func sidebandUsageFromFrame(frame *iop.ProviderTunnelFrame) *sidebandUsageObserv
// tolerates non-JSON and partial payloads: whatever cannot be parsed simply
// yields an empty summary.
type providerChatAssembler struct {
streaming bool
bodyBytes int
pending []byte
content strings.Builder
reasoning strings.Builder
toolCallNames []string
streaming bool
nonStreamingParsed bool
bodyBytes int
pending []byte
content strings.Builder
reasoning strings.Builder
toolCallNames []string
}
// providerChatDeltaEnvelope matches the provider fields the assembler
@ -658,7 +667,8 @@ func (a *providerChatAssembler) consumeDelta(delta providerChatDeltaEnvelope) {
}
func (a *providerChatAssembler) observation() *sidebandAssembledObservation {
if !a.streaming {
if !a.streaming && !a.nonStreamingParsed {
a.nonStreamingParsed = true
var resp struct {
Choices []struct {
Message providerChatDeltaEnvelope `json:"message"`
@ -718,10 +728,15 @@ func (s *Server) writeProviderTunnelSidebandStream(w http.ResponseWriter, r *htt
}
defer func() {
obs := assembler.observation()
s.logger.Info("openai chat completion sideband stream closed",
zap.String("run_id", handle.Dispatch().RunID),
zap.Bool("wrote_header", wroteHeader),
zap.Int("body_bytes", assembler.bodyBytes),
zap.String("assembled_content", obs.Content),
zap.String("assembled_reasoning", obs.Reasoning),
zap.Strings("assembled_tool_calls", obs.ToolCallNames),
zap.Int("assembled_tool_call_count", len(obs.ToolCallNames)),
)
}()
@ -924,10 +939,15 @@ func (s *Server) writeProviderTunnelSidebandResponse(w http.ResponseWriter, r *h
}
w.Header().Set(responseModeHeaderName, responseModePassthroughSideband)
writeJSON(w, providerStatus, envelope)
obs := assembler.observation()
s.logger.Info("openai chat completion sideband response",
zap.String("run_id", handle.Dispatch().RunID),
zap.Int("provider_status", providerStatus),
zap.Int("body_bytes", body.Len()),
zap.String("assembled_content", obs.Content),
zap.String("assembled_reasoning", obs.Reasoning),
zap.Strings("assembled_tool_calls", obs.ToolCallNames),
zap.Int("assembled_tool_call_count", len(obs.ToolCallNames)),
)
return
}
@ -1146,14 +1166,20 @@ func (s *Server) writeBufferedStreamOutput(w http.ResponseWriter, flusher http.F
id := "chatcmpl-" + dispatch.RunID
model := responseModel(req.Model, dispatch.Target)
content := result.message.Content
toolCallNames := extractToolCallNames(result.toolCalls)
s.logger.Info("openai chat completion stream closed",
zap.String("run_id", dispatch.RunID),
zap.Bool("strict_output", outputPolicy.Strict),
zap.Bool("strict_stream_buffer", outputPolicy.StreamBuffer),
zap.String("xml_completion_tool", outputPolicy.XMLCompletionTool),
zap.Bool("normalized", result.normalized),
zap.String("response_mode", result.responseMode),
zap.Int("content_len", len(content)),
zap.Int("reasoning_len", result.reasoningLen),
zap.String("assembled_content", content),
zap.String("assembled_reasoning", result.message.ReasoningContent),
zap.Strings("assembled_tool_calls", toolCallNames),
zap.Int("assembled_tool_call_count", len(toolCallNames)),
)
writeSSE(w, flusher, chatCompletionChunk{
ID: id,

View file

@ -60,6 +60,9 @@ openai:
enabled: false
listen: "0.0.0.0:18081"
bearer_token: ""
# Chat Completions provider routes default to metadata.iop_response_mode=passthrough.
# Callers can opt into passthrough+sideband or transformed per request metadata;
# this config has no global response-mode switch.
node: ""
adapter: "ollama"
target: ""

View file

@ -4,4 +4,10 @@
이 문서는 사람용 안내와 기존 링크 유지를 위한 포인터다. 요청 스키마, 필드 의미, 금지 사항, 구현 메모는 계약 원문을 기준으로 한다.
주요 현재 동작:
- Chat Completions provider route의 기본 응답 mode는 provider-original `passthrough`다.
- caller는 `metadata.iop_response_mode``passthrough`, `passthrough+sideband`, `transformed` 중 하나를 넣어 응답 경로를 선택할 수 있다.
- `passthrough+sideband``transformed`는 IOP 확장/변환 응답이며 provider-original byte identity로 취급하지 않는다.
에이전트 작업에서는 [agent-contract/index.md](../agent-contract/index.md)의 라우팅 규칙을 먼저 따른다.