diff --git a/agent-contract/inner/edge-node-runtime-wire.md b/agent-contract/inner/edge-node-runtime-wire.md index e4812fc..45a7925 100644 --- a/agent-contract/inner/edge-node-runtime-wire.md +++ b/agent-contract/inner/edge-node-runtime-wire.md @@ -33,6 +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과 분리된다. - cancel: Edge가 `CancelRequest`를 보내며 `CANCEL_RUN`과 `TERMINATE_SESSION`을 구분한다. - command: Edge가 `NodeCommandRequest`를 보내고 Node가 `NodeCommandResponse`로 usage/capabilities/session/transport/provider 상태를 응답한다. - refresh: Edge가 `NodeConfigRefreshRequest`로 새 config payload를 보내고 Node가 `NodeConfigRefreshResponse`로 적용/재시작 필요/실패를 응답한다. @@ -44,6 +45,8 @@ 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로 보내지 않는다. - `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 요청과 섞지 않는다. diff --git a/agent-roadmap/ROADMAP.md b/agent-roadmap/ROADMAP.md index 76662ed..9c6d9ff 100644 --- a/agent-roadmap/ROADMAP.md +++ b/agent-roadmap/ROADMAP.md @@ -63,9 +63,13 @@ provider/device/model별 qualification report와 모델 lifecycle 관리는 prov - 경로: [PHASE.md](phase/knowledge-tool-optimization-extension/PHASE.md) - 요약: 로컬 모델 성능 향상 축으로 단계 호출, tool/schema 강제, 검증/retry/fallback의 MVP 실행 모드를 먼저 스케치하고, RAG 장기 기억, advisor, context hook은 2차로 분리한다. -- [스케치] 라우팅 정책과 모델 오케스트레이션 +- [계획] 라우팅 정책과 모델 오케스트레이션 - 경로: [PHASE.md](phase/routing-policy-model-orchestration/PHASE.md) - - 요약: OpenAI-compatible 요청을 local/cloud/API provider 후보로 선택하는 routing policy, score policy, local triage, context optimizer, policy learning loop를 별도 도메인 축으로 스케치한다. + - 요약: OpenAI-compatible provider 원본 응답을 기존 Edge-Node socket 위 raw tunnel로 전달하는 passthrough 계획을 시작하고, local/cloud/API provider routing policy, score policy, local triage, context optimizer, policy learning loop를 별도 도메인 축으로 확장한다. + +- [스케치] Personal Edge 패키징과 배포 프로파일 + - 경로: [PHASE.md](phase/personal-edge-packaging-deployment/PHASE.md) + - 요약: 로컬용/서버용 코어를 분기하지 않고 같은 Edge runtime을 personal/server/fleet 배포 모드와 capability gate로 운용하며, 개인 로컬 패키지와 서버/팀 배포 패키징 경계를 장기 후속 축으로 스케치한다. ## 로딩 정책 diff --git a/agent-roadmap/phase/personal-edge-packaging-deployment/PHASE.md b/agent-roadmap/phase/personal-edge-packaging-deployment/PHASE.md new file mode 100644 index 0000000..734ec19 --- /dev/null +++ b/agent-roadmap/phase/personal-edge-packaging-deployment/PHASE.md @@ -0,0 +1,29 @@ +# Phase: Personal Edge 패키징과 배포 프로파일 + +## 상태 + +[스케치] + +## 목표 + +IOP를 서버 중심 운영 시스템으로만 보지 않고, 개인 로컬 Edge agent와 서버/팀 Edge 배포를 같은 코어에서 운용하는 패키징 방향을 정리한다. +이 Phase는 personal/local, server/team, fleet 배포 모드와 capability gate를 정의해 로컬용/서버용 코어 fork를 피하고, macOS/Windows/Linux native package와 Docker/server package의 역할을 나눈다. +하이브리드 라우팅, provider catalog, update plane, host-local manager, Control Plane enrollment가 어느 순서로 결합되어야 하는지 장기 후속 축으로 스케치한다. + +## Milestone 흐름 + +완료된 Milestone은 archive 경로를 가리키고, 검토중, 진행중, 계획, 스케치 또는 보류 Milestone은 이 Phase 하위 `milestones/` 경로를 가리킨다. +완료, 검토중, 진행중, 계획, 스케치 순서로 두어 아래로 갈수록 미래 작업에 가까워지게 정렬한다. +스케치 Milestone은 아직 구현 가능한 계획이 아니므로 사용자 검토와 구체화 후 `[계획]`으로 승격한다. + +- [스케치] Personal Local Edge 패키징과 배포 모드 프로파일 + - 경로: [personal-local-edge-deployment-profiles](milestones/personal-local-edge-deployment-profiles.md) + - 요약: 로컬용/서버용 코어를 분기하지 않고 같은 Edge runtime을 personal/server/fleet 배포 모드와 capability gate로 운용하며, 개인 로컬 패키지에서는 Node와 사용자 관리 레이어를 숨기거나 축소하는 방향을 뒤쪽 후보로 스케치한다. + +## Phase 경계 + +- 이 Phase는 제품 배포 토폴로지, packaging target, install/update UX, deployment mode, capability gate의 경계를 소유한다. +- provider catalog, local/cloud routing policy, scorer, context optimizer 자체는 `라우팅 정책과 모델 오케스트레이션` Phase 책임으로 둔다. +- release manifest, update protocol, host-local manager, rollback 상태 머신은 `Update Plane과 자체 업데이트 기반` Phase 책임으로 두고, 이 Phase는 personal/server 패키징에서 어떤 update capability를 켤지의 제품 경계를 다룬다. +- Edge/Node adapter execution, CLI agent runtime, specialized agent 등록 경로는 `Automation Runtime과 Bridge 확장` Phase 책임으로 둔다. +- 사용자/조직 IAM, billing/chargeback, 장기 audit schema 구현은 후속 운영/보안 Milestone에서 결정한다. diff --git a/agent-roadmap/phase/personal-edge-packaging-deployment/milestones/personal-local-edge-deployment-profiles.md b/agent-roadmap/phase/personal-edge-packaging-deployment/milestones/personal-local-edge-deployment-profiles.md new file mode 100644 index 0000000..87dbf20 --- /dev/null +++ b/agent-roadmap/phase/personal-edge-packaging-deployment/milestones/personal-local-edge-deployment-profiles.md @@ -0,0 +1,102 @@ +# Milestone: Personal Local Edge 패키징과 배포 모드 프로파일 + +## 위치 + +- Roadmap: [ROADMAP.md](../../../ROADMAP.md) +- Phase: [PHASE.md](../PHASE.md) + +## 목표 + +로컬용과 서버용 코어를 분기하지 않고, 같은 Edge runtime을 personal/local, server/team, fleet 배포 모드로 운용하는 방향을 스케치한다. +개인 로컬 사용자는 Node 직접 세팅이 아니라 provider를 꽂으면 localhost OpenAI-compatible endpoint가 생기는 UX를 기준으로 두고, 서버/팀 배포는 Docker, Control Plane, remote Node, fleet 운영 기능을 같은 코어의 확장 모드로 둔다. +이 Milestone은 즉시 구현이 아니라 후속 패키징, 설치, 업데이트, 기능 gate 구현을 위한 제품/운영 경계 정리를 목적으로 한다. + +## 상태 + +[스케치] + +## 승격 조건 + +- [ ] personal/local, server/team, fleet 배포 모드의 이름과 책임 범위를 결정한다. +- [ ] 같은 코어를 유지하면서 사용자 관리, Control Plane enrollment, remote Node, audit/fleet 기능을 capability gate 또는 config profile로 끄고 켜는 기준을 결정한다. +- [ ] 개인 로컬 모드에서 Node를 embedded backend, local child process, 또는 숨겨진 loopback Node 중 어떤 방식으로 시작할지 결정한다. +- [ ] macOS, Windows, Linux native package와 Docker/server package의 기본 배포 경로와 지원 우선순위를 결정한다. +- [ ] 로컬 모드의 보안 기준을 localhost bind, local API token, OS keychain 또는 env secret 중 어디까지 요구할지 결정한다. +- [ ] personal mode에서 provider catalog, route policy, usage ledger, local secrets가 어디에 저장되고 Control Plane과 어떻게 분리되는지 정리한다. +- [ ] server/team mode로 확장할 때 기존 personal Edge 상태를 어떻게 이전하거나 enrollment할지 결정한다. +- [ ] 후속 구현 Milestone과 SDD 필요 범위를 나눈다. + +## 구현 잠금 + +- 상태: 잠금 +- SDD: 불필요 +- SDD 문서: 없음 +- SDD 사유: 현재 Milestone은 personal/server 배포 토폴로지와 패키징 방향성 스케치이며, installer/config/update/security 계약 구현으로 승격할 때 SDD 필요 여부를 재판정한다. +- 잠금 해제 조건: 아래 체크리스트 + - [ ] 승격 조건의 미정 항목이 사용자 검토로 해소되어 있다. + - [ ] 구현 가능한 목표, 범위, 기능 Task, 후속 구현 Milestone 후보가 분리되어 있다. +- 결정 필요: 아래 체크리스트 + - [ ] 초기 제품 표면을 `iop-edge` 단일 바이너리로 유지할지, 별도 `iop-agent` 또는 tray/app wrapper를 둘지 결정한다. + - [ ] personal/local 모드의 기본 backend를 embedded, local child Node, remote Node disabled 중 어떤 조합으로 시작할지 결정한다. + - [ ] personal/local 모드에서 multi-user 관리, 조직 RBAC, fleet audit를 완전히 끌지, minimal local identity/audit만 남길지 결정한다. + - [ ] macOS/Windows 개인 배포에서 Docker를 보조 경로로만 둘지, 특정 provider runtime에는 Docker 옵션을 허용할지 결정한다. + - [ ] provider credential 저장을 OS keychain, local encrypted file, env reference 중 어떤 기본값으로 둘지 결정한다. + - [ ] personal Edge가 Control Plane에 optional enrollment될 때 로컬 상태와 provider key를 어디까지 공유하거나 공유하지 않을지 결정한다. + +## 범위 + +- 동일 코어 기반 personal/local, server/team, fleet 배포 모드 후보 +- 배포 모드별 feature gate: Control Plane, 사용자 관리, remote Node, fleet audit, provider pool, OpenAI-compatible API +- 개인 로컬 Edge UX: provider plug-in, localhost endpoint, local route policy, local secrets, Node 세부 노출 축소 +- 서버/팀 Edge UX: Docker 또는 Linux service 배포, Control Plane enrollment, remote Node registry, fleet 운영 기능 +- macOS, Windows, Linux native package와 Docker image/package의 역할 구분 +- personal mode에서 server/team mode로 확장하는 enrollment 또는 migration 후보 + +## 기능 + +### Epic: [deploy-mode] Unified Deployment Modes + +코어 분기 없이 배포 모드와 capability gate로 개인 로컬 제품과 서버/팀 운영 제품을 함께 수용하는 방향을 정리한다. + +- [ ] [mode-taxonomy] personal/local, server/team, fleet 모드의 책임과 기본 feature set 후보가 정리되어 있다. +- [ ] [core-boundary] provider catalog, routing policy, adapter + target 실행 계약, usage ledger, OpenAI-compatible surface를 공통 코어로 유지하고 로컬/서버용 fork를 만들지 않는 경계가 정리되어 있다. +- [ ] [feature-gates] 사용자 관리, Control Plane enrollment, remote Node, fleet audit, provider pool, OpenAI-compatible API를 capability gate 또는 config profile로 끄고 켜는 기준이 정리되어 있다. +- [ ] [personal-ux] 개인 로컬 사용자가 Node를 직접 의식하지 않고 provider만 연결해 localhost endpoint를 쓰는 UX 기준이 정리되어 있다. +- [ ] [node-topology] personal mode에서 embedded backend, local child Node, loopback Node, remote Node disabled의 장단점과 초기 선택 후보가 정리되어 있다. +- [ ] [package-targets] macOS/Windows native package, Linux service, Docker/server package의 기본 배포 경로와 지원 우선순위가 정리되어 있다. +- [ ] [local-security] localhost bind, local API token, credential storage, log/usage ledger 보존 범위 같은 personal mode 최소 보안 기준이 정리되어 있다. +- [ ] [server-mode] server/team 배포에서 Docker, Control Plane, remote Node registry, multi-user/audit/fleet 기능을 켜는 기준이 정리되어 있다. +- [ ] [migration] personal Edge를 server/team 모드로 enrollment하거나 상태를 이전할 때 공유/비공유할 설정과 secret 경계가 정리되어 있다. +- [ ] [followups] installer, update plane, config schema, local security, UI/tray, provider setup wizard 같은 후속 구현 Milestone 후보가 분리되어 있다. + +## 완료 리뷰 + +- 상태: 없음 +- 요청일: 없음 +- 완료 근거: 스케치 Milestone이며 기능 Task가 아직 충족되지 않았다. +- 검토 항목: + - [ ] 사용자가 동일 코어, 배포 모드, personal local UX 방향을 검토했다. + - [ ] 승격 조건과 결정 필요 항목이 후속 구현 Milestone으로 분리되었다. + - [ ] archive 이동을 승인했다. +- agent-ui 상태 반영: 해당 없음 +- 리뷰 코멘트: 없음 + +## 범위 제외 + +- 실제 macOS/Windows installer, tray/app, Docker image 구현 +- Edge/Node update runner, host-local manager, rollback 상태 머신 구현 +- OpenAI-compatible hybrid routing scorer 또는 provider adapter 구현 +- 사용자 관리, 조직 RBAC, billing/chargeback, 장기 audit schema 구현 +- provider runtime 다운로드, 모델 획득, local model marketplace 구현 +- 개인 로컬 모드의 UI onboarding 구현 + +## 작업 컨텍스트 + +- 관련 경로: `apps/edge/**`, `apps/node/**`, `packages/go/config/**`, `packages/go/hostsetup/**`, `configs/edge.yaml`, [edge-local-dev-guide.md](../../../../docs/edge-local-dev-guide.md) +- 표준선(선택): 로컬용/서버용 코어를 나누지 않고 deployment mode, capability gate, config profile로 기능 표면을 조정한다. +- 표준선(선택): personal/local mode의 사용자 경험은 Node bootstrap이 아니라 provider plug-in과 localhost OpenAI-compatible endpoint를 기본으로 둔다. +- 표준선(선택): 개인 배포의 기본은 macOS/Windows/Linux native package이며, Docker는 서버/팀 배포와 개발/격리 실행의 우선 경로로 둔다. +- 표준선(선택): local mode에서도 보안을 제거하지 않고 localhost bind, local API token, credential storage, 최소 usage ledger 기준을 둔다. +- 선행 작업: [Update Plane 안정 프로토콜](../../update-plane-self-update-foundation/milestones/update-plane-stable-protocol.md), [Host-local Manager 기반 자체 업데이트](../../update-plane-self-update-foundation/milestones/host-local-manager-self-update.md), [OpenAI-compatible 하이브리드 라우팅과 컨텍스트 최적화](../../routing-policy-model-orchestration/milestones/openai-compatible-hybrid-routing-context-optimization.md) +- 후속 작업: personal Edge installer 구현, deployment mode config schema, local provider setup wizard, Control Plane optional enrollment +- 확인 필요: `구현 잠금 > 결정 필요` 항목 diff --git a/agent-roadmap/phase/routing-policy-model-orchestration/PHASE.md b/agent-roadmap/phase/routing-policy-model-orchestration/PHASE.md index f2373dc..411c583 100644 --- a/agent-roadmap/phase/routing-policy-model-orchestration/PHASE.md +++ b/agent-roadmap/phase/routing-policy-model-orchestration/PHASE.md @@ -2,12 +2,12 @@ ## 상태 -[스케치] +[계획] ## 목표 IOP의 OpenAI-compatible, A2A, IOP native 입력 표면에서 들어온 요청을 local/cloud/provider API 후보로 선택하는 라우팅 정책 도메인을 별도로 관리한다. -이 Phase는 단순 provider capacity routing을 넘어 skill/예약어 기반 고신뢰 route, 자동 triage, context optimizer, score policy, route learning loop, API provider catalog를 연결해 비용, 품질, latency, local 우선 전략을 함께 최적화하는 책임을 다룬다. +이 Phase는 단순 provider capacity routing을 넘어 skill/예약어 기반 고신뢰 route, 자동 triage, analysis budget, context optimizer, score policy, route learning loop, API provider catalog를 연결해 비용, 품질, latency, local 우선 전략을 함께 최적화하는 책임을 다룬다. 초기에는 컨셉과 정책 경계를 스케치하고, 구현 가능한 단위로 승격할 때 OpenAI-compatible metadata/config/schema, usage ledger, provider catalog와의 계약을 별도 SDD로 재판정한다. ## Milestone 흐름 @@ -16,13 +16,17 @@ IOP의 OpenAI-compatible, A2A, IOP native 입력 표면에서 들어온 요청 완료, 검토중, 진행중, 계획, 스케치 순서로 두어 아래로 갈수록 미래 작업에 가까워지게 정렬한다. 스케치 Milestone은 아직 구현 가능한 계획이 아니므로 계획 Milestone보다 아래에 둔다. +- [계획] OpenAI-compatible Raw Tunnel과 Sideband Passthrough + - 경로: [openai-compatible-raw-tunnel-sideband-passthrough](milestones/openai-compatible-raw-tunnel-sideband-passthrough.md) + - 요약: OpenAI-compatible provider 응답을 기존 Edge-Node proto-socket 위 lossless raw tunnel로 전달하고, 기본값은 provider-original `passthrough`로 두며, 요청이 명시한 경우에만 `passthrough+sideband` 또는 `transformed`를 사용한다. + - [스케치] OpenAI-compatible 하이브리드 라우팅과 컨텍스트 최적화 - 경로: [openai-compatible-hybrid-routing-context-optimization](milestones/openai-compatible-hybrid-routing-context-optimization.md) - 요약: skill/예약어 기반 lane/grade 라우팅을 고신뢰 경로로 유지하고, DiffusionGemma 같은 로컬 모델을 자동 triage, negative guard, cloud context 최적화 보조, 주기적 scoring policy 학습 루프로 선택적으로 사용하는 hybrid local/cloud routing 방향을 스케치한다. ## Phase 경계 -- 이 Phase는 routing policy, model/API provider selection, score policy, policy versioning, learning loop, local/cloud escalation 기준을 소유한다. +- 이 Phase는 routing policy, model/API provider selection, analysis budget, score policy, policy versioning, learning loop, local/cloud escalation 기준을 소유한다. - provider/device/model의 실제 serving path, lifecycle, model acquisition, runtime launch/restart는 `운영 관측과 Provider 관리` 및 provider runtime 후속 Milestone 책임으로 둔다. - planner/generator/verifier 단계 호출, tool/schema validation, output validation 자체는 `지식과 도구 최적화 확장` Phase 책임으로 두고, 이 Phase는 그 실행 모드를 어떤 provider/target으로 보낼지에 대한 route policy를 다룬다. - CLI agent task file 기반 `PLAN-{lane}-GNN.md`/`CODE_REVIEW-{lane}-GNN.md` 생성과 code-review loop lifecycle은 agent-ops skill 책임이며, 이 Phase는 그 결과를 runtime route signal로 해석하는 정책과 운영 데이터를 다룬다. diff --git a/agent-roadmap/phase/routing-policy-model-orchestration/milestones/openai-compatible-hybrid-routing-context-optimization.md b/agent-roadmap/phase/routing-policy-model-orchestration/milestones/openai-compatible-hybrid-routing-context-optimization.md index 3570106..b40116d 100644 --- a/agent-roadmap/phase/routing-policy-model-orchestration/milestones/openai-compatible-hybrid-routing-context-optimization.md +++ b/agent-roadmap/phase/routing-policy-model-orchestration/milestones/openai-compatible-hybrid-routing-context-optimization.md @@ -20,10 +20,19 @@ OpenAI-compatible 방식으로 들어온 요청을 local/cloud provider와 여 - [ ] 기존 skill/예약어 기반 lane/grade 라우팅과 OpenAI-compatible 자동 라우팅의 우선순위와 override 경계를 결정한다. - [ ] DiffusionGemma 또는 동급 local triage 모델이 채울 schema, confidence, abstain, validation 기준을 결정한다. +- [ ] 초기 판단 게이트, 분석 플랜 게이트, partial analyzer 실행, policy arbiter, 실행/기록 단계를 어떤 순서와 책임으로 나눌지 결정한다. +- [ ] DiffusionGemma를 빠른 판단과 패싱 전략으로 쓸 때 feature-only 입력, short timeout, invalid schema/timeout 시 abstain 기준을 결정한다. +- [ ] 여러 partial analyzer를 항상 호출하지 않고 skip, cascade, parallel 중 어떤 방식으로 호출할지와 분석 예산 상한을 결정한다. +- [ ] 분석 비용과 latency가 예상 token 절감, 품질 개선, local 금지 예외 탐지 이득보다 클 때 분석을 생략하는 기준을 결정한다. - [ ] hard gate, weighted scorer, context optimizer, fallback/shadow mode를 어떤 실행 단계로 나눌지 결정한다. - [ ] local/cloud/provider API 후보의 capability, 비용, latency, quota, historical success를 어떤 metric으로 수집하고 route score에 반영할지 결정한다. - [ ] route score 정책을 즉시 self-mutation하지 않고, 학습 데이터가 일정량 쌓였을 때 주기적으로 policy proposal과 shadow/canary 검증을 거쳐 업데이트하는 루프를 결정한다. +- [ ] frontier teacher가 route log와 outcome을 분석해 prompt 개선, 기존 판단축 offset/threshold 조정, 새 판단축 추가 또는 기존 축 병합/삭제 후보를 policy patch proposal로 만드는 절차를 결정한다. +- [ ] 기존 판단축으로 포용 가능한 반복 패턴은 새 축을 늘리지 않고 offset/threshold 조정으로 처리하는 기준을 정리한다. +- [ ] frontier teacher와 offline replay에 전달할 route evidence pack의 원문 포함 여부, redaction, hash/preview, retention, export 금지 기준을 결정한다. - [ ] 낮은 Gemma 계열 local 모델도 쉽게 판별할 수 있는 local 금지 예외 기준선과 negative guard label을 정리한다. +- [ ] routing overhead 대비 token/cost/latency 절감 또는 품질 안정화 이득이 충분한지 판단할 ROI gate와 중단 기준을 결정한다. +- [ ] small model confidence/deferral calibration, shadow/canary, offline replay, counterfactual sampling으로 route policy를 검증하는 기준을 정리한다. - [ ] 이 Milestone을 별도 구현 Milestone으로 승격할 때 필요한 API/config/schema/ledger SDD 필요 여부를 재판정한다. - [ ] 사용자가 하이브리드 라우팅 MVP 범위와 후속 구현 우선순위를 검토했다. @@ -40,12 +49,26 @@ OpenAI-compatible 방식으로 들어온 요청을 local/cloud provider와 여 - [ ] skill/예약어 기반 라우팅 결과가 있을 때 자동 triage가 advisory로만 동작할지, hard gate 위반 시 cloud escalation을 제안할 수 있을지 결정한다. - [ ] 명시 route가 없는 OpenAI-compatible 요청에서 `iop-auto` 같은 자동 routing alias를 도입할지 결정한다. - [ ] DiffusionGemma 출력 schema를 route decision의 source of truth가 아니라 feature extraction/advisory signal로만 둘지 결정한다. + - [ ] 초기 판단 게이트에서 fast path로 빠져나갈 조건과 deeper analysis로 보낼 조건을 결정한다. + - [ ] 분석 플랜 게이트가 negative guard, task triage, context judge, frontier adjudicator 중 어떤 analyzer를 skip/cascade/parallel로 호출할지 결정하는 기준을 정리한다. + - [ ] analyzer 호출의 최대 latency, 최대 token/cost, 예상 절감액 대비 허용 비용 같은 analysis budget 기준을 결정한다. + - [ ] DiffusionGemma에 전체 원문을 넣을지, deterministic feature bundle과 짧은 intent excerpt만 넣을지 결정한다. + - [ ] DiffusionGemma guard가 timeout, schema invalid, confidence low를 반환할 때 기존 scorer로 pass할지, abstain/cloud escalation으로 처리할지 결정한다. - [ ] explicit `cloud` route를 local로 downgrade하는 동작을 금지할지, 어떤 사용자 opt-in에서 허용할지 결정한다. - [ ] explicit `local` route가 context/capability/tool/schema/privacy hard gate를 위반할 때 fail-fast, cloud escalation 권고, 또는 자동 cloud 전환 중 어떤 정책을 쓸지 결정한다. - [ ] fallback과 retry를 shadow/로그만 남길지, 같은 lane/grade 후보로 1회 자동 fallback할지, 사용자 승인 기반으로 둘지 결정한다. - [ ] 학습 모드가 frontier teacher 분석으로 policy proposal을 만들 때 필요한 최소 샘플 수, 업데이트 주기, 승인/검증 gate를 결정한다. + - [ ] frontier teacher가 제안할 수 있는 policy patch type을 prompt 개선, offset/weight 조정, threshold 조정, 새 판단축 추가, 기존 축 병합/삭제로 제한할지 결정한다. + - [ ] 새 판단축 추가 전에 기존 축 조정으로 설명 가능한지 확인하는 evidence 기준과 최소 반복 실패 cluster 크기를 결정한다. + - [ ] policy patch proposal의 evidence schema, rollback 조건, version 승격/폐기 기준을 결정한다. + - [ ] frontier teacher 또는 cloud evaluator에 full prompt/response를 보낼 수 있는지, 기본은 feature/hash/redacted preview만 사용할지 결정한다. + - [ ] route learning sample의 retention 기간, 사용자/조직/Edge별 opt-out, 민감 metadata 제거 기준을 결정한다. - [ ] local로 보내면 안 되는 예외를 deterministic hard gate, 낮은 Gemma classifier, frontier teacher label 중 어느 단계에서 잡을지 결정한다. - [ ] 작은 local model이 판별할 negative guard 기준을 keyword/metadata/context feature/schema feature처럼 단순한 신호로 시작할지, 학습된 classifier로 확장할지 결정한다. + - [ ] hard constraint와 negative guard를 weighted score보다 앞에 두는 precedence order를 확정한다. + - [ ] small model의 self-reported confidence를 그대로 쓸지, calibration/evaluation 기반 confidence로 제한할지 결정한다. + - [ ] 선택되지 않은 route의 품질을 추정하기 위한 shadow/canary/counterfactual sampling 비율, 비용 상한, 데이터 보존 기준을 결정한다. + - [ ] routing layer 자체의 추가 latency와 token/cost가 어느 수준을 넘으면 fast path 또는 기존 scorer를 우선할지 결정한다. - [ ] Claude, GPT, Gemini, GLM 같은 API provider 호출을 IOP 내부에서 어떤 `adapter + target` 후보로 표현하고 capability/catalog를 어떻게 관리할지 결정한다. ## 범위 @@ -53,12 +76,22 @@ OpenAI-compatible 방식으로 들어온 요청을 local/cloud provider와 여 - OpenAI-compatible 요청의 local/cloud/API provider 자동 선택 컨셉 - 기존 skill/예약어/lane/grade routing과 자동 triage routing의 병행 운영 원칙 - DiffusionGemma 같은 local model을 이용한 요청 분석 schema filling, confidence/abstain, parser validation, retry/repair 후보 +- 초기 판단 게이트: 요청이 fast path인지, 모델 분석 없이 바로 라우팅/실행해도 되는지, deeper analysis가 필요한지 판단하는 비용 최소화 단계 +- 분석 플랜 게이트: negative guard, task triage, context judge, frontier adjudicator 같은 partial analyzer 중 어떤 것을 skip, cascade, parallel로 호출할지 결정하는 단계 +- analysis budget: analyzer 호출에 쓸 최대 latency/token/cost와 예상 token 절감, 품질 개선, local 금지 예외 탐지 이득을 비교해 분석 생략 여부를 정하는 후보 +- DiffusionGemma fast guard: 전체 원문 대신 deterministic feature bundle, 짧은 user intent excerpt, detected keywords, output shape, metadata를 입력으로 받아 빠른 pass/abstain/escalation 판단을 수행하는 후보 - context size, compressibility, exact-source 필요성, tool/function calling, strict JSON/schema, multimodal, privacy/local-only, latency SLA, provider availability를 포함한 hard gate 후보 - route score 가중치 후보: capability fit, explicit route bonus, historical success, resource availability, quota remaining, latency fit, cost saving, local triage confidence, local risk, context loss risk, queue penalty - local model을 cloud context optimizer로 사용해 chunk, dedupe, rerank, summary, source pointer를 만들고 cloud token cost를 줄이는 방향 - shadow mode, route log, usage ledger feedback으로 자동 라우팅 threshold를 보정하는 운영 루프 - 주기적 learning mode: 실제 라우팅은 현재 policy version으로 유지하되, 축적된 요청/결과를 frontier teacher가 분석해 다음 scoring policy 후보를 제안하고 shadow/canary 검증 후 승격하는 방향 +- policy patch loop: frontier teacher가 직접 runtime policy를 변경하지 않고 prompt 개선, offset/weight/threshold 조정, 새 판단축 추가, 기존 축 병합/삭제 후보를 versioned proposal로 남기는 방향 +- teacher evidence pack: frontier teacher와 offline replay에 전달할 route evidence는 기본적으로 feature, hash, redacted preview, route/outcome metric을 우선하고, full prompt/response/raw payload 포함은 별도 결정으로 제한하는 후보 +- 판단축 확장 원칙: 기존 판단축이 반복 패턴을 포용할 수 있으면 새 축을 추가하지 않고 offset/threshold를 조정하며, 새 축은 기존 축으로 설명되지 않는 반복 실패 cluster가 충분할 때만 후보로 둔다. - local 금지 예외 탐지: 낮은 Gemma 계열 모델도 판별 가능한 negative guard 기준선, cloud escalation reason, abstain/fail-fast 후보 +- precedence order: privacy, explicit cloud, unsupported tool/schema/modality, context overflow, negative guard 같은 hard constraint를 weighted score보다 앞에 두는 정책 후보 +- calibration/evaluation: small model confidence와 deferral 판단을 그대로 신뢰하지 않고 offline replay, shadow/canary, counterfactual sampling, calibration metric으로 검증하는 후보 +- ROI gate: routing layer의 추가 latency/token/cost가 예상 절감 또는 품질 안정화 이득보다 큰 경우 fast path 또는 기존 scorer를 우선하는 중단 기준 후보 ## 기능 @@ -67,12 +100,21 @@ OpenAI-compatible 방식으로 들어온 요청을 local/cloud provider와 여 skill route와 자동 route를 경쟁시키지 않고 서로 다른 신뢰도의 라우팅 신호로 결합하기 위한 컨셉 산출물을 묶는다. - [ ] [route-sources] skill/예약어 기반 `PLAN-{lane}-GNN.md`와 `CODE_REVIEW-{lane}-GNN.md` 라우팅을 권위 있는 경로로 두고, 자동 triage를 advisory 또는 명시 route 부재 시 기본 경로로 쓰는 우선순위가 정리되어 있다. +- [ ] [analysis-plan] 초기 판단 게이트가 fast path와 deeper analysis를 나누고, 분석 플랜 게이트가 analyzer를 skip/cascade/parallel 중 어떤 방식으로 호출할지 정하는 구조가 정리되어 있다. +- [ ] [analysis-budget] analyzer 호출의 최대 latency/token/cost와 예상 token 절감, 품질 개선, local 금지 예외 탐지 이득을 비교해 분석을 생략하거나 축소하는 기준이 정리되어 있다. - [ ] [triage-schema] DiffusionGemma 또는 동급 local 모델이 채울 `task_type`, `difficulty`, `context_dependency`, `compressibility`, `required_capabilities`, `local_success_probability`, `cloud_quality_need`, `fallback_required`, `abstain`, `risk_reasons` schema 후보가 정리되어 있다. +- [ ] [fast-guard] DiffusionGemma를 빠른 판단과 패싱용 guard로 사용할 때 원문 전체 대신 feature bundle과 짧은 intent excerpt를 주고, timeout/invalid schema/low confidence는 abstain 또는 기존 scorer pass로 처리하는 기준이 정리되어 있다. - [ ] [hard-gates] context window, tool/function calling, strict JSON/schema, multimodal input, privacy/local-only, latency SLA, provider availability, explicit local/cloud/user mode 같은 deterministic gate 후보가 정리되어 있다. +- [ ] [constraint-order] privacy, explicit cloud, unsupported capability, context overflow, negative guard 같은 hard constraint가 weighted score보다 앞서는 precedence rule이 정리되어 있다. - [ ] [score-policy] hard gate를 통과한 후보에 대해 explicit route bonus, capability fit, historical success, resource/quota, latency, cost, triage confidence, risk/queue penalty를 조합하는 weighted scorer 후보가 정리되어 있다. +- [ ] [roi-gate] routing overhead, analyzer cost, latency 증가가 예상 token/cost 절감 또는 품질 안정화 이득보다 큰 경우 분석을 생략하거나 기존 scorer로 회귀하는 ROI 기준이 정리되어 있다. - [ ] [context-opt] 100k 이상 같은 단순 token threshold보다 compressible/exact-source 기준을 우선하고, local context optimizer가 cloud prompt를 줄이는 경로가 정리되어 있다. - [ ] [fallback-shadow] D01류 실행 실패 정책과 충돌하지 않도록 초기에는 shadow/advisory/route log 중심으로 운영하고, 자동 fallback은 별도 결정 뒤 구현하는 기준이 정리되어 있다. - [ ] [learning-loop] route score를 즉시 변경하지 않고, 일정량의 route log/outcome/teacher label이 쌓였을 때 policy proposal, offline evaluation, shadow/canary, version 승격 또는 폐기를 수행하는 주기적 학습 루프가 정리되어 있다. +- [ ] [teacher-proposal] frontier teacher가 route log/outcome을 분석해 prompt 개선, offset/weight/threshold 조정, 새 판단축 추가, 기존 축 병합/삭제를 versioned policy patch proposal로 제안하는 구조가 정리되어 있다. +- [ ] [teacher-data] frontier teacher와 offline replay에 전달할 route evidence pack의 redaction, hash/preview, full payload 금지/허용 조건, retention, opt-out 기준이 정리되어 있다. +- [ ] [axis-governance] 기존 판단축으로 포용 가능한 패턴은 offset/threshold로 조정하고, 새 판단축은 충분한 반복 실패 cluster와 evidence가 있을 때만 추가하는 기준이 정리되어 있다. +- [ ] [calibration-eval] small model confidence와 deferral 판단을 calibration metric, offline replay, shadow/canary, counterfactual sampling으로 검증하는 기준이 정리되어 있다. - [ ] [negative-guards] local로 보내면 안 되는 예외 케이스를 낮은 Gemma 계열 모델이 쉽게 판별할 수 있도록 단순 feature, label, escalation reason, abstain 기준으로 정리되어 있다. - [ ] [api-provider] Claude, GPT, Gemini, GLM 등 API provider와 local provider를 IOP 내부 `adapter + target` 후보로 표현하는 catalog/capability 방향이 정리되어 있다. - [ ] [ops-feedback] route decision, 후보 점수, 선택/실패 사유, token 절감량, 품질/성공 결과를 usage ledger와 qualification/report 후보에 연결하는 feedback loop가 정리되어 있다. @@ -95,6 +137,7 @@ skill route와 자동 route를 경쟁시키지 않고 서로 다른 신뢰도의 - OpenAI-compatible metadata/config/proto/API schema 구현 확정 - 실제 route scorer, provider adapter, cloud API 호출 코드 구현 - DiffusionGemma 또는 특정 local model serving 배포 구현 +- full prompt/response/raw provider payload를 기본 route learning dataset으로 저장하거나 외부 teacher에 보내는 정책 확정 - 자동 retry/fallback, 중복 실행 방지, partial output 정리의 최종 정책 구현 - billing/chargeback, 조직 IAM, 장기 retention 정책 - RAG 장기 기억 저장소나 Agent Shell 전용 advisor UX @@ -105,9 +148,19 @@ skill route와 자동 route를 경쟁시키지 않고 서로 다른 신뢰도의 - 표준선(선택): 내부 실행은 `adapter + target` 기준을 유지하고, 외부 OpenAI-compatible 경계의 `model` 표현은 alias 또는 compatibility surface로만 취급한다. - 표준선(선택): skill/예약어 기반 lane/grade 결과는 frontier 모델이 판단한 고신뢰 route signal이며, 자동 triage는 명시 route가 없는 요청이나 context optimizer/advisory 경로에서 우선 사용한다. - 표준선(선택): DiffusionGemma 출력은 최종 판결이 아니라 feature extraction 결과로 보고, schema validation, confidence threshold, abstain, cloud escalation 후보를 함께 둔다. +- 표준선(선택): 초기 판단 게이트는 라우팅 결정을 완성하지 않고 분석 깊이만 결정하며, 단순 요청은 fast path로 곧바로 기존 scorer 또는 명시 route에 넘긴다. +- 표준선(선택): 분석 플랜 게이트는 모든 analyzer를 매번 호출하지 않고, 요청 특성과 분석 예산에 따라 skip, cascade, parallel 중 하나를 선택한다. +- 표준선(선택): analyzer 호출 비용이나 latency가 예상 token 절감, 품질 개선, local 금지 예외 탐지 이득보다 크면 deeper analysis를 생략하고 기존 scorer 또는 명시 route를 우선한다. +- 표준선(선택): DiffusionGemma fast guard에는 전체 원문을 기본 입력으로 넣지 않고, deterministic feature bundle, 짧은 intent excerpt, detected keywords, requested output shape 같은 작은 입력을 우선 사용한다. +- 표준선(선택): DiffusionGemma guard에는 짧은 timeout을 두고, timeout/invalid schema/low confidence는 route 실패가 아니라 `abstain` 또는 기존 scorer pass로 처리한다. - 표준선(선택): explicit `cloud` route는 자동 local downgrade하지 않고, explicit `local` route도 hard gate 위반 시에는 fail-fast 또는 cloud escalation 권고를 남기는 방향을 우선 검토한다. - 표준선(선택): learning mode는 현재 scoring policy를 즉시 바꾸지 않고, versioned policy proposal을 만든 뒤 offline evaluation과 shadow/canary 검증을 통과한 경우에만 주기적으로 승격한다. +- 표준선(선택): frontier teacher는 runtime policy를 직접 수정하지 않고 policy patch proposal만 생성하며, 개선 순서는 prompt 개선, 기존 축 offset/weight/threshold 조정, 새 판단축 추가 순서를 우선한다. +- 표준선(선택): frontier teacher와 offline replay에는 full prompt/response를 기본 제공하지 않고, feature, hash, redacted preview, route/outcome metric으로 구성한 evidence pack을 우선 사용한다. +- 표준선(선택): 새 판단축은 기존 판단축과 offset/threshold 조정으로 설명되지 않는 반복 실패 cluster가 충분할 때만 추가하고, signal value가 낮은 판단축은 병합 또는 삭제 후보로 둔다. - 표준선(선택): 작은 Gemma 계열 모델이 맡는 negative guard는 복잡한 품질 판단보다 local 금지 신호를 낮은 비용으로 감지하는 역할을 우선하며, 애매하면 `abstain` 또는 cloud escalation 후보로 남긴다. +- 표준선(선택): small model confidence는 그대로 신뢰하지 않고 calibration/evaluation evidence로 보정하며, 선택되지 않은 route에 대한 shadow/canary/counterfactual sampling을 일부 유지해 selection bias를 줄인다. +- 표준선(선택): ROI gate를 통과하지 못한 analyzer와 policy proposal은 실시간 경로에서 제외하고, route log와 offline evidence로만 유지한다. - 선행 작업: [CLI Agent Group Grade Routing](../../automation-runtime-bridge/milestones/cli-agent-group-grade-routing.md), [요청 실행 로그와 Usage Ledger 기반](../../operational-observability-provider-management/milestones/request-execution-log-usage-ledger-foundation.md), [Provider-Device-Model Qualification 리포트와 Lifecycle 관리](../../operational-observability-provider-management/milestones/provider-device-model-qualification-report.md) - 후속 작업: 자동 route scorer 구현, OpenAI-compatible routing alias/config 구현, local context optimizer 구현, 품질 기반 routing/fallback 고도화 - 확인 필요: `구현 잠금 > 결정 필요` 항목 diff --git a/agent-roadmap/phase/routing-policy-model-orchestration/milestones/openai-compatible-raw-tunnel-sideband-passthrough.md b/agent-roadmap/phase/routing-policy-model-orchestration/milestones/openai-compatible-raw-tunnel-sideband-passthrough.md new file mode 100644 index 0000000..a1636ff --- /dev/null +++ b/agent-roadmap/phase/routing-policy-model-orchestration/milestones/openai-compatible-raw-tunnel-sideband-passthrough.md @@ -0,0 +1,108 @@ +# Milestone: OpenAI-compatible Raw Tunnel과 Sideband Passthrough + +## 위치 + +- Roadmap: [ROADMAP.md](../../../ROADMAP.md) +- Phase: [PHASE.md](../PHASE.md) + +## 목표 + +OpenAI-compatible 추론 엔진(vLLM, SGLang, Lemonade, OpenAI-compatible API provider)이 Node-local endpoint에서 반환한 HTTP/SSE 응답을 Edge-Node 기존 proto-socket 위에서 byte-faithful하게 Edge까지 전달한다. +기본 응답 방식은 provider 원본을 그대로 흘리는 `passthrough`로 두고, 요청이 명시한 경우에만 `passthrough+sideband` 또는 `transformed` 모드를 사용한다. +기존 정규화 경로는 CLI, IOP native, non-compatible provider, legacy migration 용도로 유지하되, OpenAI-compatible provider의 고성능 경로는 raw tunnel을 source of truth로 삼는다. + +## 상태 + +[계획] + +## 승격 조건 + +- 없음 + +## 구현 잠금 + +- 상태: 해제 +- SDD: 필요 +- SDD 문서: [SDD.md](../../../sdd/routing-policy-model-orchestration/openai-compatible-raw-tunnel-sideband-passthrough/SDD.md) +- SDD 사유: Edge-Node proto/wire, OpenAI-compatible API mode contract, request metadata/config, lossless streaming, cancellation/backpressure, observability 경계를 함께 바꾸는 설계 Milestone이다. +- 잠금 해제 조건: 아래 체크리스트 + - [x] SDD 잠금이 해제되어 있다. + - [x] SDD 사용자 리뷰가 없거나 승인/해결되었다. + - [x] Acceptance Scenario가 Milestone 기능 Task와 연결되어 있다. + - [x] Evidence Map이 완료 시 `Roadmap Completion`과 최종 검증 evidence로 검증 가능하게 연결되어 있다. +- 결정 필요: 없음 + +## 범위 + +- 기존 Edge-Node proto-socket 연결 위에 OpenAI-compatible provider raw response를 전달하는 lossless tunnel frame 추가 +- Edge가 Node provider에 직접 닿지 않고 Node가 provider HTTP/SSE를 열어 raw bytes를 Edge로 relay하는 구조 +- `RunEvent.delta`와 Edge `events.Bus`를 통하지 않는 per-request ordered byte stream +- Chat Completions streaming/non-streaming passthrough 우선 구현과 Responses API passthrough 후속 연결 +- 요청 단위 응답 모드 선택: `passthrough`, `passthrough+sideband`, `transformed` +- OpenAI-compatible provider의 기본값을 provider-original `passthrough`로 고정 +- pure `passthrough` body에는 IOP sideband field/event를 섞지 않고, sideband는 `passthrough+sideband` 명시 모드에서만 IOP extension stream/schema로 노출 +- `passthrough+sideband`는 provider-original byte identity를 주장하지 않는 IOP 확장 모드이며, 같은 client SSE/response stream에 IOP event/field를 합치는 방식은 이 모드에서만 허용 +- 사람이 읽을 수 있는 assembled output(`content`, `reasoning`, `tool_calls`, sideband summary, transformed output)을 실행 로그에 남기고 raw chunk payload는 기본 저장하지 않는 기준 +- backpressure, cancel, timeout, upstream provider error, client disconnect, chunk ordering, final frame semantics +- agent-contract, agent-spec, 테스트 fixture, 설정 예시 갱신 + +## 기능 + +### Epic: [raw-tunnel] Lossless Provider Tunnel + +OpenAI-compatible provider의 원본 응답을 정규화/재조립하지 않고 기존 Edge-Node socket으로 전달하는 핵심 경로를 묶는다. + +- [ ] [wire-contract] Edge-Node wire에 tunnel request/frame 메시지를 추가하고 status, headers, body bytes, sequence, end, error, usage trailer 후보를 정의한다. 검증: proto 생성과 contract test에서 raw body가 `RunEvent.delta`나 `events.Bus`를 통하지 않음을 확인한다. +- [ ] [node-relay] Node는 OpenAI-compatible provider HTTP/SSE response를 열고 provider가 보낸 chunk를 tunnel frame으로 relay한다. 검증: vLLM Qwen `reasoning_content` fixture와 Gemma content fixture가 byte comparison으로 동일하게 왕복한다. +- [ ] [edge-stream] Edge OpenAI-compatible handler는 tunnel status/headers/body chunks를 caller에게 그대로 write하고 pure passthrough를 기본값으로 적용한다. 검증: streaming/non-streaming Chat Completions fixture가 provider 원본과 byte-identical하게 반환된다. + +### Epic: [response-modes] Request-selected Modes + +원본 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 검증과 분리된다. + +### Epic: [observability] Assembled Output and Flow Control + +raw bytes는 전송 source of truth로 쓰되, 운영 관측과 저장은 사람이 읽을 수 있는 조립 결과와 구조화된 sideband를 기준으로 한다. + +- [ ] [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를 사용한다. + +## 완료 리뷰 + +- 상태: 없음 +- 요청일: 없음 +- 완료 근거: 기능 Task가 아직 충족되지 않았다. +- 검토 항목: + - [ ] 구현 결과가 pure `passthrough` 기본값과 provider-original byte stream을 보장한다. + - [ ] sideband/transformed mode가 opt-in으로만 동작한다. + - [ ] raw tunnel이 기존 Edge-Node socket 위에서 lossless/backpressure-aware하게 동작한다. + - [ ] archive 이동을 승인했다. +- agent-ui 상태 반영: 해당 없음 +- 리뷰 코멘트: 없음 + +## 범위 제외 + +- 기존 정규화 경로와 legacy provider adapter 제거 +- Edge가 Node-local provider endpoint에 직접 접속하는 구조 +- 별도 Node socket 또는 별도 stream server 추가 +- raw provider byte payload의 기본 장기 저장 +- pure `passthrough` OpenAI SSE body에 IOP sideband field/event를 주입하는 방식 +- routing score, provider qualification, billing/chargeback 정책 고도화 + +## 작업 컨텍스트 + +- 관련 경로: `proto/iop/runtime.proto`, `apps/edge/internal/openai`, `apps/edge/internal/service`, `apps/edge/internal/transport`, `apps/edge/internal/events`, `apps/node/internal/transport`, `apps/node/internal/adapters/openai_compat`, `apps/node/internal/adapters/vllm`, `packages/go/config`, [openai-compatible-api.md](../../../../agent-contract/outer/openai-compatible-api.md), [edge-node-runtime-wire.md](../../../../agent-contract/inner/edge-node-runtime-wire.md), [openai-compatible-surface.md](../../../../agent-spec/input/openai-compatible-surface.md), [edge-node-execution.md](../../../../agent-spec/runtime/edge-node-execution.md) +- 표준선: OpenAI-compatible provider response의 원본 bytes가 `passthrough` mode의 source of truth다. +- 표준선: Edge는 provider에 직접 붙지 않고 Node가 provider HTTP/SSE를 열어 기존 Edge-Node socket으로 relay한다. +- 표준선: pure `passthrough`에는 IOP sideband를 섞지 않는다. sideband와 transformed output은 요청 opt-in으로만 활성화한다. +- 표준선: `passthrough+sideband`는 명시적 IOP 확장 모드이므로 provider-original byte-identical 응답으로 간주하지 않는다. +- 표준선: OpenAI-compatible provider의 omitted mode 기본값은 `passthrough`다. +- 표준선: 실행 로그에는 raw chunk payload보다 assembled readable output과 구조화된 sideband observation을 우선 남긴다. +- 선행 작업: [OpenAI-compatible 하이브리드 라우팅과 컨텍스트 최적화](openai-compatible-hybrid-routing-context-optimization.md), [OpenAI-compatible Think 제어 MVP](../../knowledge-tool-optimization-extension/milestones/openai-compatible-think-control.md) +- 후속 작업: normalized path deprecation review, provider qualification matrix의 raw passthrough compatibility 반영, Responses API parity hardening +- 확인 필요: 없음 diff --git a/agent-roadmap/sdd/routing-policy-model-orchestration/openai-compatible-raw-tunnel-sideband-passthrough/SDD.md b/agent-roadmap/sdd/routing-policy-model-orchestration/openai-compatible-raw-tunnel-sideband-passthrough/SDD.md new file mode 100644 index 0000000..3445570 --- /dev/null +++ b/agent-roadmap/sdd/routing-policy-model-orchestration/openai-compatible-raw-tunnel-sideband-passthrough/SDD.md @@ -0,0 +1,141 @@ +# SDD: OpenAI-compatible Raw Tunnel과 Sideband Passthrough + +## 위치 + +- Milestone: [openai-compatible-raw-tunnel-sideband-passthrough](../../../phase/routing-policy-model-orchestration/milestones/openai-compatible-raw-tunnel-sideband-passthrough.md) +- Phase: [PHASE.md](../../../phase/routing-policy-model-orchestration/PHASE.md) + +## 상태 + +[승인됨] + +## SDD 잠금 + +- 상태: 해제 +- 사용자 리뷰: 없음 +- 잠금 항목: + - [x] [D01] 공식 응답 모드는 `passthrough`, `passthrough+sideband`, `transformed` 셋으로 둔다. + - [x] [D02] OpenAI-compatible inference provider의 기본 응답은 provider-original `passthrough`로 둔다. + - [x] [D03] Edge는 Node-local provider에 직접 닿지 않고, Node가 provider response를 기존 Edge-Node proto-socket 위 tunnel frame으로 relay한다. + - [x] [D04] pure `passthrough` body에는 IOP sideband를 섞지 않는다. sideband/transformed output은 요청 opt-in으로만 활성화한다. + - [x] [D05] 저장/관측 기본값은 raw byte payload가 아니라 사람이 읽을 수 있는 assembled output과 구조화된 sideband observation으로 둔다. + - [x] [D06] `passthrough+sideband`는 provider-original byte identity를 주장하지 않는 IOP 확장 모드이며, 같은 client SSE/response stream에 IOP event/field를 합치는 방식은 이 모드에서만 허용한다. + +## 문제 / 비목표 + +- 문제: 기존 OpenAI-compatible 실행 경로는 Node provider 응답을 내부 `RunEvent`와 Edge OpenAI handler에서 정규화/재조립한다. 이 과정은 vLLM Qwen 계열의 `reasoning_content`, Gemma 계열의 content/tool-call chunk 차이처럼 provider별 출력 차이를 IOP 표준 레이어에 억지로 맞추며 누락, 변형, 순서 왜곡, stream semantics 불일치를 만들 수 있다. OpenAI-compatible provider에서는 provider가 만든 응답이 이미 외부 client가 기대하는 wire format이므로, 성능과 정확성의 기준은 원본 stream passthrough여야 한다. +- 비목표: + - 기존 normalized `RunEvent` 경로 제거 + - Edge가 Node provider endpoint에 직접 접속하는 구조 + - 별도 Node socket 또는 side stream server 추가 + - raw provider byte payload 기본 장기 저장 + - pure `passthrough` stream에 IOP sideband field/event 주입 + - routing score, billing/chargeback, 장기 retention 정책 확정 + +## Source of Truth + +| 영역 | 기준 | 메모 | +|------|------|------| +| Roadmap | [openai-compatible-raw-tunnel-sideband-passthrough](../../../phase/routing-policy-model-orchestration/milestones/openai-compatible-raw-tunnel-sideband-passthrough.md) | 목표, 기능 Task, 범위, 제외 범위 기준 | +| Outer Contract | [openai-compatible-api.md](../../../../agent-contract/outer/openai-compatible-api.md) | `metadata.iop_response_mode`, default passthrough, Chat Completions/Responses surface 기준 | +| Inner Contract | [edge-node-runtime-wire.md](../../../../agent-contract/inner/edge-node-runtime-wire.md) | 기존 Edge-Node proto-socket과 새 tunnel frame 의미 기준 | +| Spec | [openai-compatible-surface.md](../../../../agent-spec/input/openai-compatible-surface.md), [edge-node-execution.md](../../../../agent-spec/runtime/edge-node-execution.md) | 현재 OpenAI-compatible handler와 Edge-Node 실행 구조 기준 | +| Code | `proto/iop/runtime.proto`, `apps/edge/internal/openai`, `apps/edge/internal/service`, `apps/edge/internal/transport`, `apps/edge/internal/events`, `apps/node/internal/transport`, `apps/node/internal/adapters/openai_compat`, `apps/node/internal/adapters/vllm`, `packages/go/config` | proto, Edge handler, Node provider relay, config/mode parsing 구현 기준 | +| External Provider | OpenAI-compatible inference engines | `passthrough` mode에서 provider HTTP/SSE response bytes가 source of truth다 | +| User Decision | D01-D06 | 2026-07-07 대화에서 mode set, default, socket path, sideband separation, readable assembled persistence, explicit sideband-combined mode 확정 | + +## State Machine + +| 상태 | 진입 조건 | 다음 상태 | 근거 | +|------|-----------|-----------|------| +| accepted | Edge OpenAI-compatible endpoint가 요청을 받았다 | mode_resolved 또는 error | request id, model, metadata/config | +| mode_resolved | 요청 mode가 해석되었다. 누락 시 `passthrough`로 둔다 | routed 또는 error | `passthrough`, `passthrough+sideband`, `transformed`; unknown mode는 error | +| routed | Edge가 실행 Node/provider target을 선택했다 | tunnel_requested 또는 normalized_dispatched | OpenAI-compatible provider는 tunnel, legacy/non-compatible은 normalized path 가능 | +| tunnel_requested | Edge가 기존 Edge-Node socket으로 tunnel request를 보냈다 | provider_started 또는 error | tunnel request frame | +| provider_started | Node가 provider HTTP/SSE request를 열었다 | response_started 또는 error | upstream status/header 관측 | +| response_started | Node가 provider status/header를 Edge에 전달했다 | body_streaming 또는 completed/error | tunnel response-start frame | +| body_streaming | provider body bytes가 sequence를 가진 tunnel frame으로 이동한다 | completed, cancelled, error | ordered body chunk frames | +| sideband_observing | 요청이 `passthrough+sideband` 또는 `transformed`이고 IOP observation이 필요하다 | body_streaming, transformed_streaming, completed/error | assembled content/reasoning/tool/usage observation | +| transformed_streaming | 요청이 `transformed`이고 IOP output writer가 provider stream을 수정한다 | completed/error | transformed response frame 또는 OpenAI-compatible output writer | +| completed | provider EOF, finish marker, non-streaming body complete가 전달됐다 | 없음 | final frame, trailer/usage 후보 | +| cancelled | caller disconnect 또는 explicit cancel이 발생했다 | 없음 | cancel/control frame, upstream request cancel | +| error | provider, socket, parser, mode, timeout 오류가 발생했다 | 없음 | error frame과 response error policy | + +## Interface Contract + +- 계약 원문: [openai-compatible-api.md](../../../../agent-contract/outer/openai-compatible-api.md), [edge-node-runtime-wire.md](../../../../agent-contract/inner/edge-node-runtime-wire.md) +- 입력: + - OpenAI-compatible Chat Completions 및 Responses request + - 요청 mode: `metadata.iop_response_mode` + - mode 값: `passthrough`, `passthrough+sideband`, `transformed` + - mode 누락: OpenAI-compatible inference provider에서는 `passthrough` + - unknown mode: fail-fast invalid request error + - Edge-selected target: node id, provider id, served model, adapter/target metadata + - tunnel control: request id/run id, cancel, timeout, stream/non-stream flag +- 출력: + - `passthrough`: provider status/header/body bytes를 caller에게 byte-faithful하게 전달 + - `passthrough+sideband`: provider original body와 IOP sideband observation을 명시적 IOP extension stream/schema로 함께 제공하며, 같은 client SSE/response stream에 IOP event/field를 합칠 수 있다. + - `transformed`: IOP가 provider stream을 읽고 사용자 지정 schema 또는 수정된 output을 반환 + - 실행 로그: assembled `content`, `reasoning`, `tool_calls`, usage/finish/provider observation, sideband summary +- 금지: + - pure `passthrough` body에 IOP sideband field/event를 삽입하지 않는다. + - `passthrough+sideband` output을 provider-original byte-identical response로 표시하지 않는다. + - OpenAI-compatible provider raw bytes를 `RunEvent.delta` 또는 lossy `events.Bus` fanout의 payload로 보내지 않는다. + - 기존 Edge-Node socket과 별개인 Node stream socket을 새로 열지 않는다. + - raw provider byte payload를 기본 장기 저장하지 않는다. + - `transformed` output을 provider-original byte stream으로 표시하지 않는다. + +## Acceptance Scenarios + +| ID | Milestone Task | Given | When | Then | +|----|----------------|-------|------|------| +| S01 | `wire-contract` | Edge가 OpenAI-compatible provider target을 선택했다 | Edge가 Node에 tunnel request를 보낸다 | 기존 Edge-Node socket 위에서 status/header/body/end/error frame이 sequence와 함께 정의된다 | +| S02 | `node-relay` | vLLM Qwen fixture가 `reasoning_content` stream chunk를 반환한다 | Node가 provider response를 relay한다 | provider chunk bytes가 정규화 누락 없이 Edge까지 동일하게 도착한다 | +| S03 | `node-relay` | Gemma fixture가 content/tool-call chunk를 반환한다 | Node가 provider response를 relay한다 | provider chunk bytes가 모델별 parser 차이 없이 Edge까지 동일하게 도착한다 | +| S04 | `edge-stream` | 요청 mode가 누락되어 있다 | Edge가 OpenAI-compatible streaming response를 반환한다 | 기본값 `passthrough`가 적용되고 provider body가 byte-identical하게 반환된다 | +| S05 | `mode-contract` | 요청이 `passthrough+sideband`를 명시한다 | Edge/Node가 stream을 처리한다 | provider original body와 IOP sideband observation이 명시적 IOP extension stream/schema로 노출된다 | +| S06 | `sideband-overlay` | 요청이 pure `passthrough`다 | provider stream이 caller에게 전달된다 | body 안에 IOP sideband field/event가 없다 | +| S07 | `transform-output` | 요청이 `transformed`를 명시한다 | IOP schema/output writer가 provider stream을 처리한다 | 반환값은 IOP transformed output으로 표시되고 passthrough byte identity 검증 대상에서 제외된다 | +| S08 | `assembled-log` | provider stream이 content, reasoning, tool call을 포함한다 | 요청이 완료된다 | 실행 로그에는 readable assembled string과 structured observation이 남고 raw chunk payload는 기본 저장되지 않는다 | +| S09 | `backpressure-cancel` | caller가 느리거나 중간에 disconnect한다 | tunnel stream이 진행 중이다 | chunk drop/reorder 없이 backpressure가 적용되고 upstream provider request가 cancel된다 | +| S10 | `contract-docs` | 구현이 완료된다 | 계약/spec/docs를 검토한다 | mode 이름, default, tunnel path, sideband/transformed semantics가 문서와 test fixture에서 일치한다 | +| S11 | `mode-contract` | 요청이 unknown `metadata.iop_response_mode`를 명시한다 | Edge가 mode를 해석한다 | silent fallback 없이 invalid request error를 반환한다 | + +## Evidence Map + +| Scenario | Required Evidence | `agent-task` 연결 | 완료 Evidence 기대 | +|----------|-------------------|------------------|---------------------------| +| S01 | proto diff, generated code, Edge/Node tunnel contract test | `agent-task/m-openai-compatible-raw-tunnel-sideband-passthrough/...` | `Roadmap Completion`에 `wire-contract`와 S01 충족 근거 | +| S02 | Qwen reasoning fixture byte comparison test | `agent-task/m-openai-compatible-raw-tunnel-sideband-passthrough/...` | `Roadmap Completion`에 `node-relay`와 S02 충족 근거 | +| S03 | Gemma fixture byte comparison test | `agent-task/m-openai-compatible-raw-tunnel-sideband-passthrough/...` | `Roadmap Completion`에 `node-relay`와 S03 충족 근거 | +| S04 | omitted mode default passthrough test와 streaming/non-streaming response comparison | `agent-task/m-openai-compatible-raw-tunnel-sideband-passthrough/...` | `Roadmap Completion`에 `edge-stream`와 S04 충족 근거 | +| S05 | explicit sideband mode test와 sideband observation fixture | `agent-task/m-openai-compatible-raw-tunnel-sideband-passthrough/...` | `Roadmap Completion`에 `mode-contract`, `sideband-overlay`와 S05 충족 근거 | +| S06 | pure passthrough response에 IOP field/event가 없음을 확인하는 negative test | `agent-task/m-openai-compatible-raw-tunnel-sideband-passthrough/...` | `Roadmap Completion`에 `sideband-overlay`와 S06 충족 근거 | +| S07 | transformed mode output contract test와 label/metadata 확인 | `agent-task/m-openai-compatible-raw-tunnel-sideband-passthrough/...` | `Roadmap Completion`에 `transform-output`와 S07 충족 근거 | +| S08 | execution log fixture에서 assembled content/reasoning/tool_calls 확인과 raw chunk 미저장 확인 | `agent-task/m-openai-compatible-raw-tunnel-sideband-passthrough/...` | `Roadmap Completion`에 `assembled-log`와 S08 충족 근거 | +| S09 | slow client, cancel, provider error, socket backpressure test | `agent-task/m-openai-compatible-raw-tunnel-sideband-passthrough/...` | `Roadmap Completion`에 `backpressure-cancel`와 S09 충족 근거 | +| S10 | agent-contract/agent-spec/docs/config example diff와 link validation | `agent-task/m-openai-compatible-raw-tunnel-sideband-passthrough/...` | `Roadmap Completion`에 `contract-docs`와 S10 충족 근거 | +| S11 | unknown mode validation test | `agent-task/m-openai-compatible-raw-tunnel-sideband-passthrough/...` | `Roadmap Completion`에 `mode-contract`와 S11 충족 근거 | + +## Cross-repo Dependencies + +- 없음 + +## Drift Check + +- [x] Milestone 기능 Task와 Acceptance Scenario가 일치한다. +- [x] Evidence Map이 code-review/complete.log에서 검증 가능하다. +- [x] agent-contract를 쓰는 경우 SDD에 계약 원문을 복제하지 않았다. +- [x] 사용자 리뷰가 필요한 항목은 없다. + +## 사용자 리뷰 이력 + +- 2026-07-07: 사용자 결정으로 공식 mode set, default original passthrough, 기존 Edge-Node socket tunnel, pure passthrough와 sideband 분리, explicit sideband-combined mode, readable assembled persistence 기준을 확정했다. + +## 작업 컨텍스트 + +- 표준선: raw provider bytes는 `passthrough` 전송의 source of truth이고, IOP sideband와 transformed output은 별도 opt-in layer다. +- 표준선: 기존 socket 설계를 활용하며, raw stream은 lossy event bus가 아니라 request-bound ordered frame으로 다룬다. +- 표준선: Chat Completions를 먼저 연결하되 Responses API parity를 같은 Milestone 범위의 후속 Task로 둔다. +- 표준선: 기존 normalized path는 유지하고, raw tunnel 안정화 뒤 별도 deprecation review에서 제거 여부를 판단한다. +- 후속 SDD: 없음 diff --git a/agent-task/archive/2026/07/m-openai-compatible-raw-tunnel-sideband-passthrough/01_node_relay/code_review_local_G04_2.log b/agent-task/archive/2026/07/m-openai-compatible-raw-tunnel-sideband-passthrough/01_node_relay/code_review_local_G04_2.log new file mode 100644 index 0000000..eee6211 --- /dev/null +++ b/agent-task/archive/2026/07/m-openai-compatible-raw-tunnel-sideband-passthrough/01_node_relay/code_review_local_G04_2.log @@ -0,0 +1,184 @@ + + +# Code Review Reference - REVIEW_REVIEW_API + +> **[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-07 +task=m-openai-compatible-raw-tunnel-sideband-passthrough/01_node_relay, plan=2, tag=REVIEW_REVIEW_API + +## 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: + - `node-relay`: Node는 OpenAI-compatible provider HTTP/SSE response를 열고 provider가 보낸 chunk를 tunnel frame으로 relay한다. +- Completion mode: check-on-pass + +## Archive Evidence Snapshot + +- Archived plan: `agent-task/m-openai-compatible-raw-tunnel-sideband-passthrough/01_node_relay/plan_local_G07_1.log` +- Archived review: `agent-task/m-openai-compatible-raw-tunnel-sideband-passthrough/01_node_relay/code_review_local_G07_1.log` +- Verdict: FAIL +- Required summary: + - R1: SDD S03과 원 plan of Gemma content/tool-call raw byte comparison evidence가 `TunnelProvider` 테스트에 없다. +- Affected files: + - `apps/node/internal/adapters/vllm/vllm_test.go` +- Verification evidence from review: + - `gofmt -l ...` was empty after review-agent formatting repair. + - `git diff --check` PASS. + - `go test -count=1 ./apps/node/internal/transport ./apps/node/internal/node ./apps/node/internal/adapters/openai_compat ./apps/node/internal/adapters/vllm` PASS. + - `go test -count=1 ./apps/edge/internal/transport ./proto/gen/iop` PASS. + - `make proto` PASS. +- Roadmap carryover: `node-relay` remains incomplete until S03 Gemma content/tool-call raw byte fixture evidence is present. +- Narrow archive reread allowed if needed: the archived plan/review files listed above, plus prior `plan_local_G07_0.log` only for the original S02/S03 fixture wording. + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. +리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. + +1. 판정을 append한다. +2. `CODE_REVIEW-{review_lane}-GNN.md` -> `code_review_{review_lane}_GNN_N.log`, `PLAN-{build_lane}-GNN.md` -> `plan_{build_lane}_GNN_M.log`로 아카이브한다. +3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/{task_name}/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 연결된 Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다. +4. PASS이고 task group이 `m-`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [REVIEW_REVIEW_API-1] S03 Gemma Tool-Call Raw Fixture | [x] | + +## 구현 체크리스트 + +- [x] `apps/node/internal/adapters/vllm/vllm_test.go`의 raw tunnel success coverage에 Gemma-style content/tool-call SSE chunks fixture를 추가한다. +- [x] 추가 fixture가 `ProviderTunnelFrame.body` concatenation과 원본 provider bytes의 exact equality를 검증하게 한다. +- [x] `gofmt -l apps/node/internal/adapters/vllm/vllm_test.go`를 실행하고 출력이 비어 있음을 기록한다. +- [x] `go test -count=1 ./apps/node/internal/adapters/vllm -run 'TestVllmTunnelProvider'`를 실행한다. +- [x] `go test -count=1 ./apps/node/internal/transport ./apps/node/internal/node ./apps/node/internal/adapters/openai_compat ./apps/node/internal/adapters/vllm`를 실행한다. +- [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_G04_2.log`로 아카이브한다. +- [x] active `PLAN-*-G??.md`를 `plan_local_G04_2.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/{task_name}/`를 `agent-task/archive/YYYY/MM/{task_name}/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [x] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/{task_group}/`를 제거하거나, 남은 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로 이동한다. + +## 계획 대비 변경 사항 + +- 없음. 계획서에서 요구한 Gemma-style content 및 tool-call SSE chunks fixture를 `TestVllmTunnelProvider_GemmaContentToolCallRawBytes` 테스트로 추가하고 exact byte comparison 검증을 성공적으로 마쳤습니다. + +## 주요 설계 결정 + +- 기존 성공 케이스 검증과 분리하기 위해 `TestVllmTunnelProvider_GemmaContentToolCallRawBytes` 라는 신규 테스트 함수를 구현하고, Gemma 응답 포맷(content chunk, tool_calls arguments chunk, [DONE] marker)의 SSE 스트림 raw payload 바이트 배열을 fake 서버에서 통째로 전송하여 tunnel frames 바디 조립 결과물과 100% 동일한지(exact equality) byte-for-byte 검증을 하도록 설계했습니다. +- 테스트 파일 저장 후 발생한 포맷 오정렬을 해결하기 위해 `gofmt -w` 도구를 가동해 파일의 Go 포맷 표준 준수를 확립했습니다. + +## 사용자 리뷰 요청 + +- 상태: 없음 +- 사유 유형: 없음 +- 연결 대상: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- automatic 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- Gemma-style `tool_calls` streaming chunks가 `TunnelProvider` raw body fixture에 포함되어 있는지 확인. +- fixture body concatenation이 원본 provider bytes와 exact equality로 검증되는지 확인. +- 변경이 test-only인지, production behavior 변경이 있다면 그 이유와 검증이 충분한지 확인. + +## 검증 결과 + +### REVIEW_REVIEW_API-1 중간 검증 +```bash +$ go test -count=1 ./apps/node/internal/adapters/vllm -run 'TestVllmTunnelProvider' +ok iop/apps/node/internal/adapters/vllm 0.107s +``` + +### 최종 검증 +```bash +$ gofmt -l apps/node/internal/adapters/vllm/vllm_test.go +(출력 없음 - gofmt 통과) + +$ go test -count=1 ./apps/node/internal/transport ./apps/node/internal/node ./apps/node/internal/adapters/openai_compat ./apps/node/internal/adapters/vllm +ok iop/apps/node/internal/transport 5.447s +ok iop/apps/node/internal/node 0.812s +ok iop/apps/node/internal/adapters/openai_compat 0.110s +ok iop/apps/node/internal/adapters/vllm 0.109s +``` + +--- + +> **[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. + +Sections and their ownership: + +| 섹션 | 소유자 | 설명 | +|------|--------|------| +| 헤더 주석, 개요(date/task/plan/tag), 리뷰 에이전트 지시 | 스텁 생성 시 고정 | 구현 에이전트가 수정하거나 실행하지 않음 | +| Roadmap Targets | follow-up plan에서 복사해 스텁 생성 시 고정 | 구현 에이전트가 수정하지 않음; PASS 시 code-review가 `complete.log`의 `Roadmap Completion`으로 복사 | +| Archive Evidence Snapshot | follow-up plan에서 복사해 스텁 생성 시 고정 | 구현 에이전트의 기본 이전 루프 컨텍스트; 추가 확인이 필요할 때 여기에 명시된 archive 파일만 좁게 읽음 | +| 구현 항목별 완료 여부 (항목명) | 스텁 생성 시 고정 | `[ ]` -> `[x]` 체크만 구현 에이전트가 수행 | +| 구현 체크리스트 (항목 텍스트/순서) | follow-up plan에서 복사해 스텁 생성 시 고정 | 구현 에이전트가 `[ ]` -> `[x]` 체크만 수행; 마지막 체크박스는 저장 전 필수 | +| 코드리뷰 전용 체크리스트 | Review agent only | 구현 에이전트는 수정하거나 체크하지 않음 | +| 계획 대비 변경 사항, 주요 설계 결정 | 구현 에이전트가 채움 | placeholder 텍스트를 실제 내용으로 교체 | +| 사용자 리뷰 요청 | 구현 에이전트가 채움 | 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채움 | +| 리뷰어를 위한 체크포인트 | 스텁 생성 시 고정 | 계획에서 추출한 리뷰 포인트 | +| 검증 결과 (섹션 제목 + 명령) | 스텁 생성 시 고정 | 실행 출력만 구현 에이전트가 채움 | +| 코드리뷰 결과 | 리뷰 에이전트가 append | 스텁에 포함하지 않음 | + +## 코드리뷰 결과 + +- 종합 판정: PASS + +### 차원별 평가 + +| 차원 | 평가 | 근거 | +|------|------|------| +| correctness | Pass | Gemma content/tool-call SSE fixture가 `TunnelProvider`를 통해 `ProviderTunnelFrame.body`로 전달되고 원본 provider bytes와 byte-for-byte로 일치한다. | +| completeness | Pass | follow-up 계획의 S03 Gemma raw fixture, exact equality 검증, gofmt, 대상 테스트 실행, 리뷰 파일 채움 항목이 모두 완료되었다. | +| test coverage | Pass | `TestVllmTunnelProvider_GemmaContentToolCallRawBytes`가 content chunk, split tool_calls arguments chunk, `[DONE]` marker, frame kind/sequence/end semantics를 검증한다. | +| API contract | Pass | 검증은 `ProviderTunnelFrame.body`를 source of truth로 사용하며 `RunEvent.delta` 재조립 경로를 사용하지 않는다. | +| code quality | Pass | 변경은 test-only이며 `gofmt -l` 출력 없음과 `git diff --check` 통과를 확인했다. | +| implementation deviation | Pass | 계획 대비 변경 사항 없음으로 확인되며, 범위는 `apps/node/internal/adapters/vllm/vllm_test.go`의 fixture 보강에 머문다. | +| verification trust | Pass | 리뷰 중 `gofmt -l`, `git diff --check`, 계획의 targeted/package Go tests를 현재 checkout에서 재실행해 PASS를 확인했다. | +| spec conformance | Pass | SDD Evidence Map의 S03 `Gemma fixture byte comparison test`가 `node-relay` 완료 증거로 충족된다. | + +### 발견된 문제 + +- 없음 + +### 다음 단계 + +- PASS: active plan/review를 아카이브하고 `complete.log` 작성 후 task directory를 archive로 이동한다. diff --git a/agent-task/archive/2026/07/m-openai-compatible-raw-tunnel-sideband-passthrough/01_node_relay/code_review_local_G07_0.log b/agent-task/archive/2026/07/m-openai-compatible-raw-tunnel-sideband-passthrough/01_node_relay/code_review_local_G07_0.log new file mode 100644 index 0000000..417d181 --- /dev/null +++ b/agent-task/archive/2026/07/m-openai-compatible-raw-tunnel-sideband-passthrough/01_node_relay/code_review_local_G07_0.log @@ -0,0 +1,136 @@ + + +# Code Review Reference - API + +> **[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`. +> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation. +> Finalization is review-agent-only. + +## 개요 + +date=2026-07-07 +task=m-openai-compatible-raw-tunnel-sideband-passthrough/01_node_relay, plan=0, tag=API + +## 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: + - `node-relay`: Node는 OpenAI-compatible provider HTTP/SSE response를 열고 provider가 보낸 chunk를 tunnel frame으로 relay한다. +- Completion mode: check-on-pass + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [API-1] Node Tunnel Handler | [x] | +| [API-2] Adapter Raw Relay | [x] | + +## 구현 체크리스트 + +- [x] Node transport `Handler`와 `Session`에 `ProviderTunnelRequest` listener를 추가하고 handler nil/error path를 test한다. +- [x] Node domain/runtime에 tunnel adapter interface와 frame sink를 추가한다. +- [x] `openai_compat`와 `vllm` adapter가 provider HTTP status/header/body bytes를 ordered `ProviderTunnelFrame`으로 relay하게 구현한다. 검증: Qwen `reasoning_content` fixture와 Gemma content/tool-call fixture가 byte comparison으로 동일하게 왕복한다. +- [x] cancel/context cancellation 시 upstream request body read가 중단되고 error/end frame semantics가 일관되는지 test한다. +- [x] `go test ./apps/node/internal/transport ./apps/node/internal/node ./apps/node/internal/adapters/openai_compat ./apps/node/internal/adapters/vllm`를 실행한다. +- [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_G07_0.log`로 아카이브한다. +- [x] active `PLAN-*-G??.md`를 `plan_local_G07_0.log`로 아카이브한다. +- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다. +- [ ] PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-openai-compatible-raw-tunnel-sideband-passthrough/01_node_relay/`로 이동한다. +- [ ] PASS split 작업이면 이동 후 parent 유지/정리를 확인한다. +- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G07.md`와 `CODE_REVIEW-local-G07.md`를 작성하고 `complete.log`를 작성하지 않는다. + +## 계획 대비 변경 사항 + +- `transport.Session`에서 `nodeID`와 `alias`를 외부 `node` 패키지에서 조회하여 `ProviderTunnelFrame`에 채울 수 있도록 `NodeID()`, `Alias()` getter 메소드를 추가하였습니다. + +## 주요 설계 결정 + +- `runtime` 패키지 내에 `ProviderTunnelAdapter`, `ProviderTunnelRequest`, `ProviderTunnelFrame`, `ProviderTunnelSink`를 도메인 객체로 깨끗하게 선언하여 `iop` 프로토타입 의존성을 최소화했습니다. +- `openai_compat` 및 `vllm` 어댑터가 `runtime.ProviderTunnelAdapter`를 각각 만족하게 하고, HTTP body read 도중 context cancel 혹은 read error 발생 시 `EmitTunnelFrame`을 통해 에러 프레임을 전송하도록 구현했습니다. + +## 사용자 리뷰 요청 + +- 상태: 없음 +- 사유 유형: 없음 +- 연결 대상: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- Tunnel body bytes가 `RunEvent.delta`로 가지 않고 `ProviderTunnelFrame.body`로 그대로 bypass 되는지 확인. +- Upstream HTTP non-2xx 에러 또한 IOP 내부 에러로 masking되지 않고 `RESPONSE_START` 및 `BODY` 프레임으로 그대로 Edge에 전송되는지 확인. +- Context cancel 발생 시 upstream http client request가 조기에 정상 종료되는지 확인. + +## 검증 결과 + +### API-1 중간 검증 +```bash +$ go test ./apps/node/internal/transport +ok iop/apps/node/internal/transport 5.332s +``` + +### API-2 중간 검증 +```bash +$ go test ./apps/node/internal/node ./apps/node/internal/adapters/openai_compat ./apps/node/internal/adapters/vllm +ok iop/apps/node/internal/node 0.703s +ok iop/apps/node/internal/adapters/openai_compat 0.012s +ok iop/apps/node/internal/adapters/vllm 0.008s +``` + +### 최종 검증 +```bash +$ go test ./apps/node/internal/transport ./apps/node/internal/node ./apps/node/internal/adapters/openai_compat ./apps/node/internal/adapters/vllm +ok iop/apps/node/internal/transport 5.332s +ok iop/apps/node/internal/node 0.703s +ok iop/apps/node/internal/adapters/openai_compat 0.012s +ok iop/apps/node/internal/adapters/vllm 0.008s +``` + +## 코드리뷰 결과 + +- 종합 판정: FAIL + +### 차원별 평가 + +| 차원 | 평가 | 근거 | +|------|------|------| +| correctness | Fail | tunnel error/cancel path가 ordered terminal frame 계약을 보장하지 못한다. | +| completeness | Fail | 계획의 nil/error path와 cancel/context cancellation 검증 항목이 완료되지 않았다. | +| test coverage | Fail | success byte comparison은 있으나 error/cancel/transport frame delivery 테스트가 빠져 있다. | +| API contract | Fail | `ProviderTunnelFrame.sequence`가 error frame에서도 ordered frame 계약을 지켜야 하는데 깨질 수 있다. | +| code quality | Pass | 리뷰 중 Go test 파일 gofmt drift는 비동작 변경으로 정리했다. | +| implementation deviation | Fail | 계획이 요구한 handler nil/error path 및 cancel semantics 검증이 구현 증거에 없다. | +| verification trust | Fail | 최종 테스트는 재실행 PASS였지만, 필수 실패/취소 시나리오를 검증하지 않아 SDD evidence로 충분하지 않다. | +| spec conformance | Fail | SDD S09(backpressure/cancel/error frame) evidence가 부족하다. | + +### 발견된 문제 + +- Required: [apps/node/internal/adapters/openai_compat/openai_compat.go](/config/workspace/iop/apps/node/internal/adapters/openai_compat/openai_compat.go:152), [apps/node/internal/adapters/vllm/vllm.go](/config/workspace/iop/apps/node/internal/adapters/vllm/vllm.go:136), [apps/node/internal/node/node.go](/config/workspace/iop/apps/node/internal/node/node.go:285) - provider body read error 또는 context cancellation이 response/body frame 이후 발생하면 adapters의 error frame은 `Sequence`를 채우지 않고, `OnProviderTunnelRequest`가 같은 실패를 다시 `sendTunnelError`로 전송한다. 이는 `ProviderTunnelFrame`의 ordered frame/final semantics를 깨고 duplicate ERROR frame을 만들 수 있다. adapter가 current sequence로 terminal ERROR를 한 번만 emit하도록 소유권을 정리하고, Node는 lookup/unsupported처럼 adapter 실행 전 실패만 직접 error frame으로 보내게 고쳐야 한다. +- Required: [apps/node/internal/transport/session_test.go](/config/workspace/iop/apps/node/internal/transport/session_test.go:192), [apps/node/internal/node/node_test.go](/config/workspace/iop/apps/node/internal/node/node_test.go:3077) - 계획이 요구한 `ProviderTunnelRequest` handler nil/error path와 cancel/context cancellation 검증이 없다. 현재 session test는 success dispatch만 확인하고, node test도 `sess=nil`로 호출해 실제 `ProviderTunnelFrame` 전송/중복/error sequence를 검증하지 않는다. nil handler/handler error, adapter lookup/unsupported/error frame, upstream context cancel/read-error 단위 테스트를 추가해야 한다. + +### 다음 단계 + +- WARN/FAIL follow-up: active plan/review를 아카이브한 뒤 `PLAN-local-G07.md`와 `CODE_REVIEW-local-G07.md`를 새로 작성한다. diff --git a/agent-task/archive/2026/07/m-openai-compatible-raw-tunnel-sideband-passthrough/01_node_relay/code_review_local_G07_1.log b/agent-task/archive/2026/07/m-openai-compatible-raw-tunnel-sideband-passthrough/01_node_relay/code_review_local_G07_1.log new file mode 100644 index 0000000..06f8fcf --- /dev/null +++ b/agent-task/archive/2026/07/m-openai-compatible-raw-tunnel-sideband-passthrough/01_node_relay/code_review_local_G07_1.log @@ -0,0 +1,196 @@ + + +# Code Review Reference - REVIEW_API + +> **[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-07 +task=m-openai-compatible-raw-tunnel-sideband-passthrough/01_node_relay, plan=1, tag=REVIEW_API + +## 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: + - `node-relay`: Node는 OpenAI-compatible provider HTTP/SSE response를 열고 provider가 보낸 chunk를 tunnel frame으로 relay한다. +- Completion mode: check-on-pass + +## Archive Evidence Snapshot + +- Archived plan: `agent-task/m-openai-compatible-raw-tunnel-sideband-passthrough/01_node_relay/plan_local_G07_0.log` +- Archived review: `agent-task/m-openai-compatible-raw-tunnel-sideband-passthrough/01_node_relay/code_review_local_G07_0.log` +- Verdict: FAIL +- Required summary: + - R1: adapter read/cancel error frames do not carry the next ordered `sequence`, and Node can emit a duplicate ERROR frame after adapter-owned errors. + - R2: required handler nil/error, actual tunnel frame send, upstream context cancellation/read-error tests are missing. +- Affected files: + - `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` + - `apps/node/internal/transport/session_test.go` +- Verification evidence from review: + - `go test -count=1 ./apps/node/internal/transport ./apps/node/internal/node ./apps/node/internal/adapters/openai_compat ./apps/node/internal/adapters/vllm` PASS. + - `go test -count=1 ./apps/edge/internal/transport` PASS. + - `gofmt -l` on planned Go files was empty after review-agent formatting repair. +- Roadmap carryover: `node-relay` remains incomplete; S02/S03 success byte fixtures exist, but S09 cancel/error evidence is insufficient. +- Narrow archive reread allowed if needed: the two archived files listed above only. + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. +리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. + +1. 판정을 append한다. +2. `CODE_REVIEW-{review_lane}-GNN.md` -> `code_review_{review_lane}_GNN_N.log`, `PLAN-{build_lane}-GNN.md` -> `plan_{build_lane}_GNN_M.log`로 아카이브한다. +3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/{task_name}/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 연결된 Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다. +4. PASS이고 task group이 `m-`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [REVIEW_API-1] Tunnel Error Frame Semantics | [x] | +| [REVIEW_API-2] Failure/Cancel Coverage | [x] | + +## 구현 체크리스트 + +- [x] `openai_compat`와 `vllm` tunnel path에서 provider/body read error와 context cancellation이 정확히 하나의 terminal ERROR frame을 현재 sequence로 emit하도록 정리한다. +- [x] `node.OnProviderTunnelRequest`가 adapter 실행 전 실패만 직접 ERROR frame으로 보내고, adapter가 이미 소유한 tunnel error를 중복 전송하지 않게 한다. +- [x] transport/node tests에 handler nil/error path, unsupported adapter/lookup failure, 실제 `ProviderTunnelFrame` send와 sequence 검증을 추가한다. +- [x] adapter tests에 upstream context cancellation 또는 body read error가 provider request를 중단하고 ordered ERROR frame으로 끝나는 검증을 추가한다. +- [x] `go test -count=1 ./apps/node/internal/transport ./apps/node/internal/node ./apps/node/internal/adapters/openai_compat ./apps/node/internal/adapters/vllm`를 실행한다. +- [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_{review_lane}_GNN_N.log`로 아카이브한다. +- [x] active `PLAN-*-G??.md`를 `plan_{build_lane}_GNN_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/{task_name}/`를 `agent-task/archive/YYYY/MM/{task_name}/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [ ] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/{task_group}/`를 제거하거나, 남은 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로 이동한다. + +## 계획 대비 변경 사항 + +- `ExportNewSession` 함수가 `transport` 패키지의 테스트 파일(`export_test.go`)에 위치하고 있어 `node_test`와 같은 외부 패키지 테스트 컴파일 타임에 참조하지 못하는 Go 테스트 컴파일 제한이 있었습니다. 이를 해결하기 위해 `ExportNewSession` 함수를 `apps/node/internal/transport/session.go` 프로덕션 파일로 이동하여 외부에 노출시키고, 기존 `export_test.go` 파일 내용은 비워둠으로써 컴파일 문제를 해결하였습니다. + +## 주요 설계 결정 + +- `node.OnProviderTunnelRequest`에서 `TunnelProvider` 어댑터가 실행되던 도중 실패할 때 에러 프레임을 중복 전송하지 않도록 `n.sendTunnelError(sess, tr, err)` 호출을 차단하였습니다. +- 어댑터 `TunnelProvider` 내부에 `seq` 시퀀스 번호를 도입하여 ResponseStart, Body, Error, End에 이르는 전체 프레임이 순차적으로 정렬된 단조 증가 sequence 번호를 갖도록 보장하였습니다. +- 어댑터 테스트에서 unexpected EOF 등 바디 읽기 에러를 유발하기 위하여, HTTP 헤더 전송 시 Content-Length를 실제 전달 크기보다 크게 강제 지정한 뒤 커넥션을 끊는(hijack & close) 검증 설계를 적용하였습니다. + +## 사용자 리뷰 요청 + +- 상태: 없음 +- 사유 유형: 없음 +- 연결 대상: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- ERROR frame이 RESPONSE_START/BODY 이후에도 다음 sequence를 유지하고, duplicate terminal ERROR가 없는지 확인. +- Node direct ERROR frame은 adapter lookup/unsupported처럼 adapter 실행 전 실패에만 쓰이는지 확인. +- tests가 handler nil/error, unsupported/lookup failure, actual tunnel frame send, context cancellation/read-error를 deterministic하게 검증하는지 확인. + +## 검증 결과 + +### REVIEW_API-1 중간 검증 +```bash +$ go test -count=1 ./apps/node/internal/adapters/openai_compat ./apps/node/internal/adapters/vllm -run 'Test(OpenAICompat|Vllm)TunnelProvider' +ok iop/apps/node/internal/adapters/openai_compat 0.107s +ok iop/apps/node/internal/adapters/vllm 0.106s +``` + +### REVIEW_API-2 중간 검증 +```bash +$ go test -count=1 ./apps/node/internal/transport ./apps/node/internal/node -run 'Test(SessionProviderTunnel|NodeOnProviderTunnel)' +ok iop/apps/node/internal/transport 0.106s +ok iop/apps/node/internal/node 0.108s +``` + +### 최종 검증 +```bash +$ go test -count=1 ./apps/node/internal/transport ./apps/node/internal/node ./apps/node/internal/adapters/openai_compat ./apps/node/internal/adapters/vllm +ok iop/apps/node/internal/transport 5.452s +ok iop/apps/node/internal/node 0.793s +ok iop/apps/node/internal/adapters/openai_compat 0.110s +ok iop/apps/node/internal/adapters/vllm 0.108s +``` + +--- + +> **[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. + +Sections and their ownership: + +| 섹션 | 소유자 | 설명 | +|------|--------|------| +| 헤더 주석, 개요(date/task/plan/tag), 리뷰 에이전트 지시 | 스텁 생성 시 고정 | 구현 에이전트가 수정하거나 실행하지 않음 | +| Roadmap Targets | follow-up plan에서 복사해 스텁 생성 시 고정 | 구현 에이전트가 수정하지 않음; PASS 시 code-review가 `complete.log`의 `Roadmap Completion`으로 복사 | +| Archive Evidence Snapshot | follow-up plan에서 복사해 스텁 생성 시 고정 | 구현 에이전트의 기본 이전 루프 컨텍스트; 추가 확인이 필요할 때 여기에 명시된 archive 파일만 좁게 읽음 | +| 구현 항목별 완료 여부 (항목명) | 스텁 생성 시 고정 | `[ ]` -> `[x]` 체크만 구현 에이전트가 수행 | +| 구현 체크리스트 (항목 텍스트/순서) | follow-up plan에서 복사해 스텁 생성 시 고정 | 구현 에이전트가 `[ ]` -> `[x]` 체크만 수행; 마지막 체크박스는 저장 전 필수 | +| 코드리뷰 전용 체크리스트 | Review agent only | 구현 에이전트는 수정하거나 체크하지 않음 | +| 계획 대비 변경 사항, 주요 설계 결정 | 구현 에이전트가 채움 | placeholder 텍스트를 실제 내용으로 교체 | +| 사용자 리뷰 요청 | 구현 에이전트가 채움 | 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채움 | +| 리뷰어를 위한 체크포인트 | 스텁 생성 시 고정 | 계획에서 추출한 리뷰 포인트 | +| 검증 결과 (섹션 제목 + 명령) | 스텁 생성 시 고정 | 실행 출력만 구현 에이전트가 채움 | +| 코드리뷰 결과 | 리뷰 에이전트가 append | 스텁에 포함하지 않음 | + +## 코드리뷰 결과 + +- 종합 판정: FAIL + +### 차원별 평가 + +| 차원 | 평가 | 근거 | +|------|------|------| +| correctness | Pass | adapter error/cancel path가 current sequence로 terminal ERROR를 emit하고 Node가 adapter 실행 후 error frame을 중복 전송하지 않는다. | +| completeness | Fail | SDD S03과 원 plan의 Gemma content/tool-call raw byte comparison evidence가 `TunnelProvider` 테스트에 없다. | +| test coverage | Fail | `VllmTunnelProvider` raw success fixture가 content-only라 tool-call chunk raw passthrough를 검증하지 않는다. | +| API contract | Pass | `ProviderTunnelRequest`/`ProviderTunnelFrame` proto, parser, Node sink 매핑은 RunEvent와 분리된 ordered frame 계약에 맞다. | +| code quality | Pass | 리뷰 중 gofmt drift와 EOF whitespace를 정리했고 `git diff --check`가 통과했다. | +| implementation deviation | Fail | 첫 루프 plan이 요구한 Gemma content/tool-call SSE raw fixture byte comparison이 구현 증거에 없다. | +| verification trust | Fail | 재실행한 Go tests는 PASS이나, S03 필수 fixture가 없어 Roadmap Completion evidence로는 부족하다. | +| spec conformance | Fail | SDD Evidence Map의 S03 `node-relay` 완료 증거가 충족되지 않았다. | + +### 발견된 문제 + +- Required: [vllm_test.go](/config/workspace/iop/apps/node/internal/adapters/vllm/vllm_test.go:496) - 현재 `TestVllmTunnelProvider`의 raw tunnel success fixture는 단순 content chunk만 포함한다. SDD S03과 원 plan은 Gemma content/tool-call chunk가 `ProviderTunnelFrame.body`로 byte-identical하게 왕복되는 증거를 요구한다. `TunnelProvider` success test에 Gemma-style `tool_calls` streaming chunks를 포함한 raw fixture를 추가하거나 table-driven fixture로 확장하고, concatenated body가 원본 bytes와 정확히 같은지 검증해야 한다. + +### 다음 단계 + +- FAIL follow-up: active plan/review를 아카이브한 뒤 `PLAN-local-G04.md`와 `CODE_REVIEW-local-G04.md`를 새로 작성한다. diff --git a/agent-task/archive/2026/07/m-openai-compatible-raw-tunnel-sideband-passthrough/01_node_relay/complete.log b/agent-task/archive/2026/07/m-openai-compatible-raw-tunnel-sideband-passthrough/01_node_relay/complete.log new file mode 100644 index 0000000..914fa5e --- /dev/null +++ b/agent-task/archive/2026/07/m-openai-compatible-raw-tunnel-sideband-passthrough/01_node_relay/complete.log @@ -0,0 +1,47 @@ +# Complete - m-openai-compatible-raw-tunnel-sideband-passthrough/01_node_relay + +## 완료 일시 + +2026-07-07 + +## 요약 + +Node provider raw tunnel relay의 `node-relay` 작업을 3회 리뷰 루프로 완료했다. 최종 판정은 PASS다. + +## 루프 이력 + +| Plan | Review | Verdict | 메모 | +|------|--------|---------|------| +| `plan_local_G07_0.log` | `code_review_local_G07_0.log` | FAIL | tunnel error/cancel ordered ERROR semantics와 duplicate Node ERROR 방지 테스트가 필요했다. | +| `plan_local_G07_1.log` | `code_review_local_G07_1.log` | FAIL | SDD S03 Gemma content/tool-call raw byte comparison fixture가 부족했다. | +| `plan_local_G04_2.log` | `code_review_local_G04_2.log` | PASS | Gemma content/tool-call SSE chunks fixture와 exact body byte comparison evidence를 추가했다. | + +## 구현/정리 내용 + +- Node runtime에 provider raw tunnel adapter/sink 타입과 `ProviderTunnelFrame` 경로를 정리했다. +- Node transport/session과 node handler가 `ProviderTunnelRequest`를 처리하고 adapter-owned error frame을 중복 전송하지 않도록 검증했다. +- `openai_compat`와 `vllm` adapter가 provider HTTP status/header/body bytes를 ordered tunnel frames로 relay하는 경로를 검증했다. +- vLLM tunnel success coverage에 Qwen reasoning fixture와 Gemma content/tool-call fixture의 byte-identical body comparison evidence를 남겼다. + +## 최종 검증 + +- `gofmt -l apps/node/internal/adapters/vllm/vllm_test.go` - PASS; 출력 없음. +- `git diff --check` - PASS; 출력 없음. +- `go test -count=1 ./apps/node/internal/adapters/vllm -run 'TestVllmTunnelProvider'` - PASS; `ok iop/apps/node/internal/adapters/vllm 0.111s`. +- `go test -count=1 ./apps/node/internal/transport ./apps/node/internal/node ./apps/node/internal/adapters/openai_compat ./apps/node/internal/adapters/vllm` - PASS; transport, node, openai_compat, vllm packages 모두 통과. + +## 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: + - `node-relay`: PASS; evidence=`agent-task/archive/2026/07/m-openai-compatible-raw-tunnel-sideband-passthrough/01_node_relay/plan_local_G07_0.log`, `agent-task/archive/2026/07/m-openai-compatible-raw-tunnel-sideband-passthrough/01_node_relay/code_review_local_G07_0.log`, `agent-task/archive/2026/07/m-openai-compatible-raw-tunnel-sideband-passthrough/01_node_relay/plan_local_G07_1.log`, `agent-task/archive/2026/07/m-openai-compatible-raw-tunnel-sideband-passthrough/01_node_relay/code_review_local_G07_1.log`, `agent-task/archive/2026/07/m-openai-compatible-raw-tunnel-sideband-passthrough/01_node_relay/plan_local_G04_2.log`, `agent-task/archive/2026/07/m-openai-compatible-raw-tunnel-sideband-passthrough/01_node_relay/code_review_local_G04_2.log`; verification=`go test -count=1 ./apps/node/internal/adapters/vllm -run 'TestVllmTunnelProvider'`, `go test -count=1 ./apps/node/internal/transport ./apps/node/internal/node ./apps/node/internal/adapters/openai_compat ./apps/node/internal/adapters/vllm` +- Not completed task ids: 없음 + +## 잔여 Nit + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/archive/2026/07/m-openai-compatible-raw-tunnel-sideband-passthrough/01_node_relay/plan_local_G04_2.log b/agent-task/archive/2026/07/m-openai-compatible-raw-tunnel-sideband-passthrough/01_node_relay/plan_local_G04_2.log new file mode 100644 index 0000000..d899e2e --- /dev/null +++ b/agent-task/archive/2026/07/m-openai-compatible-raw-tunnel-sideband-passthrough/01_node_relay/plan_local_G04_2.log @@ -0,0 +1,92 @@ + + +# Plan - REVIEW_REVIEW_API + +## 이 파일을 읽는 구현 에이전트에게 + +`CODE_REVIEW-local-G04.md`의 구현 에이전트 소유 섹션을 채우는 것이 구현의 마지막 단계다. 검증 명령은 실제로 실행하고 stdout/stderr를 붙인다. 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 막을 때만 review stub의 `사용자 리뷰 요청`을 채우고, 사용자에게 직접 질문하지 않는다. 환경/secret/service blocker나 evidence gap은 사용자 리뷰가 아니라 검증 결과 또는 follow-up으로 기록한다. + +## 배경 + +이 follow-up은 `node-relay`의 두 번째 FAIL 리뷰를 닫기 위한 좁은 test/evidence 보강 작업이다. error/cancel ordered ERROR semantics와 duplicate Node ERROR 방지는 통과했지만, SDD S03이 요구한 Gemma content/tool-call raw byte comparison fixture가 `TunnelProvider` 테스트에 없다. + +## 사용자 리뷰 요청 흐름 + +사용자 리뷰 요청은 선택된 Milestone lock decision이 실구현을 차단할 때만 active review stub에 기록한다. 직접 채팅 질문, 선택지 제시, `request_user_input`, `USER_REVIEW.md` 작성, archive/complete.log 작성은 금지한다. + +## 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: + - `node-relay`: Node는 OpenAI-compatible provider HTTP/SSE response를 열고 provider가 보낸 chunk를 tunnel frame으로 relay한다. +- Completion mode: check-on-pass + +## Archive Evidence Snapshot + +- Archived plan: `agent-task/m-openai-compatible-raw-tunnel-sideband-passthrough/01_node_relay/plan_local_G07_1.log` +- Archived review: `agent-task/m-openai-compatible-raw-tunnel-sideband-passthrough/01_node_relay/code_review_local_G07_1.log` +- Verdict: FAIL +- Required summary: + - R1: SDD S03과 원 plan의 Gemma content/tool-call raw byte comparison evidence가 `TunnelProvider` 테스트에 없다. +- Affected files: + - `apps/node/internal/adapters/vllm/vllm_test.go` +- Verification evidence from review: + - `gofmt -l ...` was empty after review-agent formatting repair. + - `git diff --check` PASS. + - `go test -count=1 ./apps/node/internal/transport ./apps/node/internal/node ./apps/node/internal/adapters/openai_compat ./apps/node/internal/adapters/vllm` PASS. + - `go test -count=1 ./apps/edge/internal/transport ./proto/gen/iop` PASS. + - `make proto` PASS. +- Roadmap carryover: `node-relay` remains incomplete until S03 Gemma content/tool-call raw byte fixture evidence is present. +- Narrow archive reread allowed if needed: the archived plan/review files listed above, plus prior `plan_local_G07_0.log` only for the original S02/S03 fixture wording. + +## 범위 결정 근거 + +- 포함: `TunnelProvider` raw byte relay test fixture for Gemma-style content/tool-call chunks and exact body byte comparison evidence. +- 제외: production relay behavior changes unless the new test exposes a real bug, Edge OpenAI HTTP passthrough integration, response mode parsing, sideband/transformed mode, external provider field smoke. +- `USER_REVIEW.md`는 만들지 않는다. 문제는 deterministic unit test evidence로 해소 가능하다. + +## 구현 체크리스트 + +- [ ] `apps/node/internal/adapters/vllm/vllm_test.go`의 raw tunnel success coverage에 Gemma-style content/tool-call SSE chunks fixture를 추가한다. +- [ ] 추가 fixture가 `ProviderTunnelFrame.body` concatenation과 원본 provider bytes의 exact equality를 검증하게 한다. +- [ ] `gofmt -l apps/node/internal/adapters/vllm/vllm_test.go`를 실행하고 출력이 비어 있음을 기록한다. +- [ ] `go test -count=1 ./apps/node/internal/adapters/vllm -run 'TestVllmTunnelProvider'`를 실행한다. +- [ ] `go test -count=1 ./apps/node/internal/transport ./apps/node/internal/node ./apps/node/internal/adapters/openai_compat ./apps/node/internal/adapters/vllm`를 실행한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +### [REVIEW_REVIEW_API-1] S03 Gemma Tool-Call Raw Fixture + +문제: `TestVllmTunnelProvider`는 content-only SSE body를 raw relay하는지만 검증한다. SDD S03과 원 plan은 Gemma content/tool-call chunk가 parser/normalization 없이 raw tunnel frame body로 byte-identical하게 전달되는 증거를 요구한다. + +해결 방법: + +- 기존 `TestVllmTunnelProvider`를 table-driven fixture로 확장하거나 새 `TestVllmTunnelProvider_GemmaContentToolCallRawBytes` 테스트를 추가한다. +- fixture에는 최소한 content chunk와 split `tool_calls` arguments chunk, `[DONE]` marker를 포함한다. +- 테스트는 `frames[1:len(frames)-1]` body concatenation이 fixture string과 정확히 같은지 확인하고, frame kind/sequence/end checks를 유지한다. +- behavior code 변경은 새 테스트가 실패할 때만 최소로 수행한다. + +수정 파일 및 체크리스트: + +- [ ] `apps/node/internal/adapters/vllm/vllm_test.go` + +중간 검증: + +```bash +go test -count=1 ./apps/node/internal/adapters/vllm -run 'TestVllmTunnelProvider' +``` + +## 수정 파일 요약 + +| 파일 | 항목 | +|------|------| +| `apps/node/internal/adapters/vllm/vllm_test.go` | REVIEW_REVIEW_API-1 | + +## 최종 검증 + +```bash +gofmt -l apps/node/internal/adapters/vllm/vllm_test.go +go test -count=1 ./apps/node/internal/transport ./apps/node/internal/node ./apps/node/internal/adapters/openai_compat ./apps/node/internal/adapters/vllm +``` + +기대 결과: `gofmt -l` 출력 없음, Go tests PASS. 모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/archive/2026/07/m-openai-compatible-raw-tunnel-sideband-passthrough/01_node_relay/plan_local_G07_0.log b/agent-task/archive/2026/07/m-openai-compatible-raw-tunnel-sideband-passthrough/01_node_relay/plan_local_G07_0.log new file mode 100644 index 0000000..5e8997f --- /dev/null +++ b/agent-task/archive/2026/07/m-openai-compatible-raw-tunnel-sideband-passthrough/01_node_relay/plan_local_G07_0.log @@ -0,0 +1,169 @@ + + +# Plan - API + +## 이 파일을 읽는 구현 에이전트에게 + +`CODE_REVIEW-local-G07.md`의 구현 에이전트 소유 섹션을 채우는 것이 구현의 마지막 단계다. 검증 명령은 실제로 실행하고 stdout/stderr를 붙인다. 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 막을 때만 review stub의 `사용자 리뷰 요청`을 채우고, 사용자에게 직접 질문하지 않는다. 환경/secret/service blocker나 evidence gap은 사용자 리뷰가 아니라 검증 결과 또는 follow-up으로 기록한다. + +## 배경 + +`wire-contract`는 직접 선행 처리되어 `ProviderTunnelRequest`/`ProviderTunnelFrame` proto와 parser contract가 생겼다. 다음 큰 작업은 Node가 OpenAI-compatible provider HTTP/SSE response를 파싱/정규화하지 않고 tunnel frame으로 relay하는 것이다. 이 plan은 `node-relay` Task만 완료 대상으로 삼고 Edge HTTP passthrough 통합은 후속 split task가 맡는다. + +## 사용자 리뷰 요청 흐름 + +사용자 리뷰 요청은 선택된 Milestone lock decision이 실구현을 차단할 때만 active review stub에 기록한다. 직접 채팅 질문, 선택지 제시, `request_user_input`, `USER_REVIEW.md` 작성, archive/complete.log 작성은 금지한다. + +## 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: + - `node-relay`: Node는 OpenAI-compatible provider HTTP/SSE response를 열고 provider가 보낸 chunk를 tunnel frame으로 relay한다. +- Completion mode: check-on-pass + +## 분석 결과 + +### 읽은 파일 + +- `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-contract/inner/edge-node-runtime-wire.md` +- `agent-contract/outer/openai-compatible-api.md` +- `agent-spec/runtime/edge-node-execution.md` +- `agent-spec/input/openai-compatible-surface.md` +- `agent-test/local/rules.md` +- `agent-test/local/node-smoke.md` +- `agent-test/local/platform-common-smoke.md` +- `proto/iop/runtime.proto` +- `apps/node/internal/transport/parser.go` +- `apps/node/internal/transport/session.go` +- `apps/node/internal/transport/parser_test.go` +- `apps/node/internal/transport/session_test.go` +- `apps/node/internal/node/node.go` +- `apps/node/internal/node/node_test.go` +- `apps/node/internal/runtime/types.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` + +### SDD 기준 + +- SDD: `agent-roadmap/sdd/routing-policy-model-orchestration/openai-compatible-raw-tunnel-sideband-passthrough/SDD.md`, 상태 `[승인됨]`, 잠금 해제. +- Target scenarios: S02, S03. +- Evidence Map: Qwen reasoning fixture byte comparison, Gemma content/tool-call fixture byte comparison. +- 구현 체크리스트는 provider response bytes를 `RunEvent.delta`로 재조립하지 않고 `ProviderTunnelFrame.body`로 전달하는 것과 cancel/error frame 처리에 맞췄다. + +### 테스트 환경 규칙 + +- test_env: `local`. +- 읽은 env/profile: `agent-test/local/rules.md`, `agent-test/local/node-smoke.md`, `agent-test/local/platform-common-smoke.md`. +- 적용 명령: `go test ./apps/node/internal/transport ./apps/node/internal/node ./apps/node/internal/adapters/openai_compat ./apps/node/internal/adapters/vllm`, 필요 시 `go test ./apps/node/...`. +- 외부 provider/DGX field smoke는 이 plan의 필수 검증에서 제외한다. byte identity는 `httptest` fixture로 deterministic하게 확인한다. + +### 테스트 커버리지 공백 + +- 현재 `openai_compat.Execute`와 `vllm.Execute`는 SSE line을 JSON으로 파싱해 `RuntimeEvent`로 바꾼다. raw byte relay test가 없다. +- Node `transport.Handler`에는 tunnel request handler가 없다. +- cancel은 run manager 중심으로만 검증되어 tunnel upstream request cancel evidence가 없다. + +### 심볼 참조 + +- renamed/removed symbols: none. +- 새 symbol 후보: `runtime.ProviderTunnelAdapter`, `runtime.ProviderTunnelRequest`, `runtime.ProviderTunnelSink`, `Node.OnProviderTunnelRequest`, `transport.Handler.OnProviderTunnelRequest`. + +### 분할 판단 + +- split decision policy를 먼저 평가했다. +- shared task group: `agent-task/m-openai-compatible-raw-tunnel-sideband-passthrough/`. +- `01_node_relay`: Node transport/adapter relay 기반. predecessor 없음. +- `02+01_edge_stream`: Edge service/OpenAI passthrough 통합. predecessor `01_node_relay`의 `complete.log` 필요. +- API/foundation(Node relay)과 call-site/HTTP integration(Edge stream)이 분리되어 split이 필요하다. + +### 범위 결정 근거 + +- 포함: Node가 `ProviderTunnelRequest`를 받아 OpenAI-compatible adapter endpoint를 열고 ordered `ProviderTunnelFrame`을 보냄. +- 제외: Edge OpenAI handler의 caller response write, response mode/sideband/transformed, assembled log persistence, external field provider smoke. +- 기존 normalized `RunRequest`/`RunEvent` path는 유지한다. + +### 빌드 등급 + +- `local-G07`: Node transport handler와 provider adapters를 함께 바꾸지만 `httptest` byte fixture와 package tests로 deterministic 검증 가능하다. + +## 구현 체크리스트 + +- [ ] Node transport `Handler`와 `Session`에 `ProviderTunnelRequest` listener를 추가하고 handler nil/error path를 test한다. +- [ ] Node domain/runtime에 tunnel adapter interface와 frame sink를 추가한다. +- [ ] `openai_compat`와 `vllm` adapter가 provider HTTP status/header/body bytes를 ordered `ProviderTunnelFrame`으로 relay하게 구현한다. 검증: Qwen `reasoning_content` fixture와 Gemma content/tool-call fixture가 byte comparison으로 동일하게 왕복한다. +- [ ] cancel/context cancellation 시 upstream request body read가 중단되고 error/end frame semantics가 일관되는지 test한다. +- [ ] `go test ./apps/node/internal/transport ./apps/node/internal/node ./apps/node/internal/adapters/openai_compat ./apps/node/internal/adapters/vllm`를 실행한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +### [API-1] Node Tunnel Handler + +문제: [apps/node/internal/transport/session.go](/config/workspace/iop/apps/node/internal/transport/session.go:15)의 handler는 `RunRequest`, cancel, command, config refresh만 처리한다. + +해결 방법: + +```go +// before: apps/node/internal/transport/session.go:15 +type Handler interface { + OnRunRequest(ctx context.Context, sess *Session, req *iop.RunRequest) error + OnCancel(ctx context.Context, sess *Session, req *iop.CancelRequest) error + OnCommandRequest(ctx context.Context, sess *Session, req *iop.NodeCommandRequest) (*iop.NodeCommandResponse, error) + OnConfigRefresh(ctx context.Context, sess *Session, req *iop.NodeConfigRefreshRequest) (*iop.NodeConfigRefreshResponse, error) +} +``` + +`OnProviderTunnelRequest(ctx, sess, req)`를 추가하고 `newSession`에 `AddListenerTyped[*iop.ProviderTunnelRequest]`를 추가한다. + +수정 파일 및 체크리스트: + +- [ ] `apps/node/internal/transport/session.go` +- [ ] `apps/node/internal/transport/session_test.go` +- [ ] `apps/node/internal/transport/parser_test.go` + +테스트 작성: `ProviderTunnelRequest`가 handler로 전달되고 handler nil이면 무시/로그만 되는 session test를 추가한다. + +중간 검증: `go test ./apps/node/internal/transport` PASS. + +### [API-2] Adapter Raw Relay + +문제: [apps/node/internal/adapters/openai_compat/openai_compat.go](/config/workspace/iop/apps/node/internal/adapters/openai_compat/openai_compat.go:198)부터 provider stream을 scanner/JSON parser로 바꾸고, [apps/node/internal/adapters/vllm/vllm.go](/config/workspace/iop/apps/node/internal/adapters/vllm/vllm.go:198)도 같은 normalized path를 사용한다. + +해결 방법: `runtime.ProviderTunnelAdapter`를 추가하고 `openai_compat`/`vllm`이 `TunnelProvider(ctx, req, sink)`를 구현한다. HTTP error status는 IOP error로 변환하지 말고 status/header/body frame으로 전달한다. 네트워크/build request 오류만 `ERROR` frame으로 보낸다. + +수정 파일 및 체크리스트: + +- [ ] `apps/node/internal/runtime/types.go` +- [ ] `apps/node/internal/node/node.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` + +테스트 작성: Qwen reasoning SSE와 Gemma content/tool-call SSE raw fixture를 `httptest`로 반환하고 frame body concatenation이 원본 bytes와 같은지 확인한다. + +중간 검증: `go test ./apps/node/internal/node ./apps/node/internal/adapters/openai_compat ./apps/node/internal/adapters/vllm` PASS. + +## 수정 파일 요약 + +| 파일 | 항목 | +|------|------| +| `apps/node/internal/transport/session.go` | API-1 | +| `apps/node/internal/transport/session_test.go` | API-1 | +| `apps/node/internal/runtime/types.go` | API-2 | +| `apps/node/internal/node/node.go` | API-2 | +| `apps/node/internal/adapters/openai_compat/openai_compat.go` | API-2 | +| `apps/node/internal/adapters/openai_compat/openai_compat_test.go` | API-2 | +| `apps/node/internal/adapters/vllm/vllm.go` | API-2 | +| `apps/node/internal/adapters/vllm/vllm_test.go` | API-2 | + +## 최종 검증 + +```bash +go test ./apps/node/internal/transport ./apps/node/internal/node ./apps/node/internal/adapters/openai_compat ./apps/node/internal/adapters/vllm +``` + +기대 결과: PASS. Go test cache output 허용. 모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/archive/2026/07/m-openai-compatible-raw-tunnel-sideband-passthrough/01_node_relay/plan_local_G07_1.log b/agent-task/archive/2026/07/m-openai-compatible-raw-tunnel-sideband-passthrough/01_node_relay/plan_local_G07_1.log new file mode 100644 index 0000000..187e7a0 --- /dev/null +++ b/agent-task/archive/2026/07/m-openai-compatible-raw-tunnel-sideband-passthrough/01_node_relay/plan_local_G07_1.log @@ -0,0 +1,127 @@ + + +# Plan - REVIEW_API + +## 이 파일을 읽는 구현 에이전트에게 + +`CODE_REVIEW-local-G07.md`의 구현 에이전트 소유 섹션을 채우는 것이 구현의 마지막 단계다. 검증 명령은 실제로 실행하고 stdout/stderr를 붙인다. 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 막을 때만 review stub의 `사용자 리뷰 요청`을 채우고, 사용자에게 직접 질문하지 않는다. 환경/secret/service blocker나 evidence gap은 사용자 리뷰가 아니라 검증 결과 또는 follow-up으로 기록한다. + +## 배경 + +이 follow-up은 `node-relay` 구현의 FAIL 리뷰를 닫기 위한 좁은 보정 작업이다. 성공 body passthrough fixture는 통과했지만, tunnel error/cancel path의 ordered terminal frame semantics와 계획 필수 테스트가 부족하다. + +## 사용자 리뷰 요청 흐름 + +사용자 리뷰 요청은 선택된 Milestone lock decision이 실구현을 차단할 때만 active review stub에 기록한다. 직접 채팅 질문, 선택지 제시, `request_user_input`, `USER_REVIEW.md` 작성, archive/complete.log 작성은 금지한다. + +## 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: + - `node-relay`: Node는 OpenAI-compatible provider HTTP/SSE response를 열고 provider가 보낸 chunk를 tunnel frame으로 relay한다. +- Completion mode: check-on-pass + +## Archive Evidence Snapshot + +- Archived plan: `agent-task/m-openai-compatible-raw-tunnel-sideband-passthrough/01_node_relay/plan_local_G07_0.log` +- Archived review: `agent-task/m-openai-compatible-raw-tunnel-sideband-passthrough/01_node_relay/code_review_local_G07_0.log` +- Verdict: FAIL +- Required summary: + - R1: adapter read/cancel error frames do not carry the next ordered `sequence`, and Node can emit a duplicate ERROR frame after adapter-owned errors. + - R2: required handler nil/error, actual tunnel frame send, upstream context cancellation/read-error tests are missing. +- Affected files: + - `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` + - `apps/node/internal/transport/session_test.go` +- Verification evidence from review: + - `go test -count=1 ./apps/node/internal/transport ./apps/node/internal/node ./apps/node/internal/adapters/openai_compat ./apps/node/internal/adapters/vllm` PASS. + - `go test -count=1 ./apps/edge/internal/transport` PASS. + - `gofmt -l` on planned Go files was empty after review-agent formatting repair. +- Roadmap carryover: `node-relay` remains incomplete; S02/S03 success byte fixtures exist, but S09 cancel/error evidence is insufficient. +- Narrow archive reread allowed if needed: the two archived files listed above only. + +## 범위 결정 근거 + +- 포함: tunnel adapter error/cancel terminal frame ownership, sequence assignment, duplicate error prevention, missing targeted tests. +- 제외: Edge OpenAI HTTP passthrough integration, response mode parsing, sideband/transformed mode, assembled log persistence, external provider field smoke. +- `USER_REVIEW.md`는 만들지 않는다. 모든 문제는 코드와 deterministic tests로 해소 가능하다. + +## 구현 체크리스트 + +- [ ] `openai_compat`와 `vllm` tunnel path에서 provider/body read error와 context cancellation이 정확히 하나의 terminal ERROR frame을 현재 sequence로 emit하도록 정리한다. +- [ ] `node.OnProviderTunnelRequest`가 adapter 실행 전 실패만 직접 ERROR frame으로 보내고, adapter가 이미 소유한 tunnel error를 중복 전송하지 않게 한다. +- [ ] transport/node tests에 handler nil/error path, unsupported adapter/lookup failure, 실제 `ProviderTunnelFrame` send와 sequence 검증을 추가한다. +- [ ] adapter tests에 upstream context cancellation 또는 body read error가 provider request를 중단하고 ordered ERROR frame으로 끝나는 검증을 추가한다. +- [ ] `go test -count=1 ./apps/node/internal/transport ./apps/node/internal/node ./apps/node/internal/adapters/openai_compat ./apps/node/internal/adapters/vllm`를 실행한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +### [REVIEW_API-1] Tunnel Error Frame Semantics + +문제: adapter body read error 또는 context cancellation이 body frames 이후 발생하면 ERROR frame이 ordered sequence를 잃거나 Node가 같은 오류를 다시 전송할 수 있다. + +해결 방법: + +- adapter tunnel implementation이 `seq`를 소유하고 RESPONSE_START/BODY/ERROR/END 모두 단조 증가 sequence를 사용하게 한다. +- provider request build/do 실패처럼 response start 전 실패는 sequence `0` ERROR frame 하나로 끝낸다. +- response start 이후 read error 또는 context cancellation은 다음 sequence의 ERROR frame 하나로 끝낸다. +- `TunnelProvider`가 ERROR frame을 이미 emit한 오류에 대해서는 Node가 추가 ERROR frame을 보내지 않게 한다. Node direct ERROR frame은 adapter lookup 실패, unsupported adapter처럼 adapter 실행 전 실패에 한정한다. + +수정 파일 및 체크리스트: + +- [ ] `apps/node/internal/adapters/openai_compat/openai_compat.go` +- [ ] `apps/node/internal/adapters/vllm/vllm.go` +- [ ] `apps/node/internal/node/node.go` + +중간 검증: + +```bash +go test -count=1 ./apps/node/internal/adapters/openai_compat ./apps/node/internal/adapters/vllm -run 'Test(OpenAICompat|Vllm)TunnelProvider' +``` + +### [REVIEW_API-2] Failure/Cancel Coverage + +문제: 첫 루프 테스트는 success passthrough만 확인하고 계획의 nil/error/cancel 검증을 닫지 못했다. + +해결 방법: + +- `transport.Session` test에서 `ProviderTunnelRequest` no handler/handler error path가 panic 없이 처리되는지 확인한다. +- `node.Node` test에서 real or fake proto sender를 통해 unsupported/lookup failure와 adapter error path가 정확히 하나의 ERROR frame을 보내고 sequence/tunnel id/node id를 보존하는지 확인한다. +- adapter tests에서 `httptest` handler가 request context cancellation을 관측하고, sink가 ordered ERROR frame으로 종료되는지 확인한다. + +수정 파일 및 체크리스트: + +- [ ] `apps/node/internal/transport/session_test.go` +- [ ] `apps/node/internal/node/node_test.go` +- [ ] `apps/node/internal/adapters/openai_compat/openai_compat_test.go` +- [ ] `apps/node/internal/adapters/vllm/vllm_test.go` + +중간 검증: + +```bash +go test -count=1 ./apps/node/internal/transport ./apps/node/internal/node -run 'Test(SessionProviderTunnel|NodeOnProviderTunnel)' +``` + +## 수정 파일 요약 + +| 파일 | 항목 | +|------|------| +| `apps/node/internal/adapters/openai_compat/openai_compat.go` | REVIEW_API-1 | +| `apps/node/internal/adapters/openai_compat/openai_compat_test.go` | REVIEW_API-2 | +| `apps/node/internal/adapters/vllm/vllm.go` | REVIEW_API-1 | +| `apps/node/internal/adapters/vllm/vllm_test.go` | REVIEW_API-2 | +| `apps/node/internal/node/node.go` | REVIEW_API-1 | +| `apps/node/internal/node/node_test.go` | REVIEW_API-2 | +| `apps/node/internal/transport/session_test.go` | REVIEW_API-2 | + +## 최종 검증 + +```bash +go test -count=1 ./apps/node/internal/transport ./apps/node/internal/node ./apps/node/internal/adapters/openai_compat ./apps/node/internal/adapters/vllm +``` + +기대 결과: PASS. 모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/m-openai-compatible-raw-tunnel-sideband-passthrough/02+01_edge_stream/CODE_REVIEW-cloud-G07.md b/agent-task/m-openai-compatible-raw-tunnel-sideband-passthrough/02+01_edge_stream/CODE_REVIEW-cloud-G07.md new file mode 100644 index 0000000..57188a3 --- /dev/null +++ b/agent-task/m-openai-compatible-raw-tunnel-sideband-passthrough/02+01_edge_stream/CODE_REVIEW-cloud-G07.md @@ -0,0 +1,104 @@ + + +# Code Review Reference - API + +> **[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`. +> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation. +> Finalization is review-agent-only. + +## 개요 + +date=2026-07-07 +task=m-openai-compatible-raw-tunnel-sideband-passthrough/02+01_edge_stream, plan=0, tag=API + +## 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: + - `edge-stream`: Edge OpenAI-compatible handler는 tunnel status/headers/body chunks를 caller에게 그대로 write하고 pure passthrough를 기본값으로 적용한다. +- Completion mode: check-on-pass + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [API-1] Tunnel Frame Routing | [ ] | +| [API-2] OpenAI Passthrough Writer | [ ] | + +## 구현 체크리스트 + +- [ ] `01_node_relay` predecessor `complete.log`를 확인하고 경로를 review stub에 기록한다. +- [ ] Edge transport가 `ProviderTunnelFrame`을 request-bound channel로 라우팅하되 `events.Bus`에는 publish하지 않게 구현한다. +- [ ] Edge service에 provider-pool/direct route 공통 `SubmitProviderTunnel` 경로를 추가하고 admission slot release를 END/ERROR/cancel에서 보장한다. +- [ ] OpenAI Chat Completions handler가 OpenAI-compatible provider route의 omitted mode를 pure `passthrough`로 처리하고 provider status/header/body bytes를 caller에게 그대로 쓴다. 검증: streaming/non-streaming Chat Completions fixture가 provider 원본과 byte-identical하게 반환된다. +- [ ] caller disconnect/context cancel이 Node cancel/control path로 전파되는지 test한다. +- [ ] `go test ./apps/edge/internal/transport ./apps/edge/internal/service ./apps/edge/internal/openai`를 실행한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 코드리뷰 전용 체크리스트 + +> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. + +- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [ ] predecessor `01_node_relay` completion evidence가 유효하다. +- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G07_N.log`로 아카이브한다. +- [ ] active `PLAN-*-G??.md`를 `plan_cloud_G07_M.log`로 아카이브한다. +- [ ] PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-openai-compatible-raw-tunnel-sideband-passthrough/02+01_edge_stream/`로 이동한다. + +## 계획 대비 변경 사항 + +_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._ + +## 주요 설계 결정 + +_구현 에이전트가 주요 설계 결정 사항을 기록한다._ + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 연결 대상: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- Pure passthrough body에 IOP sideband/event/field가 섞이지 않는가. +- Streaming/non-streaming body byte comparison이 provider fixture와 동일한가. +- Provider-pool admission slot이 END/ERROR/cancel에서 release되는가. + +## 검증 결과 + +### API-1 중간 검증 +```bash +$ go test ./apps/edge/internal/transport ./apps/edge/internal/service +(output) +``` + +### API-2 중간 검증 +```bash +$ go test ./apps/edge/internal/openai +(output) +``` + +### 최종 검증 +```bash +$ go test ./apps/edge/internal/transport ./apps/edge/internal/service ./apps/edge/internal/openai +(output) +``` diff --git a/agent-task/m-openai-compatible-raw-tunnel-sideband-passthrough/02+01_edge_stream/PLAN-cloud-G07.md b/agent-task/m-openai-compatible-raw-tunnel-sideband-passthrough/02+01_edge_stream/PLAN-cloud-G07.md new file mode 100644 index 0000000..bb09173 --- /dev/null +++ b/agent-task/m-openai-compatible-raw-tunnel-sideband-passthrough/02+01_edge_stream/PLAN-cloud-G07.md @@ -0,0 +1,172 @@ + + +# Plan - API + +## 이 파일을 읽는 구현 에이전트에게 + +`01_node_relay`의 `complete.log`가 있어야 시작한다. 구현 후 `CODE_REVIEW-cloud-G07.md`의 구현 에이전트 소유 섹션을 실제 구현/검증 출력으로 채운다. Milestone lock decision만 review stub의 `사용자 리뷰 요청`에 기록하며, 사용자에게 직접 질문하지 않는다. + +## 배경 + +Node relay가 완료되면 Edge는 OpenAI-compatible provider route에 대해 normalized `RunEvent` stream 대신 request-bound tunnel frame stream을 caller에게 그대로 써야 한다. 이 plan은 `edge-stream` Task만 완료 대상으로 삼고 pure `passthrough` 기본값을 구현한다. sideband/transformed mode는 별도 Epic 범위다. + +## 사용자 리뷰 요청 흐름 + +사용자 리뷰 요청은 선택된 Milestone lock decision이 실구현을 차단할 때만 active review stub에 기록한다. 직접 채팅 질문, 선택지 제시, `request_user_input`, `USER_REVIEW.md` 작성, archive/complete.log 작성은 금지한다. + +## 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: + - `edge-stream`: Edge OpenAI-compatible handler는 tunnel status/headers/body chunks를 caller에게 그대로 write하고 pure passthrough를 기본값으로 적용한다. +- Completion mode: check-on-pass + +## 분석 결과 + +### 읽은 파일 + +- `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-contract/inner/edge-node-runtime-wire.md` +- `agent-contract/outer/openai-compatible-api.md` +- `agent-spec/input/openai-compatible-surface.md` +- `agent-spec/runtime/edge-node-execution.md` +- `agent-test/local/rules.md` +- `agent-test/local/edge-smoke.md` +- `agent-test/local/platform-common-smoke.md` +- `apps/edge/internal/service/run_dispatch.go` +- `apps/edge/internal/openai/chat_handler.go` +- `apps/edge/internal/openai/run_result.go` +- `apps/edge/internal/openai/stream.go` +- `apps/edge/internal/openai/server.go` +- `apps/edge/internal/transport/server.go` +- `proto/iop/runtime.proto` + +### SDD 기준 + +- SDD: `agent-roadmap/sdd/routing-policy-model-orchestration/openai-compatible-raw-tunnel-sideband-passthrough/SDD.md`, 상태 `[승인됨]`, 잠금 해제. +- Target scenarios: S04. +- Evidence Map: omitted mode default passthrough test와 streaming/non-streaming response comparison. +- 구현 체크리스트는 pure passthrough에 IOP sideband를 섞지 않고 status/header/body byte identity를 보존하는 데 맞췄다. + +### 테스트 환경 규칙 + +- test_env: `local`. +- 읽은 env/profile: `agent-test/local/rules.md`, `agent-test/local/edge-smoke.md`, `agent-test/local/platform-common-smoke.md`. +- 적용 명령: `go test ./apps/edge/internal/transport ./apps/edge/internal/service ./apps/edge/internal/openai`. +- OpenAI-compatible boundary 변경이므로 full-cycle 실제 구동은 code-review에서 남은 위험으로 확인해야 한다. 이 plan의 deterministic completion evidence는 `httptest` streaming/non-streaming byte comparison이다. + +### 테스트 커버리지 공백 + +- 현재 [apps/edge/internal/service/run_dispatch.go](/config/workspace/iop/apps/edge/internal/service/run_dispatch.go:68)는 `RunEvent` channel만 반환한다. +- [apps/edge/internal/openai/chat_handler.go](/config/workspace/iop/apps/edge/internal/openai/chat_handler.go:83)는 모든 Chat Completions 요청을 `SubmitRun` normalized path로 보낸다. +- [apps/edge/internal/openai/stream.go](/config/workspace/iop/apps/edge/internal/openai/stream.go:1)는 OpenAI SSE chunk를 재작성한다. provider-original passthrough test가 없다. + +### 심볼 참조 + +- renamed/removed symbols: none. +- 새 symbol 후보: `SubmitProviderTunnel`, `ProviderTunnelHandle`, `ProviderTunnelStream`, transport tunnel subscription/router. + +### 분할 판단 + +- split decision policy를 먼저 평가했다. +- task group: `agent-task/m-openai-compatible-raw-tunnel-sideband-passthrough/`. +- predecessor: `01_node_relay` must have `complete.log`; active/archive lookup은 구현 시작 시 확인한다. +- 이 task는 Edge service/OpenAI HTTP integration이므로 Node relay foundation과 분리한다. + +### 범위 결정 근거 + +- 포함: Chat Completions streaming/non-streaming pure passthrough 기본값, tunnel status/header/body write, caller disconnect cancel. +- 제외: `passthrough+sideband`, `transformed`, Responses API parity hardening, assembled-log persistence, provider qualification matrix. +- CLI/non-compatible/legacy normalized route는 유지한다. + +### 빌드 등급 + +- `cloud-G07`: 외부 HTTP wire semantics와 Edge service/provider-pool dispatch가 함께 바뀌며, 단순 local compile만으로 byte identity를 충분히 보장하기 어렵다. + +## 의존 관계 및 구현 순서 + +- `02+01_edge_stream`은 sibling `01_node_relay`의 `complete.log` 이후 시작한다. 구현 시작 전 `agent-task/m-openai-compatible-raw-tunnel-sideband-passthrough/01_node_relay/complete.log` 또는 matching archive complete.log를 확인한다. + +## 구현 체크리스트 + +- [ ] `01_node_relay` predecessor `complete.log`를 확인하고 경로를 review stub에 기록한다. +- [ ] Edge transport가 `ProviderTunnelFrame`을 request-bound channel로 라우팅하되 `events.Bus`에는 publish하지 않게 구현한다. +- [ ] Edge service에 provider-pool/direct route 공통 `SubmitProviderTunnel` 경로를 추가하고 admission slot release를 END/ERROR/cancel에서 보장한다. +- [ ] OpenAI Chat Completions handler가 OpenAI-compatible provider route의 omitted mode를 pure `passthrough`로 처리하고 provider status/header/body bytes를 caller에게 그대로 쓴다. 검증: streaming/non-streaming Chat Completions fixture가 provider 원본과 byte-identical하게 반환된다. +- [ ] caller disconnect/context cancel이 Node cancel/control path로 전파되는지 test한다. +- [ ] `go test ./apps/edge/internal/transport ./apps/edge/internal/service ./apps/edge/internal/openai`를 실행한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +### [API-1] Tunnel Frame Routing + +문제: + +```go +// before: apps/edge/internal/service/run_dispatch.go:68 +type RunStream struct { + Events <-chan *iop.RunEvent + NodeEvents <-chan *iop.EdgeNodeEvent +} +``` + +현재 Edge service는 RunEvent stream만 알고 tunnel frame stream을 구독할 수 없다. + +해결 방법: transport/server 또는 service에 tunnel frame router를 두고 `tunnel_id`별 buffered channel을 제공한다. `ProviderTunnelFrame` listener는 `RunEvent` handler나 `events.Bus.PublishRun`을 호출하지 않는다. + +수정 파일 및 체크리스트: + +- [ ] `apps/edge/internal/transport/server.go` +- [ ] `apps/edge/internal/transport/server_test.go` 또는 새 `tunnel_test.go` +- [ ] `apps/edge/internal/service/run_dispatch.go` + +테스트 작성: tunnel frame이 구독자에게 도착하고 `events.Bus` run subscriber에는 도착하지 않는 negative test를 추가한다. + +중간 검증: `go test ./apps/edge/internal/transport ./apps/edge/internal/service` PASS. + +### [API-2] OpenAI Passthrough Writer + +문제: + +```go +// before: apps/edge/internal/openai/chat_handler.go:83 +handle, err := s.service.SubmitRun(r.Context(), submitReq) +``` + +현재 Chat Completions는 provider-compatible route도 normalized run path로 보내며 [apps/edge/internal/openai/stream.go](/config/workspace/iop/apps/edge/internal/openai/stream.go:1)에서 SSE를 재작성한다. + +해결 방법: provider-compatible dispatch(`ProviderPool` 또는 `openai_compat`/`vllm`)의 omitted mode는 `SubmitProviderTunnel`을 사용한다. Response start frame에서 status/header를 쓰고 body frame은 순서대로 그대로 write/flush한다. Pure passthrough에는 IOP sideband field/event를 추가하지 않는다. + +수정 파일 및 체크리스트: + +- [ ] `apps/edge/internal/openai/chat_handler.go` +- [ ] `apps/edge/internal/openai/stream.go` +- [ ] `apps/edge/internal/openai/run_result.go` +- [ ] `apps/edge/internal/openai/server.go` +- [ ] 새 또는 기존 `apps/edge/internal/openai/*_test.go` + +테스트 작성: `httptest` provider fixture로 streaming SSE와 non-streaming JSON body를 각각 tunnel frame으로 흘려 caller response body가 provider body와 byte-identical한지 확인한다. + +중간 검증: `go test ./apps/edge/internal/openai` PASS. + +## 수정 파일 요약 + +| 파일 | 항목 | +|------|------| +| `apps/edge/internal/transport/server.go` | API-1 | +| `apps/edge/internal/transport/*_test.go` | API-1 | +| `apps/edge/internal/service/run_dispatch.go` | API-1 | +| `apps/edge/internal/openai/chat_handler.go` | API-2 | +| `apps/edge/internal/openai/stream.go` | API-2 | +| `apps/edge/internal/openai/run_result.go` | API-2 | +| `apps/edge/internal/openai/server.go` | API-2 | +| `apps/edge/internal/openai/*_test.go` | API-2 | + +## 최종 검증 + +```bash +go test ./apps/edge/internal/transport ./apps/edge/internal/service ./apps/edge/internal/openai +``` + +기대 결과: PASS. Go test cache output 허용. 모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/apps/edge/internal/transport/server.go b/apps/edge/internal/transport/server.go index a1eb39d..f11eb78 100644 --- a/apps/edge/internal/transport/server.go +++ b/apps/edge/internal/transport/server.go @@ -37,6 +37,10 @@ func edgeParserMap() toki.ParserMap { m := &iop.EdgeNodeEvent{} return m, proto.Unmarshal(b, m) }, + toki.TypeNameOf(&iop.ProviderTunnelFrame{}): func(b []byte) (proto.Message, error) { + m := &iop.ProviderTunnelFrame{} + return m, proto.Unmarshal(b, m) + }, toki.TypeNameOf(&iop.RegisterRequest{}): func(b []byte) (proto.Message, error) { m := &iop.RegisterRequest{} return m, proto.Unmarshal(b, m) diff --git a/apps/edge/internal/transport/server_test.go b/apps/edge/internal/transport/server_test.go index 5b2a6b8..8defac4 100644 --- a/apps/edge/internal/transport/server_test.go +++ b/apps/edge/internal/transport/server_test.go @@ -153,6 +153,47 @@ func TestEdgeParserMap_EdgeNodeEvent(t *testing.T) { } } +func TestEdgeParserMap_ProviderTunnelFrameIsSeparateFromRunEvent(t *testing.T) { + parsers := edgeParserMap() + rawBody := []byte("data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"raw\"}}]}\n\n") + original := &iop.ProviderTunnelFrame{ + RunId: "run-1", + TunnelId: "tunnel-1", + Sequence: 7, + Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_BODY, + Body: rawBody, + } + payload, err := proto.Marshal(original) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + key := toki.TypeNameOf(original) + if key == toki.TypeNameOf(&iop.RunEvent{}) { + t.Fatal("provider tunnel frame must not share the RunEvent wire type") + } + parser, ok := parsers[key] + if !ok { + t.Fatalf("parser not found for key: %s", key) + } + + parsed, err := parser(payload) + if err != nil { + t.Fatalf("parse: %v", err) + } + if _, ok := parsed.(*iop.RunEvent); ok { + t.Fatal("provider tunnel frame must not parse as RunEvent") + } + got := parsed.(*iop.ProviderTunnelFrame) + if got.GetRunId() != original.GetRunId() || + got.GetTunnelId() != original.GetTunnelId() || + got.GetSequence() != original.GetSequence() || + got.GetKind() != original.GetKind() || + string(got.GetBody()) != string(rawBody) { + t.Fatalf("unexpected tunnel frame: %+v", got) + } +} + func TestServerEnrichesRunEventNodeAlias(t *testing.T) { registry := edgenode.NewRegistry() registry.Register(&edgenode.NodeEntry{NodeID: "node-1", Alias: "alias-1"}) diff --git a/apps/node/internal/adapters/openai_compat/openai_compat.go b/apps/node/internal/adapters/openai_compat/openai_compat.go index 374f8fd..5e43dd8 100644 --- a/apps/node/internal/adapters/openai_compat/openai_compat.go +++ b/apps/node/internal/adapters/openai_compat/openai_compat.go @@ -77,6 +77,111 @@ func New(cfg config.OpenAICompatConf, logger *zap.Logger, instanceName ...string func (a *Adapter) Name() string { return Name } +func (a *Adapter) TunnelProvider(ctx context.Context, req runtime.ProviderTunnelRequest, sink runtime.ProviderTunnelSink) error { + var seq int64 = 0 + + if a.endpoint == "" { + err := fmt.Errorf("openai_compat adapter: endpoint is required") + _ = emitTunnelError(ctx, sink, req, seq, err) + return err + } + + urlStr := joinOpenAIPath(a.endpoint, req.Path) + httpReq, err := http.NewRequestWithContext(ctx, req.Method, urlStr, bytes.NewReader(req.Body)) + if err != nil { + err = fmt.Errorf("build request: %w", err) + _ = emitTunnelError(ctx, sink, req, seq, err) + return err + } + + isJSON := req.Method == http.MethodPost + a.applyHeaders(httpReq, isJSON) + for k, v := range req.Headers { + httpReq.Header.Set(k, v) + } + + resp, err := a.client.Do(httpReq) + if err != nil { + err = fmt.Errorf("request failed: %w", err) + _ = emitTunnelError(ctx, sink, req, seq, err) + return err + } + defer resp.Body.Close() + + respHeaders := make(map[string]string) + for k, values := range resp.Header { + if len(values) > 0 { + respHeaders[k] = values[0] + } + } + + err = sink.EmitTunnelFrame(ctx, runtime.ProviderTunnelFrame{ + RunID: req.RunID, + TunnelID: req.TunnelID, + Sequence: seq, + Kind: runtime.ProviderTunnelFrameKindResponseStart, + StatusCode: resp.StatusCode, + Headers: respHeaders, + Timestamp: time.Now(), + }) + if err != nil { + return err + } + seq++ + + buf := make([]byte, 4096) + for { + n, readErr := resp.Body.Read(buf) + if n > 0 { + err = sink.EmitTunnelFrame(ctx, runtime.ProviderTunnelFrame{ + RunID: req.RunID, + TunnelID: req.TunnelID, + Sequence: seq, + Kind: runtime.ProviderTunnelFrameKindBody, + Body: append([]byte(nil), buf[:n]...), + Timestamp: time.Now(), + }) + if err != nil { + return err + } + seq++ + } + if readErr != nil { + if readErr == io.EOF { + break + } + if ctx.Err() != nil { + _ = emitTunnelError(ctx, sink, req, seq, ctx.Err()) + return ctx.Err() + } + err = fmt.Errorf("read response body: %w", readErr) + _ = emitTunnelError(ctx, sink, req, seq, err) + return err + } + } + + err = sink.EmitTunnelFrame(ctx, runtime.ProviderTunnelFrame{ + RunID: req.RunID, + TunnelID: req.TunnelID, + Sequence: seq, + Kind: runtime.ProviderTunnelFrameKindEnd, + End: true, + Timestamp: time.Now(), + }) + return err +} + +func emitTunnelError(ctx context.Context, sink runtime.ProviderTunnelSink, req runtime.ProviderTunnelRequest, seq int64, err error) error { + return sink.EmitTunnelFrame(ctx, runtime.ProviderTunnelFrame{ + RunID: req.RunID, + TunnelID: req.TunnelID, + Sequence: seq, + Kind: runtime.ProviderTunnelFrameKindError, + Error: err.Error(), + Timestamp: time.Now(), + }) +} + func (a *Adapter) Capabilities(ctx context.Context) (runtime.Capabilities, error) { probeCtx, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() diff --git a/apps/node/internal/adapters/openai_compat/openai_compat_test.go b/apps/node/internal/adapters/openai_compat/openai_compat_test.go index 4bae1db..80ec637 100644 --- a/apps/node/internal/adapters/openai_compat/openai_compat_test.go +++ b/apps/node/internal/adapters/openai_compat/openai_compat_test.go @@ -1,6 +1,7 @@ package openai_compat import ( + "bytes" "context" "encoding/json" "fmt" @@ -9,6 +10,7 @@ import ( "strings" "sync" "testing" + "time" "go.uber.org/zap" @@ -1092,3 +1094,240 @@ func TestOpenAICompatExecuteVLLMUnsupportedInvalidBudget(t *testing.T) { } } } + +type fakeTunnelSink struct { + mu sync.Mutex + frames []runtime.ProviderTunnelFrame +} + +func (s *fakeTunnelSink) EmitTunnelFrame(_ context.Context, frame runtime.ProviderTunnelFrame) error { + s.mu.Lock() + defer s.mu.Unlock() + s.frames = append(s.frames, frame) + return nil +} + +func (s *fakeTunnelSink) all() []runtime.ProviderTunnelFrame { + s.mu.Lock() + defer s.mu.Unlock() + return append([]runtime.ProviderTunnelFrame(nil), s.frames...) +} + +func TestOpenAICompatTunnelProvider(t *testing.T) { + expectedBody := "data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"think Qwen\"}}]}\n\ndata: [DONE]\n\n" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST method, got %s", r.Method) + } + if r.URL.Path != "/v1/chat/completions" { + t.Errorf("expected path /v1/chat/completions, got %s", r.URL.Path) + } + if r.Header.Get("Authorization") != "Bearer test-key" { + t.Errorf("expected Authorization header, got %s", r.Header.Get("Authorization")) + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("X-Custom-Header", "custom-value") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(expectedBody)) + })) + defer server.Close() + + adapter := New(config.OpenAICompatConf{ + Endpoint: server.URL, + }, zap.NewNop()) + + sink := &fakeTunnelSink{} + req := runtime.ProviderTunnelRequest{ + RunID: "run-1", + TunnelID: "tunnel-1", + Method: "POST", + Path: "/v1/chat/completions", + Headers: map[string]string{"Authorization": "Bearer test-key"}, + Body: []byte(`{"model":"qwen3.6:35b","prompt":"hi"}`), + } + + err := adapter.TunnelProvider(context.Background(), req, sink) + if err != nil { + t.Fatalf("TunnelProvider failed: %v", err) + } + + frames := sink.all() + if len(frames) < 3 { + t.Fatalf("expected at least 3 frames, got %d", len(frames)) + } + + startFrame := frames[0] + if startFrame.Kind != runtime.ProviderTunnelFrameKindResponseStart { + t.Errorf("expected RESPONSE_START, got %s", startFrame.Kind) + } + if startFrame.StatusCode != http.StatusOK { + t.Errorf("expected 200 OK, got %d", startFrame.StatusCode) + } + if startFrame.Headers["X-Custom-Header"] != "custom-value" { + t.Errorf("expected Custom Header, got %v", startFrame.Headers) + } + + var bodyBuffer bytes.Buffer + var lastSeq int64 = 0 + for _, f := range frames[1 : len(frames)-1] { + if f.Kind != runtime.ProviderTunnelFrameKindBody { + t.Errorf("expected BODY kind, got %s", f.Kind) + } + if f.Sequence != lastSeq+1 { + t.Errorf("expected seq %d, got %d", lastSeq+1, f.Sequence) + } + bodyBuffer.Write(f.Body) + lastSeq = f.Sequence + } + if bodyBuffer.String() != expectedBody { + t.Errorf("body mismatch: got %q, want %q", bodyBuffer.String(), expectedBody) + } + + endFrame := frames[len(frames)-1] + if endFrame.Kind != runtime.ProviderTunnelFrameKindEnd { + t.Errorf("expected END, got %s", endFrame.Kind) + } + if !endFrame.End { + t.Errorf("expected End true, got %v", endFrame.End) + } + if endFrame.Sequence != lastSeq+1 { + t.Errorf("expected seq %d, got %d", lastSeq+1, endFrame.Sequence) + } +} + +func TestOpenAICompatTunnelProvider_Cancel(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + handlerObservedCancel := make(chan struct{}) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + + flusher, ok := w.(http.Flusher) + for { + _, err := w.Write([]byte("data: chunk\n\n")) + if err != nil { + close(handlerObservedCancel) + return + } + if ok { + flusher.Flush() + } + select { + case <-r.Context().Done(): + close(handlerObservedCancel) + return + case <-time.After(10 * time.Millisecond): + } + } + })) + defer server.Close() + + adapter := New(config.OpenAICompatConf{ + Endpoint: server.URL, + }, zap.NewNop()) + + sink := &fakeTunnelSink{} + req := runtime.ProviderTunnelRequest{ + RunID: "run-1", + TunnelID: "tunnel-1", + Method: "POST", + Path: "/v1/chat/completions", + Body: []byte(`{"prompt":"hi"}`), + } + + errCh := make(chan error, 1) + go func() { + errCh <- adapter.TunnelProvider(ctx, req, sink) + }() + + time.Sleep(100 * time.Millisecond) + cancel() + + select { + case err := <-errCh: + if err == nil { + t.Error("expected error from TunnelProvider on cancellation, got nil") + } + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for TunnelProvider to return") + } + + select { + case <-handlerObservedCancel: + case <-time.After(2 * time.Second): + t.Fatal("handler did not observe context cancel") + } + + frames := sink.all() + if len(frames) < 2 { + t.Fatalf("expected at least 2 frames, got %d: %+v", len(frames), frames) + } + + lastFrame := frames[len(frames)-1] + if lastFrame.Kind != runtime.ProviderTunnelFrameKindError { + t.Errorf("expected last frame to be ERROR, got %s", lastFrame.Kind) + } + expectedSeq := int64(len(frames) - 1) + if lastFrame.Sequence != expectedSeq { + t.Errorf("expected error frame sequence %d, got %d", expectedSeq, lastFrame.Sequence) + } + if lastFrame.Error == "" { + t.Error("expected non-empty error message in frame") + } +} + +func TestOpenAICompatTunnelProvider_BodyReadError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hj, ok := w.(http.Hijacker) + if !ok { + t.Fatal("webserver doesn't support hijacking") + } + conn, _, err := hj.Hijack() + if err != nil { + t.Fatalf("hijack failed: %v", err) + } + defer conn.Close() + + _, _ = conn.Write([]byte("HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: 100\r\n\r\n")) + _, _ = conn.Write([]byte("data: chunk1\n\n")) + })) + defer server.Close() + + adapter := New(config.OpenAICompatConf{ + Endpoint: server.URL, + }, zap.NewNop()) + + sink := &fakeTunnelSink{} + req := runtime.ProviderTunnelRequest{ + RunID: "run-1", + TunnelID: "tunnel-1", + Method: "POST", + Path: "/v1/chat/completions", + Body: []byte(`{"prompt":"hi"}`), + } + + err := adapter.TunnelProvider(context.Background(), req, sink) + if err == nil { + t.Fatal("expected error from body read failure, got nil") + } + + frames := sink.all() + if len(frames) < 2 { + t.Fatalf("expected at least 2 frames, got %d: %+v", len(frames), frames) + } + + lastFrame := frames[len(frames)-1] + if lastFrame.Kind != runtime.ProviderTunnelFrameKindError { + t.Errorf("expected last frame to be ERROR, got %s", lastFrame.Kind) + } + expectedSeq := int64(len(frames) - 1) + if lastFrame.Sequence != expectedSeq { + t.Errorf("expected error frame sequence %d, got %d", expectedSeq, lastFrame.Sequence) + } + if !strings.Contains(lastFrame.Error, "read response body") && !strings.Contains(lastFrame.Error, "EOF") { + t.Errorf("expected body read error msg, got %q", lastFrame.Error) + } +} diff --git a/apps/node/internal/adapters/vllm/vllm.go b/apps/node/internal/adapters/vllm/vllm.go index bf8d2e9..98cf21d 100644 --- a/apps/node/internal/adapters/vllm/vllm.go +++ b/apps/node/internal/adapters/vllm/vllm.go @@ -60,6 +60,112 @@ func New(cfg config.VllmConf, logger *zap.Logger, instanceName ...string) *Vllm func (v *Vllm) Name() string { return Name } +func (v *Vllm) TunnelProvider(ctx context.Context, req runtime.ProviderTunnelRequest, sink runtime.ProviderTunnelSink) error { + var seq int64 = 0 + + if v.endpoint == "" { + err := fmt.Errorf("vllm adapter: endpoint is required") + _ = emitTunnelError(ctx, sink, req, seq, err) + return err + } + + urlStr := joinURL(v.endpoint, req.Path) + httpReq, err := http.NewRequestWithContext(ctx, req.Method, urlStr, bytes.NewReader(req.Body)) + if err != nil { + err = fmt.Errorf("build request: %w", err) + _ = emitTunnelError(ctx, sink, req, seq, err) + return err + } + + if req.Method == http.MethodPost { + httpReq.Header.Set("Content-Type", "application/json") + } + for k, val := range req.Headers { + httpReq.Header.Set(k, val) + } + + resp, err := v.client.Do(httpReq) + if err != nil { + err = fmt.Errorf("request failed: %w", err) + _ = emitTunnelError(ctx, sink, req, seq, err) + return err + } + defer resp.Body.Close() + + respHeaders := make(map[string]string) + for k, values := range resp.Header { + if len(values) > 0 { + respHeaders[k] = values[0] + } + } + + err = sink.EmitTunnelFrame(ctx, runtime.ProviderTunnelFrame{ + RunID: req.RunID, + TunnelID: req.TunnelID, + Sequence: seq, + Kind: runtime.ProviderTunnelFrameKindResponseStart, + StatusCode: resp.StatusCode, + Headers: respHeaders, + Timestamp: time.Now(), + }) + if err != nil { + return err + } + seq++ + + buf := make([]byte, 4096) + for { + n, readErr := resp.Body.Read(buf) + if n > 0 { + err = sink.EmitTunnelFrame(ctx, runtime.ProviderTunnelFrame{ + RunID: req.RunID, + TunnelID: req.TunnelID, + Sequence: seq, + Kind: runtime.ProviderTunnelFrameKindBody, + Body: append([]byte(nil), buf[:n]...), + Timestamp: time.Now(), + }) + if err != nil { + return err + } + seq++ + } + if readErr != nil { + if readErr == io.EOF { + break + } + if ctx.Err() != nil { + _ = emitTunnelError(ctx, sink, req, seq, ctx.Err()) + return ctx.Err() + } + err = fmt.Errorf("read response body: %w", readErr) + _ = emitTunnelError(ctx, sink, req, seq, err) + return err + } + } + + err = sink.EmitTunnelFrame(ctx, runtime.ProviderTunnelFrame{ + RunID: req.RunID, + TunnelID: req.TunnelID, + Sequence: seq, + Kind: runtime.ProviderTunnelFrameKindEnd, + End: true, + Timestamp: time.Now(), + }) + return err +} + +func emitTunnelError(ctx context.Context, sink runtime.ProviderTunnelSink, req runtime.ProviderTunnelRequest, seq int64, err error) error { + return sink.EmitTunnelFrame(ctx, runtime.ProviderTunnelFrame{ + RunID: req.RunID, + TunnelID: req.TunnelID, + Sequence: seq, + Kind: runtime.ProviderTunnelFrameKindError, + Error: err.Error(), + Timestamp: time.Now(), + }) +} + func (v *Vllm) Capabilities(ctx context.Context) (runtime.Capabilities, error) { probeCtx, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() diff --git a/apps/node/internal/adapters/vllm/vllm_test.go b/apps/node/internal/adapters/vllm/vllm_test.go index 5fa549e..b731180 100644 --- a/apps/node/internal/adapters/vllm/vllm_test.go +++ b/apps/node/internal/adapters/vllm/vllm_test.go @@ -1,6 +1,7 @@ package vllm import ( + "bytes" "context" "encoding/json" "fmt" @@ -9,6 +10,7 @@ import ( "strings" "sync" "testing" + "time" "go.uber.org/zap" @@ -471,3 +473,315 @@ func TestVllmProbeProviderAvailability(t *testing.T) { } }) } + +type fakeTunnelSink struct { + mu sync.Mutex + frames []runtime.ProviderTunnelFrame +} + +func (s *fakeTunnelSink) EmitTunnelFrame(_ context.Context, frame runtime.ProviderTunnelFrame) error { + s.mu.Lock() + defer s.mu.Unlock() + s.frames = append(s.frames, frame) + return nil +} + +func (s *fakeTunnelSink) all() []runtime.ProviderTunnelFrame { + s.mu.Lock() + defer s.mu.Unlock() + return append([]runtime.ProviderTunnelFrame(nil), s.frames...) +} + +func TestVllmTunnelProvider(t *testing.T) { + expectedBody := "data: {\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\ndata: [DONE]\n\n" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST method, got %s", r.Method) + } + if r.URL.Path != "/v1/chat/completions" { + t.Errorf("expected path /v1/chat/completions, got %s", r.URL.Path) + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("X-Custom-Header", "custom-value") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(expectedBody)) + })) + defer server.Close() + + adapter := New(config.VllmConf{ + Endpoint: server.URL, + }, zap.NewNop()) + + sink := &fakeTunnelSink{} + req := runtime.ProviderTunnelRequest{ + RunID: "run-1", + TunnelID: "tunnel-1", + Method: "POST", + Path: "/v1/chat/completions", + Body: []byte(`{"model":"qwen3.6:35b","prompt":"hi"}`), + } + + err := adapter.TunnelProvider(context.Background(), req, sink) + if err != nil { + t.Fatalf("TunnelProvider failed: %v", err) + } + + frames := sink.all() + if len(frames) < 3 { + t.Fatalf("expected at least 3 frames, got %d", len(frames)) + } + + startFrame := frames[0] + if startFrame.Kind != runtime.ProviderTunnelFrameKindResponseStart { + t.Errorf("expected RESPONSE_START, got %s", startFrame.Kind) + } + if startFrame.StatusCode != http.StatusOK { + t.Errorf("expected 200 OK, got %d", startFrame.StatusCode) + } + if startFrame.Headers["X-Custom-Header"] != "custom-value" { + t.Errorf("expected Custom Header, got %v", startFrame.Headers) + } + + var bodyBuffer bytes.Buffer + var lastSeq int64 = 0 + for _, f := range frames[1 : len(frames)-1] { + if f.Kind != runtime.ProviderTunnelFrameKindBody { + t.Errorf("expected BODY kind, got %s", f.Kind) + } + if f.Sequence != lastSeq+1 { + t.Errorf("expected seq %d, got %d", lastSeq+1, f.Sequence) + } + bodyBuffer.Write(f.Body) + lastSeq = f.Sequence + } + if bodyBuffer.String() != expectedBody { + t.Errorf("body mismatch: got %q, want %q", bodyBuffer.String(), expectedBody) + } + + endFrame := frames[len(frames)-1] + if endFrame.Kind != runtime.ProviderTunnelFrameKindEnd { + t.Errorf("expected END, got %s", endFrame.Kind) + } + if !endFrame.End { + t.Errorf("expected End true, got %v", endFrame.End) + } + if endFrame.Sequence != lastSeq+1 { + t.Errorf("expected seq %d, got %d", lastSeq+1, endFrame.Sequence) + } +} + +func TestVllmTunnelProvider_Cancel(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + handlerObservedCancel := make(chan struct{}) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + + flusher, ok := w.(http.Flusher) + for { + _, err := w.Write([]byte("data: chunk\n\n")) + if err != nil { + close(handlerObservedCancel) + return + } + if ok { + flusher.Flush() + } + select { + case <-r.Context().Done(): + close(handlerObservedCancel) + return + case <-time.After(10 * time.Millisecond): + } + } + })) + defer server.Close() + + adapter := New(config.VllmConf{ + Endpoint: server.URL, + }, zap.NewNop()) + + sink := &fakeTunnelSink{} + req := runtime.ProviderTunnelRequest{ + RunID: "run-1", + TunnelID: "tunnel-1", + Method: "POST", + Path: "/v1/chat/completions", + Body: []byte(`{"prompt":"hi"}`), + } + + errCh := make(chan error, 1) + go func() { + errCh <- adapter.TunnelProvider(ctx, req, sink) + }() + + time.Sleep(100 * time.Millisecond) + cancel() + + select { + case err := <-errCh: + if err == nil { + t.Error("expected error from TunnelProvider on cancellation, got nil") + } + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for TunnelProvider to return") + } + + select { + case <-handlerObservedCancel: + case <-time.After(2 * time.Second): + t.Fatal("handler did not observe context cancel") + } + + frames := sink.all() + if len(frames) < 2 { + t.Fatalf("expected at least 2 frames, got %d: %+v", len(frames), frames) + } + + lastFrame := frames[len(frames)-1] + if lastFrame.Kind != runtime.ProviderTunnelFrameKindError { + t.Errorf("expected last frame to be ERROR, got %s", lastFrame.Kind) + } + expectedSeq := int64(len(frames) - 1) + if lastFrame.Sequence != expectedSeq { + t.Errorf("expected error frame sequence %d, got %d", expectedSeq, lastFrame.Sequence) + } + if lastFrame.Error == "" { + t.Error("expected non-empty error message in frame") + } +} + +func TestVllmTunnelProvider_BodyReadError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hj, ok := w.(http.Hijacker) + if !ok { + t.Fatal("webserver doesn't support hijacking") + } + conn, _, err := hj.Hijack() + if err != nil { + t.Fatalf("hijack failed: %v", err) + } + defer conn.Close() + + _, _ = conn.Write([]byte("HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: 100\r\n\r\n")) + _, _ = conn.Write([]byte("data: chunk1\n\n")) + })) + defer server.Close() + + adapter := New(config.VllmConf{ + Endpoint: server.URL, + }, zap.NewNop()) + + sink := &fakeTunnelSink{} + req := runtime.ProviderTunnelRequest{ + RunID: "run-1", + TunnelID: "tunnel-1", + Method: "POST", + Path: "/v1/chat/completions", + Body: []byte(`{"prompt":"hi"}`), + } + + err := adapter.TunnelProvider(context.Background(), req, sink) + if err == nil { + t.Fatal("expected error from body read failure, got nil") + } + + frames := sink.all() + if len(frames) < 2 { + t.Fatalf("expected at least 2 frames, got %d: %+v", len(frames), frames) + } + + lastFrame := frames[len(frames)-1] + if lastFrame.Kind != runtime.ProviderTunnelFrameKindError { + t.Errorf("expected last frame to be ERROR, got %s", lastFrame.Kind) + } + expectedSeq := int64(len(frames) - 1) + if lastFrame.Sequence != expectedSeq { + t.Errorf("expected error frame sequence %d, got %d", expectedSeq, lastFrame.Sequence) + } + if !strings.Contains(lastFrame.Error, "read response body") && !strings.Contains(lastFrame.Error, "EOF") { + t.Errorf("expected body read error msg, got %q", lastFrame.Error) + } +} + +func TestVllmTunnelProvider_GemmaContentToolCallRawBytes(t *testing.T) { + expectedBody := "data: {\"choices\":[{\"delta\":{\"content\":\"Gemma response \"}}]}\n\n" + + "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_gemma_1\",\"type\":\"function\",\"function\":{\"name\":\"run_commands\",\"arguments\":\"{\\\"commands\\\": \"}}]}}]}\n\n" + + "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"[\\\"git status\\\"]}\"}}]}}]}\n\n" + + "data: [DONE]\n\n" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST method, got %s", r.Method) + } + if r.URL.Path != "/v1/chat/completions" { + t.Errorf("expected path /v1/chat/completions, got %s", r.URL.Path) + } + + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(expectedBody)) + })) + defer server.Close() + + adapter := New(config.VllmConf{ + Endpoint: server.URL, + }, zap.NewNop()) + + sink := &fakeTunnelSink{} + req := runtime.ProviderTunnelRequest{ + RunID: "run-gemma-1", + TunnelID: "tunnel-gemma-1", + Method: "POST", + Path: "/v1/chat/completions", + Body: []byte(`{"model":"gemma-2b","prompt":"hi"}`), + } + + err := adapter.TunnelProvider(context.Background(), req, sink) + if err != nil { + t.Fatalf("TunnelProvider failed: %v", err) + } + + frames := sink.all() + if len(frames) < 3 { + t.Fatalf("expected at least 3 frames, got %d", len(frames)) + } + + startFrame := frames[0] + if startFrame.Kind != runtime.ProviderTunnelFrameKindResponseStart { + t.Errorf("expected RESPONSE_START, got %s", startFrame.Kind) + } + if startFrame.StatusCode != http.StatusOK { + t.Errorf("expected 200 OK, got %d", startFrame.StatusCode) + } + + var bodyBuffer bytes.Buffer + var lastSeq int64 = 0 + for _, f := range frames[1 : len(frames)-1] { + if f.Kind != runtime.ProviderTunnelFrameKindBody { + t.Errorf("expected BODY kind, got %s", f.Kind) + } + if f.Sequence != lastSeq+1 { + t.Errorf("expected seq %d, got %d", lastSeq+1, f.Sequence) + } + bodyBuffer.Write(f.Body) + lastSeq = f.Sequence + } + if bodyBuffer.String() != expectedBody { + t.Errorf("body mismatch:\ngot: %q\nwant: %q", bodyBuffer.String(), expectedBody) + } + + endFrame := frames[len(frames)-1] + if endFrame.Kind != runtime.ProviderTunnelFrameKindEnd { + t.Errorf("expected END, got %s", endFrame.Kind) + } + if !endFrame.End { + t.Errorf("expected End true, got %v", endFrame.End) + } + if endFrame.Sequence != lastSeq+1 { + t.Errorf("expected seq %d, got %d", lastSeq+1, endFrame.Sequence) + } +} diff --git a/apps/node/internal/node/node.go b/apps/node/internal/node/node.go index c49c548..8e17202 100644 --- a/apps/node/internal/node/node.go +++ b/apps/node/internal/node/node.go @@ -213,6 +213,150 @@ func (n *Node) OnRunRequest(ctx context.Context, sess *transport.Session, req *i return run() } +// OnProviderTunnelRequest handles an incoming ProviderTunnelRequest from a transport Session. +func (n *Node) OnProviderTunnelRequest(ctx context.Context, sess *transport.Session, req *iop.ProviderTunnelRequest) error { + n.logger.Info("provider tunnel request received", + zap.String("run_id", req.GetRunId()), + zap.String("tunnel_id", req.GetTunnelId()), + zap.String("adapter", req.GetAdapter()), + zap.String("target", req.GetTarget()), + ) + + tr := runtime.ProviderTunnelRequest{ + RunID: req.GetRunId(), + TunnelID: req.GetTunnelId(), + Adapter: req.GetAdapter(), + Target: req.GetTarget(), + Method: req.GetMethod(), + Path: req.GetPath(), + Headers: req.GetHeaders(), + Body: req.GetBody(), + Stream: req.GetStream(), + TimeoutSec: int(req.GetTimeoutSec()), + Metadata: req.GetMetadata(), + SessionID: req.GetSessionId(), + } + + n.configSetMu.RLock() + adapter, err := n.router.LookupAdapter(tr.Adapter) + n.configSetMu.RUnlock() + if err != nil { + n.sendTunnelError(sess, tr, fmt.Errorf("node: lookup adapter: %w", err)) + return fmt.Errorf("node: lookup adapter: %w", err) + } + + tunnelAdapter, ok := adapter.(runtime.ProviderTunnelAdapter) + if !ok { + err := fmt.Errorf("node: adapter %q does not support tunneling", tr.Adapter) + n.sendTunnelError(sess, tr, err) + return err + } + + var sender protoSender = noopSender{} + nodeID := n.nodeID + nodeAlias := "" + if sess != nil { + if sess.IsAlive() { + sender = sess + } + nodeID = sess.NodeID() + nodeAlias = sess.Alias() + } + + sink := &tunnelSink{ + sess: sender, + nodeID: nodeID, + nodeAlias: nodeAlias, + } + + execCtx := ctx + if tr.TimeoutSec > 0 { + var cancel context.CancelFunc + execCtx, cancel = context.WithTimeout(ctx, time.Duration(tr.TimeoutSec)*time.Second) + defer cancel() + } + + if err := tunnelAdapter.TunnelProvider(execCtx, tr, sink); err != nil { + n.logger.Warn("provider tunnel error", + zap.String("run_id", tr.RunID), + zap.String("tunnel_id", tr.TunnelID), + zap.Error(err), + ) + return err + } + + return nil +} + +func (n *Node) sendTunnelError(sess *transport.Session, tr runtime.ProviderTunnelRequest, err error) { + if sess == nil || !sess.IsAlive() { + return + } + tf := &iop.ProviderTunnelFrame{ + RunId: tr.RunID, + TunnelId: tr.TunnelID, + Sequence: 0, + Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_ERROR, + Error: err.Error(), + Timestamp: time.Now().UnixNano(), + NodeId: sess.NodeID(), + NodeAlias: sess.Alias(), + } + _ = sess.Send(tf) +} + +type tunnelSink struct { + sess protoSender + nodeID string + nodeAlias string +} + +func (s *tunnelSink) EmitTunnelFrame(ctx context.Context, frame runtime.ProviderTunnelFrame) error { + var usage *iop.Usage + if frame.Usage != nil { + usage = &iop.Usage{ + InputTokens: int32(frame.Usage.InputTokens), + OutputTokens: int32(frame.Usage.OutputTokens), + } + } + + protoKind := iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_UNSPECIFIED + switch frame.Kind { + case runtime.ProviderTunnelFrameKindResponseStart: + protoKind = iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_RESPONSE_START + case runtime.ProviderTunnelFrameKindBody: + protoKind = iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_BODY + case runtime.ProviderTunnelFrameKindEnd: + protoKind = iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END + case runtime.ProviderTunnelFrameKindError: + protoKind = iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_ERROR + case runtime.ProviderTunnelFrameKindUsage: + protoKind = iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_USAGE + } + + tf := &iop.ProviderTunnelFrame{ + RunId: frame.RunID, + TunnelId: frame.TunnelID, + Sequence: frame.Sequence, + Kind: protoKind, + StatusCode: int32(frame.StatusCode), + Headers: frame.Headers, + Body: frame.Body, + End: frame.End, + Error: frame.Error, + Usage: usage, + Metadata: frame.Metadata, + Timestamp: frame.Timestamp.UnixNano(), + NodeId: s.nodeID, + NodeAlias: s.nodeAlias, + } + + if s.sess != nil { + return s.sess.Send(tf) + } + return nil +} + // admissionFor returns an admissionManager with only the per-adapter gate for // the given adapter instance key. Node-wide global admission gating has been // removed; each adapter controls its own concurrency via Capabilities().MaxConcurrency. diff --git a/apps/node/internal/node/node_test.go b/apps/node/internal/node/node_test.go index 0cf6eee..2f3bc85 100644 --- a/apps/node/internal/node/node_test.go +++ b/apps/node/internal/node/node_test.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "net" "net/http" "net/http/httptest" "strings" @@ -13,7 +14,9 @@ import ( "testing" "time" + toki "git.toki-labs.com/toki/proto-socket/go" "go.uber.org/zap" + "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/structpb" "iop/apps/node/internal/adapters" @@ -3017,3 +3020,257 @@ func TestResolveAdapterErrorObservedByEdge(t *testing.T) { t.Fatalf("expected resolve prefix, got %v", err) } } + +type mockTunnelAdapter struct { + countingAdapter + t *testing.T + expectedReq runtime.ProviderTunnelRequest + respondErr error +} + +func (a *mockTunnelAdapter) Name() string { return "openai_compat" } +func (a *mockTunnelAdapter) Capabilities(_ context.Context) (runtime.Capabilities, error) { + return runtime.Capabilities{AdapterName: "openai_compat", Targets: []string{"qwen"}}, nil +} +func (a *mockTunnelAdapter) TunnelProvider(ctx context.Context, req runtime.ProviderTunnelRequest, sink runtime.ProviderTunnelSink) error { + if req.RunID != a.expectedReq.RunID || req.TunnelID != a.expectedReq.TunnelID { + a.t.Errorf("unexpected tunnel req: %+v", req) + } + + if a.respondErr != nil { + return a.respondErr + } + + err := sink.EmitTunnelFrame(ctx, runtime.ProviderTunnelFrame{ + RunID: req.RunID, + TunnelID: req.TunnelID, + Sequence: 0, + Kind: runtime.ProviderTunnelFrameKindResponseStart, + StatusCode: 200, + Headers: map[string]string{"Content-Type": "application/json"}, + Timestamp: time.Now(), + }) + if err != nil { + return err + } + + err = sink.EmitTunnelFrame(ctx, runtime.ProviderTunnelFrame{ + RunID: req.RunID, + TunnelID: req.TunnelID, + Sequence: 1, + Kind: runtime.ProviderTunnelFrameKindBody, + Body: []byte(`{"choices":[{"delta":{"content":"ok"}}]}`), + Timestamp: time.Now(), + }) + if err != nil { + return err + } + + err = sink.EmitTunnelFrame(ctx, runtime.ProviderTunnelFrame{ + RunID: req.RunID, + TunnelID: req.TunnelID, + Sequence: 2, + Kind: runtime.ProviderTunnelFrameKindEnd, + End: true, + Timestamp: time.Now(), + }) + return err +} + +func TestNodeOnProviderTunnelRequest_Success(t *testing.T) { + mta := &mockTunnelAdapter{ + t: t, + expectedReq: runtime.ProviderTunnelRequest{ + RunID: "run-tunnel-1", + TunnelID: "tunnel-1", + }, + } + router := &fixedRouter{adapterName: "openai_compat", adapters: make(map[string]runtime.Adapter)} + router.adapters["openai_compat"] = mta + n, _ := makeNode(t, router) + + req := &iop.ProviderTunnelRequest{ + RunId: "run-tunnel-1", + TunnelId: "tunnel-1", + Adapter: "openai_compat", + Target: "qwen", + Method: "POST", + Path: "/v1/chat/completions", + } + + err := n.OnProviderTunnelRequest(context.Background(), nil, req) + if err != nil { + t.Fatalf("OnProviderTunnelRequest failed: %v", err) + } +} + +func buildSessionTestPipeForNode(t *testing.T) (edgeSide *toki.TcpClient, sess *transport.Session) { + t.Helper() + edgeConn, nodeConn := net.Pipe() + edgeParserMap := toki.ParserMap{ + toki.TypeNameOf(&iop.ProviderTunnelFrame{}): func(b []byte) (proto.Message, error) { + m := &iop.ProviderTunnelFrame{} + return m, proto.Unmarshal(b, m) + }, + } + nodeParserMap := toki.ParserMap{} + edgeSide = toki.NewTcpClient(edgeConn, 0, 0, edgeParserMap) + nodeSide := toki.NewTcpClient(nodeConn, 0, 0, nodeParserMap) + t.Cleanup(func() { edgeSide.Close(); nodeSide.Close() }) + + sess = transport.ExportNewSession(nodeSide, zap.NewNop(), "node-id-1", "alias-1") + return edgeSide, sess +} + +func TestNodeOnProviderTunnelRequest_LookupFailure(t *testing.T) { + router := &fixedRouter{ + adapterName: "nonexistent", + adapters: make(map[string]runtime.Adapter), + lookupErrors: map[string]error{ + "nonexistent": errors.New("adapter lookup error"), + }, + } + n, _ := makeNode(t, router) + + edgeSide, sess := buildSessionTestPipeForNode(t) + + req := &iop.ProviderTunnelRequest{ + RunId: "run-tunnel-1", + TunnelId: "tunnel-1", + Adapter: "nonexistent", + Target: "qwen", + } + + frameCh := make(chan *iop.ProviderTunnelFrame, 10) + toki.AddListenerTyped[*iop.ProviderTunnelFrame](&edgeSide.Communicator, func(tf *iop.ProviderTunnelFrame) { + frameCh <- tf + }) + + err := n.OnProviderTunnelRequest(context.Background(), sess, req) + if err == nil { + t.Fatal("expected error, got nil") + } + + select { + case frame := <-frameCh: + if frame.GetRunId() != "run-tunnel-1" || frame.GetTunnelId() != "tunnel-1" { + t.Errorf("unexpected IDs in frame: run_id=%q tunnel_id=%q", frame.GetRunId(), frame.GetTunnelId()) + } + if frame.GetSequence() != 0 { + t.Errorf("expected sequence 0, got %d", frame.GetSequence()) + } + if frame.GetKind() != iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_ERROR { + t.Errorf("expected ERROR frame, got %v", frame.GetKind()) + } + if !strings.Contains(frame.GetError(), "adapter lookup error") { + t.Errorf("expected error message containing 'adapter lookup error', got %q", frame.GetError()) + } + if frame.GetNodeId() != "node-id-1" || frame.GetNodeAlias() != "alias-1" { + t.Errorf("unexpected node ID or alias: node_id=%q alias=%q", frame.GetNodeId(), frame.GetNodeAlias()) + } + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for ERROR frame") + } + + // Verify no more frames + select { + case frame := <-frameCh: + t.Fatalf("unexpected duplicate frame received: %+v", frame) + default: + } +} + +func TestNodeOnProviderTunnelRequest_UnsupportedAdapter(t *testing.T) { + // countingAdapter does not implement ProviderTunnelAdapter + mta := &countingAdapter{} + router := &fixedRouter{adapterName: "test", adapters: make(map[string]runtime.Adapter)} + router.adapters["test"] = mta + n, _ := makeNode(t, router) + + edgeSide, sess := buildSessionTestPipeForNode(t) + + req := &iop.ProviderTunnelRequest{ + RunId: "run-tunnel-1", + TunnelId: "tunnel-1", + Adapter: "test", + Target: "qwen", + } + + frameCh := make(chan *iop.ProviderTunnelFrame, 10) + toki.AddListenerTyped[*iop.ProviderTunnelFrame](&edgeSide.Communicator, func(tf *iop.ProviderTunnelFrame) { + frameCh <- tf + }) + + err := n.OnProviderTunnelRequest(context.Background(), sess, req) + if err == nil { + t.Fatal("expected error, got nil") + } + + select { + case frame := <-frameCh: + if frame.GetRunId() != "run-tunnel-1" || frame.GetTunnelId() != "tunnel-1" { + t.Errorf("unexpected IDs in frame: run_id=%q tunnel_id=%q", frame.GetRunId(), frame.GetTunnelId()) + } + if frame.GetSequence() != 0 { + t.Errorf("expected sequence 0, got %d", frame.GetSequence()) + } + if frame.GetKind() != iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_ERROR { + t.Errorf("expected ERROR frame, got %v", frame.GetKind()) + } + if !strings.Contains(frame.GetError(), "does not support tunneling") { + t.Errorf("expected error message containing 'does not support tunneling', got %q", frame.GetError()) + } + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for ERROR frame") + } + + // Verify no more frames + select { + case frame := <-frameCh: + t.Fatalf("unexpected duplicate frame received: %+v", frame) + default: + } +} + +func TestNodeOnProviderTunnelRequest_AdapterErrorNoDuplicate(t *testing.T) { + // Adapter directly returns error + mta := &mockTunnelAdapter{ + t: t, + expectedReq: runtime.ProviderTunnelRequest{ + RunID: "run-tunnel-1", + TunnelID: "tunnel-1", + }, + respondErr: errors.New("adapter runtime error"), + } + router := &fixedRouter{adapterName: "openai_compat", adapters: make(map[string]runtime.Adapter)} + router.adapters["openai_compat"] = mta + n, _ := makeNode(t, router) + + edgeSide, sess := buildSessionTestPipeForNode(t) + + req := &iop.ProviderTunnelRequest{ + RunId: "run-tunnel-1", + TunnelId: "tunnel-1", + Adapter: "openai_compat", + Target: "qwen", + } + + frameCh := make(chan *iop.ProviderTunnelFrame, 10) + toki.AddListenerTyped[*iop.ProviderTunnelFrame](&edgeSide.Communicator, func(tf *iop.ProviderTunnelFrame) { + frameCh <- tf + }) + + err := n.OnProviderTunnelRequest(context.Background(), sess, req) + if err == nil { + t.Fatal("expected error, got nil") + } + + // The mockTunnelAdapter inside does not emit frames if respondErr is set, it just returns errors. + // Since we removed sendTunnelError for adapter failures from node.go, no frames should be received at all. + // Wait a brief moment to ensure no frames were sent. + select { + case frame := <-frameCh: + t.Fatalf("unexpected frame received: %+v", frame) + case <-time.After(100 * time.Millisecond): + } +} diff --git a/apps/node/internal/runtime/types.go b/apps/node/internal/runtime/types.go index 77b0dfd..f14537f 100644 --- a/apps/node/internal/runtime/types.go +++ b/apps/node/internal/runtime/types.go @@ -213,3 +213,57 @@ type PolicyEngine interface { Apply(ctx context.Context, spec ExecutionSpec) (ExecutionSpec, error) Validate(ctx context.Context, spec ExecutionSpec) error } + +// ProviderTunnelRequest represents the node-domain request for raw provider tunnel execution. +type ProviderTunnelRequest struct { + RunID string + TunnelID string + Adapter string + Target string + Method string + Path string + Headers map[string]string + Body []byte + Stream bool + TimeoutSec int + Metadata map[string]string + SessionID string +} + +// ProviderTunnelFrameKind classifies a ProviderTunnelFrame. +type ProviderTunnelFrameKind string + +const ( + ProviderTunnelFrameKindResponseStart ProviderTunnelFrameKind = "response_start" + ProviderTunnelFrameKindBody ProviderTunnelFrameKind = "body" + ProviderTunnelFrameKindEnd ProviderTunnelFrameKind = "end" + ProviderTunnelFrameKindError ProviderTunnelFrameKind = "error" + ProviderTunnelFrameKindUsage ProviderTunnelFrameKind = "usage" +) + +// ProviderTunnelFrame carries raw provider response segments to Edge. +type ProviderTunnelFrame struct { + RunID string + TunnelID string + Sequence int64 + Kind ProviderTunnelFrameKind + StatusCode int + Headers map[string]string + Body []byte + End bool + Error string + Usage *UsageStats + Metadata map[string]string + Timestamp time.Time +} + +// ProviderTunnelSink receives ProviderTunnelFrames emitted during provider tunnel execution. +type ProviderTunnelSink interface { + EmitTunnelFrame(ctx context.Context, frame ProviderTunnelFrame) error +} + +// ProviderTunnelAdapter is implemented by adapters that support direct HTTP/SSE raw tunneling. +type ProviderTunnelAdapter interface { + Adapter + TunnelProvider(ctx context.Context, req ProviderTunnelRequest, sink ProviderTunnelSink) error +} diff --git a/apps/node/internal/transport/export_test.go b/apps/node/internal/transport/export_test.go index 7908bbe..74f67c0 100644 --- a/apps/node/internal/transport/export_test.go +++ b/apps/node/internal/transport/export_test.go @@ -1,11 +1,3 @@ package transport -import ( - toki "git.toki-labs.com/toki/proto-socket/go" - "go.uber.org/zap" -) - -// ExportNewSession exposes newSession for black-box transport tests. -func ExportNewSession(client *toki.TcpClient, logger *zap.Logger, nodeID, alias string) *Session { - return newSession(client, logger, nodeID, alias) -} +// ExportNewSession moved to session.go to allow import from other test packages. diff --git a/apps/node/internal/transport/integration_test.go b/apps/node/internal/transport/integration_test.go index 7b72dd5..641f09c 100644 --- a/apps/node/internal/transport/integration_test.go +++ b/apps/node/internal/transport/integration_test.go @@ -133,6 +133,10 @@ func (m *mockHandler) OnConfigRefresh(_ context.Context, _ *transport.Session, r }, nil } +func (m *mockHandler) OnProviderTunnelRequest(ctx context.Context, sess *transport.Session, req *iop.ProviderTunnelRequest) error { + return nil +} + func getFreePort(t *testing.T) string { l, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { diff --git a/apps/node/internal/transport/parser.go b/apps/node/internal/transport/parser.go index a16404e..f5fc932 100644 --- a/apps/node/internal/transport/parser.go +++ b/apps/node/internal/transport/parser.go @@ -13,6 +13,10 @@ func nodeParserMap() toki.ParserMap { m := &iop.RunRequest{} return m, proto.Unmarshal(b, m) }, + toki.TypeNameOf(&iop.ProviderTunnelRequest{}): func(b []byte) (proto.Message, error) { + m := &iop.ProviderTunnelRequest{} + return m, proto.Unmarshal(b, m) + }, toki.TypeNameOf(&iop.EdgeNodeEvent{}): func(b []byte) (proto.Message, error) { m := &iop.EdgeNodeEvent{} return m, proto.Unmarshal(b, m) diff --git a/apps/node/internal/transport/parser_test.go b/apps/node/internal/transport/parser_test.go index 54aa329..e6eb0aa 100644 --- a/apps/node/internal/transport/parser_test.go +++ b/apps/node/internal/transport/parser_test.go @@ -45,6 +45,54 @@ func TestNodeParserMap_RunRequest(t *testing.T) { } } +func TestNodeParserMap_ProviderTunnelRequest(t *testing.T) { + parsers := nodeParserMap() + original := &iop.ProviderTunnelRequest{ + RunId: "run-1", + TunnelId: "tunnel-1", + Adapter: "openai_compat", + Target: "qwen", + Method: "POST", + Path: "/v1/chat/completions", + Headers: map[string]string{"content-type": "application/json"}, + Body: []byte(`{"stream":true}`), + Stream: true, + TimeoutSec: 30, + Metadata: map[string]string{"openai_model": "qwen"}, + SessionId: "openai", + } + payload, err := proto.Marshal(original) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + key := toki.TypeNameOf(original) + parser, ok := parsers[key] + if !ok { + t.Fatalf("parser not found for key: %s", key) + } + + parsed, err := parser(payload) + if err != nil { + t.Fatalf("parse: %v", err) + } + got := parsed.(*iop.ProviderTunnelRequest) + if got.GetRunId() != original.GetRunId() || + got.GetTunnelId() != original.GetTunnelId() || + got.GetAdapter() != original.GetAdapter() || + got.GetTarget() != original.GetTarget() || + got.GetMethod() != original.GetMethod() || + got.GetPath() != original.GetPath() || + got.GetHeaders()["content-type"] != "application/json" || + string(got.GetBody()) != string(original.GetBody()) || + got.GetStream() != original.GetStream() || + got.GetTimeoutSec() != original.GetTimeoutSec() || + got.GetMetadata()["openai_model"] != "qwen" || + got.GetSessionId() != original.GetSessionId() { + t.Fatalf("unexpected tunnel request: %+v", got) + } +} + func TestNodeParserMap_CancelRequest(t *testing.T) { parsers := nodeParserMap() original := &iop.CancelRequest{ diff --git a/apps/node/internal/transport/session.go b/apps/node/internal/transport/session.go index 021d9c2..12e983d 100644 --- a/apps/node/internal/transport/session.go +++ b/apps/node/internal/transport/session.go @@ -18,6 +18,7 @@ type Handler interface { OnCancel(ctx context.Context, sess *Session, req *iop.CancelRequest) error OnCommandRequest(ctx context.Context, sess *Session, req *iop.NodeCommandRequest) (*iop.NodeCommandResponse, error) OnConfigRefresh(ctx context.Context, sess *Session, req *iop.NodeConfigRefreshRequest) (*iop.NodeConfigRefreshResponse, error) + OnProviderTunnelRequest(ctx context.Context, sess *Session, req *iop.ProviderTunnelRequest) error } // Session represents the node's persistent connection to edge. @@ -69,6 +70,26 @@ func newSession(client *toki.TcpClient, logger *zap.Logger, nodeID, alias string } }) + toki.AddListenerTyped[*iop.ProviderTunnelRequest](&client.Communicator, func(req *iop.ProviderTunnelRequest) { + go func() { + s.mu.RLock() + h := s.handler + s.mu.RUnlock() + if h == nil { + logger.Warn("provider tunnel request ignored: handler not ready", + zap.String("run_id", req.GetRunId()), + ) + return + } + if err := h.OnProviderTunnelRequest(context.Background(), s, req); err != nil { + logger.Warn("provider tunnel request error", + zap.String("run_id", req.GetRunId()), + zap.Error(err), + ) + } + }() + }) + toki.AddRequestListenerTyped[*iop.NodeCommandRequest, *iop.NodeCommandResponse](&client.Communicator, func(req *iop.NodeCommandRequest) (*iop.NodeCommandResponse, error) { s.mu.RLock() h := s.handler @@ -133,6 +154,16 @@ func (s *Session) SetHandler(h Handler) { s.mu.Unlock() } +// NodeID returns the session's node ID. +func (s *Session) NodeID() string { + return s.nodeID +} + +// Alias returns the session's node alias. +func (s *Session) Alias() string { + return s.alias +} + func (s *Session) SetEventHandler(handler func(*iop.EdgeNodeEvent)) { s.mu.Lock() s.eventHandler = handler @@ -219,3 +250,8 @@ func transportDisconnectFields(info toki.DisconnectInfo) []zap.Field { } return fields } + +// ExportNewSession exposes newSession for black-box transport and node tests. +func ExportNewSession(client *toki.TcpClient, logger *zap.Logger, nodeID, alias string) *Session { + return newSession(client, logger, nodeID, alias) +} diff --git a/apps/node/internal/transport/session_test.go b/apps/node/internal/transport/session_test.go index e0eac53..240e06a 100644 --- a/apps/node/internal/transport/session_test.go +++ b/apps/node/internal/transport/session_test.go @@ -34,6 +34,9 @@ func (h *noopHandler) OnConfigRefresh(_ context.Context, _ *transport.Session, r Status: iop.NodeConfigRefreshStatus_NODE_CONFIG_REFRESH_STATUS_APPLIED, }, nil } +func (h *noopHandler) OnProviderTunnelRequest(_ context.Context, _ *transport.Session, _ *iop.ProviderTunnelRequest) error { + return nil +} func TestSession_SetHandler_ConcurrentSafe(t *testing.T) { var s transport.Session @@ -42,6 +45,7 @@ func TestSession_SetHandler_ConcurrentSafe(t *testing.T) { for i := 0; i < 50; i++ { wg.Add(1) go func() { + t.Helper() defer wg.Done() s.SetHandler(h) }() @@ -62,12 +66,20 @@ func buildSessionTestPipe(t *testing.T) (edgeSide *toki.TcpClient, nodeSide *tok m := &iop.NodeConfigRefreshResponse{} return m, proto.Unmarshal(b, m) }, + toki.TypeNameOf(&iop.ProviderTunnelFrame{}): func(b []byte) (proto.Message, error) { + m := &iop.ProviderTunnelFrame{} + return m, proto.Unmarshal(b, m) + }, } nodeParserMap := toki.ParserMap{ toki.TypeNameOf(&iop.NodeConfigRefreshRequest{}): func(b []byte) (proto.Message, error) { m := &iop.NodeConfigRefreshRequest{} return m, proto.Unmarshal(b, m) }, + toki.TypeNameOf(&iop.ProviderTunnelRequest{}): func(b []byte) (proto.Message, error) { + m := &iop.ProviderTunnelRequest{} + return m, proto.Unmarshal(b, m) + }, } edgeSide = toki.NewTcpClient(edgeConn, 0, 0, edgeParserMap) nodeSide = toki.NewTcpClient(nodeConn, 0, 0, nodeParserMap) @@ -162,6 +174,116 @@ func TestSessionConfigRefreshRequestNoHandlerReturnsFailure(t *testing.T) { } } +type tunnelHandler struct { + noopHandler + mu sync.Mutex + requests []*iop.ProviderTunnelRequest + done chan struct{} +} + +func (h *tunnelHandler) OnProviderTunnelRequest(_ context.Context, _ *transport.Session, req *iop.ProviderTunnelRequest) error { + h.mu.Lock() + h.requests = append(h.requests, req) + h.mu.Unlock() + close(h.done) + return nil +} + +func TestSessionProviderTunnelRequest(t *testing.T) { + edgeSide, nodeSide := buildSessionTestPipe(t) + + sess := transport.ExportNewSession(nodeSide, zap.NewNop(), "node-test", "alias-test") + handler := &tunnelHandler{done: make(chan struct{})} + sess.SetHandler(handler) + + req := &iop.ProviderTunnelRequest{ + RunId: "run-1", + TunnelId: "tunnel-1", + Adapter: "openai_compat", + Target: "target-1", + } + + if err := edgeSide.Send(req); err != nil { + t.Fatalf("Send: %v", err) + } + + select { + case <-handler.done: + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for tunnel request") + } + + handler.mu.Lock() + defer handler.mu.Unlock() + if len(handler.requests) != 1 { + t.Fatalf("expected 1 request, got %d", len(handler.requests)) + } + got := handler.requests[0] + if got.GetRunId() != "run-1" || got.GetTunnelId() != "tunnel-1" { + t.Errorf("unexpected request fields: %+v", got) + } +} + // Compile check: Session must export a way to create instances for tests. // ExportNewSession is expected in session_export_test.go or a separate test helper file. var _ = fmt.Sprintf + +type errorTunnelHandler struct { + noopHandler + done chan struct{} +} + +func (h *errorTunnelHandler) OnProviderTunnelRequest(_ context.Context, _ *transport.Session, req *iop.ProviderTunnelRequest) error { + defer close(h.done) + return errors.New("tunnel error") +} + +func TestSessionProviderTunnelRequest_NilAndErrHandler(t *testing.T) { + // 1. Nil handler test + { + edgeSide, nodeSide := buildSessionTestPipe(t) + sess := transport.ExportNewSession(nodeSide, zap.NewNop(), "node-test-nil", "alias-test") + // handler is nil + + req := &iop.ProviderTunnelRequest{ + RunId: "run-nil", + TunnelId: "tunnel-nil", + Adapter: "openai_compat", + Target: "target-nil", + } + + if err := edgeSide.Send(req); err != nil { + t.Fatalf("Send: %v", err) + } + + // wait a bit to ensure no panic + time.Sleep(100 * time.Millisecond) + sess.Close() + } + + // 2. Error handler test + { + edgeSide, nodeSide := buildSessionTestPipe(t) + sess := transport.ExportNewSession(nodeSide, zap.NewNop(), "node-test-err", "alias-test") + handler := &errorTunnelHandler{done: make(chan struct{})} + sess.SetHandler(handler) + + req := &iop.ProviderTunnelRequest{ + RunId: "run-err", + TunnelId: "tunnel-err", + Adapter: "openai_compat", + Target: "target-err", + } + + if err := edgeSide.Send(req); err != nil { + t.Fatalf("Send: %v", err) + } + + select { + case <-handler.done: + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for tunnel request") + } + sess.Close() + } +} diff --git a/proto/gen/iop/runtime.pb.go b/proto/gen/iop/runtime.pb.go index 97cc4a2..5bd207c 100644 --- a/proto/gen/iop/runtime.pb.go +++ b/proto/gen/iop/runtime.pb.go @@ -120,6 +120,64 @@ func (CancelAction) EnumDescriptor() ([]byte, []int) { return file_proto_iop_runtime_proto_rawDescGZIP(), []int{1} } +type ProviderTunnelFrameKind int32 + +const ( + ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_UNSPECIFIED ProviderTunnelFrameKind = 0 + ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_RESPONSE_START ProviderTunnelFrameKind = 1 + ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_BODY ProviderTunnelFrameKind = 2 + ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END ProviderTunnelFrameKind = 3 + ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_ERROR ProviderTunnelFrameKind = 4 + ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_USAGE ProviderTunnelFrameKind = 5 +) + +// Enum value maps for ProviderTunnelFrameKind. +var ( + ProviderTunnelFrameKind_name = map[int32]string{ + 0: "PROVIDER_TUNNEL_FRAME_KIND_UNSPECIFIED", + 1: "PROVIDER_TUNNEL_FRAME_KIND_RESPONSE_START", + 2: "PROVIDER_TUNNEL_FRAME_KIND_BODY", + 3: "PROVIDER_TUNNEL_FRAME_KIND_END", + 4: "PROVIDER_TUNNEL_FRAME_KIND_ERROR", + 5: "PROVIDER_TUNNEL_FRAME_KIND_USAGE", + } + ProviderTunnelFrameKind_value = map[string]int32{ + "PROVIDER_TUNNEL_FRAME_KIND_UNSPECIFIED": 0, + "PROVIDER_TUNNEL_FRAME_KIND_RESPONSE_START": 1, + "PROVIDER_TUNNEL_FRAME_KIND_BODY": 2, + "PROVIDER_TUNNEL_FRAME_KIND_END": 3, + "PROVIDER_TUNNEL_FRAME_KIND_ERROR": 4, + "PROVIDER_TUNNEL_FRAME_KIND_USAGE": 5, + } +) + +func (x ProviderTunnelFrameKind) Enum() *ProviderTunnelFrameKind { + p := new(ProviderTunnelFrameKind) + *p = x + return p +} + +func (x ProviderTunnelFrameKind) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ProviderTunnelFrameKind) Descriptor() protoreflect.EnumDescriptor { + return file_proto_iop_runtime_proto_enumTypes[2].Descriptor() +} + +func (ProviderTunnelFrameKind) Type() protoreflect.EnumType { + return &file_proto_iop_runtime_proto_enumTypes[2] +} + +func (x ProviderTunnelFrameKind) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ProviderTunnelFrameKind.Descriptor instead. +func (ProviderTunnelFrameKind) EnumDescriptor() ([]byte, []int) { + return file_proto_iop_runtime_proto_rawDescGZIP(), []int{2} +} + type NodeCommandType int32 const ( @@ -162,11 +220,11 @@ func (x NodeCommandType) String() string { } func (NodeCommandType) Descriptor() protoreflect.EnumDescriptor { - return file_proto_iop_runtime_proto_enumTypes[2].Descriptor() + return file_proto_iop_runtime_proto_enumTypes[3].Descriptor() } func (NodeCommandType) Type() protoreflect.EnumType { - return &file_proto_iop_runtime_proto_enumTypes[2] + return &file_proto_iop_runtime_proto_enumTypes[3] } func (x NodeCommandType) Number() protoreflect.EnumNumber { @@ -175,7 +233,7 @@ func (x NodeCommandType) Number() protoreflect.EnumNumber { // Deprecated: Use NodeCommandType.Descriptor instead. func (NodeCommandType) EnumDescriptor() ([]byte, []int) { - return file_proto_iop_runtime_proto_rawDescGZIP(), []int{2} + return file_proto_iop_runtime_proto_rawDescGZIP(), []int{3} } type NodeConfigRefreshStatus int32 @@ -217,11 +275,11 @@ func (x NodeConfigRefreshStatus) String() string { } func (NodeConfigRefreshStatus) Descriptor() protoreflect.EnumDescriptor { - return file_proto_iop_runtime_proto_enumTypes[3].Descriptor() + return file_proto_iop_runtime_proto_enumTypes[4].Descriptor() } func (NodeConfigRefreshStatus) Type() protoreflect.EnumType { - return &file_proto_iop_runtime_proto_enumTypes[3] + return &file_proto_iop_runtime_proto_enumTypes[4] } func (x NodeConfigRefreshStatus) Number() protoreflect.EnumNumber { @@ -230,7 +288,7 @@ func (x NodeConfigRefreshStatus) Number() protoreflect.EnumNumber { // Deprecated: Use NodeConfigRefreshStatus.Descriptor instead. func (NodeConfigRefreshStatus) EnumDescriptor() ([]byte, []int) { - return file_proto_iop_runtime_proto_rawDescGZIP(), []int{3} + return file_proto_iop_runtime_proto_rawDescGZIP(), []int{4} } // RunRequest initiates an adapter execution on a node. @@ -491,6 +549,293 @@ func (x *RunEvent) GetNodeAlias() string { return "" } +// ProviderTunnelRequest asks a node to open a provider HTTP request and relay +// the raw provider response over ProviderTunnelFrame messages on the existing +// Edge-Node socket. It is separate from RunRequest, which remains the +// normalized adapter execution path. +type ProviderTunnelRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + RunId string `protobuf:"bytes,1,opt,name=run_id,json=runId,proto3" json:"run_id,omitempty"` + TunnelId string `protobuf:"bytes,2,opt,name=tunnel_id,json=tunnelId,proto3" json:"tunnel_id,omitempty"` + Adapter string `protobuf:"bytes,3,opt,name=adapter,proto3" json:"adapter,omitempty"` + Target string `protobuf:"bytes,4,opt,name=target,proto3" json:"target,omitempty"` + Method string `protobuf:"bytes,5,opt,name=method,proto3" json:"method,omitempty"` + Path string `protobuf:"bytes,6,opt,name=path,proto3" json:"path,omitempty"` + Headers map[string]string `protobuf:"bytes,7,rep,name=headers,proto3" json:"headers,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + Body []byte `protobuf:"bytes,8,opt,name=body,proto3" json:"body,omitempty"` + Stream bool `protobuf:"varint,9,opt,name=stream,proto3" json:"stream,omitempty"` + TimeoutSec int32 `protobuf:"varint,10,opt,name=timeout_sec,json=timeoutSec,proto3" json:"timeout_sec,omitempty"` + Metadata map[string]string `protobuf:"bytes,11,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + SessionId string `protobuf:"bytes,12,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProviderTunnelRequest) Reset() { + *x = ProviderTunnelRequest{} + mi := &file_proto_iop_runtime_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProviderTunnelRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProviderTunnelRequest) ProtoMessage() {} + +func (x *ProviderTunnelRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_iop_runtime_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProviderTunnelRequest.ProtoReflect.Descriptor instead. +func (*ProviderTunnelRequest) Descriptor() ([]byte, []int) { + return file_proto_iop_runtime_proto_rawDescGZIP(), []int{2} +} + +func (x *ProviderTunnelRequest) GetRunId() string { + if x != nil { + return x.RunId + } + return "" +} + +func (x *ProviderTunnelRequest) GetTunnelId() string { + if x != nil { + return x.TunnelId + } + return "" +} + +func (x *ProviderTunnelRequest) GetAdapter() string { + if x != nil { + return x.Adapter + } + return "" +} + +func (x *ProviderTunnelRequest) GetTarget() string { + if x != nil { + return x.Target + } + return "" +} + +func (x *ProviderTunnelRequest) GetMethod() string { + if x != nil { + return x.Method + } + return "" +} + +func (x *ProviderTunnelRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *ProviderTunnelRequest) GetHeaders() map[string]string { + if x != nil { + return x.Headers + } + return nil +} + +func (x *ProviderTunnelRequest) GetBody() []byte { + if x != nil { + return x.Body + } + return nil +} + +func (x *ProviderTunnelRequest) GetStream() bool { + if x != nil { + return x.Stream + } + return false +} + +func (x *ProviderTunnelRequest) GetTimeoutSec() int32 { + if x != nil { + return x.TimeoutSec + } + return 0 +} + +func (x *ProviderTunnelRequest) GetMetadata() map[string]string { + if x != nil { + return x.Metadata + } + return nil +} + +func (x *ProviderTunnelRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +// ProviderTunnelFrame carries ordered raw provider response data back to Edge. +// Body bytes are the passthrough source of truth and must not be routed through +// RunEvent.delta or the lossy event bus fanout. +type ProviderTunnelFrame struct { + state protoimpl.MessageState `protogen:"open.v1"` + RunId string `protobuf:"bytes,1,opt,name=run_id,json=runId,proto3" json:"run_id,omitempty"` + TunnelId string `protobuf:"bytes,2,opt,name=tunnel_id,json=tunnelId,proto3" json:"tunnel_id,omitempty"` + Sequence int64 `protobuf:"varint,3,opt,name=sequence,proto3" json:"sequence,omitempty"` + Kind ProviderTunnelFrameKind `protobuf:"varint,4,opt,name=kind,proto3,enum=iop.ProviderTunnelFrameKind" json:"kind,omitempty"` + StatusCode int32 `protobuf:"varint,5,opt,name=status_code,json=statusCode,proto3" json:"status_code,omitempty"` + Headers map[string]string `protobuf:"bytes,6,rep,name=headers,proto3" json:"headers,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + Body []byte `protobuf:"bytes,7,opt,name=body,proto3" json:"body,omitempty"` + End bool `protobuf:"varint,8,opt,name=end,proto3" json:"end,omitempty"` + Error string `protobuf:"bytes,9,opt,name=error,proto3" json:"error,omitempty"` + Usage *Usage `protobuf:"bytes,10,opt,name=usage,proto3" json:"usage,omitempty"` + Metadata map[string]string `protobuf:"bytes,11,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + Timestamp int64 `protobuf:"varint,12,opt,name=timestamp,proto3" json:"timestamp,omitempty"` // unix nano + NodeId string `protobuf:"bytes,13,opt,name=node_id,json=nodeId,proto3" json:"node_id,omitempty"` + NodeAlias string `protobuf:"bytes,14,opt,name=node_alias,json=nodeAlias,proto3" json:"node_alias,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProviderTunnelFrame) Reset() { + *x = ProviderTunnelFrame{} + mi := &file_proto_iop_runtime_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProviderTunnelFrame) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProviderTunnelFrame) ProtoMessage() {} + +func (x *ProviderTunnelFrame) ProtoReflect() protoreflect.Message { + mi := &file_proto_iop_runtime_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProviderTunnelFrame.ProtoReflect.Descriptor instead. +func (*ProviderTunnelFrame) Descriptor() ([]byte, []int) { + return file_proto_iop_runtime_proto_rawDescGZIP(), []int{3} +} + +func (x *ProviderTunnelFrame) GetRunId() string { + if x != nil { + return x.RunId + } + return "" +} + +func (x *ProviderTunnelFrame) GetTunnelId() string { + if x != nil { + return x.TunnelId + } + return "" +} + +func (x *ProviderTunnelFrame) GetSequence() int64 { + if x != nil { + return x.Sequence + } + return 0 +} + +func (x *ProviderTunnelFrame) GetKind() ProviderTunnelFrameKind { + if x != nil { + return x.Kind + } + return ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_UNSPECIFIED +} + +func (x *ProviderTunnelFrame) GetStatusCode() int32 { + if x != nil { + return x.StatusCode + } + return 0 +} + +func (x *ProviderTunnelFrame) GetHeaders() map[string]string { + if x != nil { + return x.Headers + } + return nil +} + +func (x *ProviderTunnelFrame) GetBody() []byte { + if x != nil { + return x.Body + } + return nil +} + +func (x *ProviderTunnelFrame) GetEnd() bool { + if x != nil { + return x.End + } + return false +} + +func (x *ProviderTunnelFrame) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +func (x *ProviderTunnelFrame) GetUsage() *Usage { + if x != nil { + return x.Usage + } + return nil +} + +func (x *ProviderTunnelFrame) GetMetadata() map[string]string { + if x != nil { + return x.Metadata + } + return nil +} + +func (x *ProviderTunnelFrame) GetTimestamp() int64 { + if x != nil { + return x.Timestamp + } + return 0 +} + +func (x *ProviderTunnelFrame) GetNodeId() string { + if x != nil { + return x.NodeId + } + return "" +} + +func (x *ProviderTunnelFrame) GetNodeAlias() string { + if x != nil { + return x.NodeAlias + } + return "" +} + // EdgeNodeEvent is a general edge-node lifecycle/control event envelope. // It is separate from RunEvent, which is reserved for adapter execution streams. type EdgeNodeEvent struct { @@ -509,7 +854,7 @@ type EdgeNodeEvent struct { func (x *EdgeNodeEvent) Reset() { *x = EdgeNodeEvent{} - mi := &file_proto_iop_runtime_proto_msgTypes[2] + mi := &file_proto_iop_runtime_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -521,7 +866,7 @@ func (x *EdgeNodeEvent) String() string { func (*EdgeNodeEvent) ProtoMessage() {} func (x *EdgeNodeEvent) ProtoReflect() protoreflect.Message { - mi := &file_proto_iop_runtime_proto_msgTypes[2] + mi := &file_proto_iop_runtime_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -534,7 +879,7 @@ func (x *EdgeNodeEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use EdgeNodeEvent.ProtoReflect.Descriptor instead. func (*EdgeNodeEvent) Descriptor() ([]byte, []int) { - return file_proto_iop_runtime_proto_rawDescGZIP(), []int{2} + return file_proto_iop_runtime_proto_rawDescGZIP(), []int{4} } func (x *EdgeNodeEvent) GetEventId() string { @@ -603,7 +948,7 @@ type Usage struct { func (x *Usage) Reset() { *x = Usage{} - mi := &file_proto_iop_runtime_proto_msgTypes[3] + mi := &file_proto_iop_runtime_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -615,7 +960,7 @@ func (x *Usage) String() string { func (*Usage) ProtoMessage() {} func (x *Usage) ProtoReflect() protoreflect.Message { - mi := &file_proto_iop_runtime_proto_msgTypes[3] + mi := &file_proto_iop_runtime_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -628,7 +973,7 @@ func (x *Usage) ProtoReflect() protoreflect.Message { // Deprecated: Use Usage.ProtoReflect.Descriptor instead. func (*Usage) Descriptor() ([]byte, []int) { - return file_proto_iop_runtime_proto_rawDescGZIP(), []int{3} + return file_proto_iop_runtime_proto_rawDescGZIP(), []int{5} } func (x *Usage) GetInputTokens() int32 { @@ -655,7 +1000,7 @@ type Heartbeat struct { func (x *Heartbeat) Reset() { *x = Heartbeat{} - mi := &file_proto_iop_runtime_proto_msgTypes[4] + mi := &file_proto_iop_runtime_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -667,7 +1012,7 @@ func (x *Heartbeat) String() string { func (*Heartbeat) ProtoMessage() {} func (x *Heartbeat) ProtoReflect() protoreflect.Message { - mi := &file_proto_iop_runtime_proto_msgTypes[4] + mi := &file_proto_iop_runtime_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -680,7 +1025,7 @@ func (x *Heartbeat) ProtoReflect() protoreflect.Message { // Deprecated: Use Heartbeat.ProtoReflect.Descriptor instead. func (*Heartbeat) Descriptor() ([]byte, []int) { - return file_proto_iop_runtime_proto_rawDescGZIP(), []int{4} + return file_proto_iop_runtime_proto_rawDescGZIP(), []int{6} } func (x *Heartbeat) GetTimestamp() int64 { @@ -704,7 +1049,7 @@ type CancelRequest struct { func (x *CancelRequest) Reset() { *x = CancelRequest{} - mi := &file_proto_iop_runtime_proto_msgTypes[5] + mi := &file_proto_iop_runtime_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -716,7 +1061,7 @@ func (x *CancelRequest) String() string { func (*CancelRequest) ProtoMessage() {} func (x *CancelRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_iop_runtime_proto_msgTypes[5] + mi := &file_proto_iop_runtime_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -729,7 +1074,7 @@ func (x *CancelRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CancelRequest.ProtoReflect.Descriptor instead. func (*CancelRequest) Descriptor() ([]byte, []int) { - return file_proto_iop_runtime_proto_rawDescGZIP(), []int{5} + return file_proto_iop_runtime_proto_rawDescGZIP(), []int{7} } func (x *CancelRequest) GetRunId() string { @@ -782,7 +1127,7 @@ type NodeCommandRequest struct { func (x *NodeCommandRequest) Reset() { *x = NodeCommandRequest{} - mi := &file_proto_iop_runtime_proto_msgTypes[6] + mi := &file_proto_iop_runtime_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -794,7 +1139,7 @@ func (x *NodeCommandRequest) String() string { func (*NodeCommandRequest) ProtoMessage() {} func (x *NodeCommandRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_iop_runtime_proto_msgTypes[6] + mi := &file_proto_iop_runtime_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -807,7 +1152,7 @@ func (x *NodeCommandRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use NodeCommandRequest.ProtoReflect.Descriptor instead. func (*NodeCommandRequest) Descriptor() ([]byte, []int) { - return file_proto_iop_runtime_proto_rawDescGZIP(), []int{6} + return file_proto_iop_runtime_proto_rawDescGZIP(), []int{8} } func (x *NodeCommandRequest) GetRequestId() string { @@ -879,7 +1224,7 @@ type NodeCommandResponse struct { func (x *NodeCommandResponse) Reset() { *x = NodeCommandResponse{} - mi := &file_proto_iop_runtime_proto_msgTypes[7] + mi := &file_proto_iop_runtime_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -891,7 +1236,7 @@ func (x *NodeCommandResponse) String() string { func (*NodeCommandResponse) ProtoMessage() {} func (x *NodeCommandResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_iop_runtime_proto_msgTypes[7] + mi := &file_proto_iop_runtime_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -904,7 +1249,7 @@ func (x *NodeCommandResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use NodeCommandResponse.ProtoReflect.Descriptor instead. func (*NodeCommandResponse) Descriptor() ([]byte, []int) { - return file_proto_iop_runtime_proto_rawDescGZIP(), []int{7} + return file_proto_iop_runtime_proto_rawDescGZIP(), []int{9} } func (x *NodeCommandResponse) GetRequestId() string { @@ -1008,7 +1353,7 @@ type ProviderSnapshot struct { func (x *ProviderSnapshot) Reset() { *x = ProviderSnapshot{} - mi := &file_proto_iop_runtime_proto_msgTypes[8] + mi := &file_proto_iop_runtime_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1020,7 +1365,7 @@ func (x *ProviderSnapshot) String() string { func (*ProviderSnapshot) ProtoMessage() {} func (x *ProviderSnapshot) ProtoReflect() protoreflect.Message { - mi := &file_proto_iop_runtime_proto_msgTypes[8] + mi := &file_proto_iop_runtime_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1033,7 +1378,7 @@ func (x *ProviderSnapshot) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderSnapshot.ProtoReflect.Descriptor instead. func (*ProviderSnapshot) Descriptor() ([]byte, []int) { - return file_proto_iop_runtime_proto_rawDescGZIP(), []int{8} + return file_proto_iop_runtime_proto_rawDescGZIP(), []int{10} } func (x *ProviderSnapshot) GetAdapter() string { @@ -1155,7 +1500,7 @@ type AgentUsageStatus struct { func (x *AgentUsageStatus) Reset() { *x = AgentUsageStatus{} - mi := &file_proto_iop_runtime_proto_msgTypes[9] + mi := &file_proto_iop_runtime_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1167,7 +1512,7 @@ func (x *AgentUsageStatus) String() string { func (*AgentUsageStatus) ProtoMessage() {} func (x *AgentUsageStatus) ProtoReflect() protoreflect.Message { - mi := &file_proto_iop_runtime_proto_msgTypes[9] + mi := &file_proto_iop_runtime_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1180,7 +1525,7 @@ func (x *AgentUsageStatus) ProtoReflect() protoreflect.Message { // Deprecated: Use AgentUsageStatus.ProtoReflect.Descriptor instead. func (*AgentUsageStatus) Descriptor() ([]byte, []int) { - return file_proto_iop_runtime_proto_rawDescGZIP(), []int{9} + return file_proto_iop_runtime_proto_rawDescGZIP(), []int{11} } func (x *AgentUsageStatus) GetRawOutput() string { @@ -1236,7 +1581,7 @@ type Error struct { func (x *Error) Reset() { *x = Error{} - mi := &file_proto_iop_runtime_proto_msgTypes[10] + mi := &file_proto_iop_runtime_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1248,7 +1593,7 @@ func (x *Error) String() string { func (*Error) ProtoMessage() {} func (x *Error) ProtoReflect() protoreflect.Message { - mi := &file_proto_iop_runtime_proto_msgTypes[10] + mi := &file_proto_iop_runtime_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1261,7 +1606,7 @@ func (x *Error) ProtoReflect() protoreflect.Message { // Deprecated: Use Error.ProtoReflect.Descriptor instead. func (*Error) Descriptor() ([]byte, []int) { - return file_proto_iop_runtime_proto_rawDescGZIP(), []int{10} + return file_proto_iop_runtime_proto_rawDescGZIP(), []int{12} } func (x *Error) GetCode() string { @@ -1288,7 +1633,7 @@ type RegisterRequest struct { func (x *RegisterRequest) Reset() { *x = RegisterRequest{} - mi := &file_proto_iop_runtime_proto_msgTypes[11] + mi := &file_proto_iop_runtime_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1300,7 +1645,7 @@ func (x *RegisterRequest) String() string { func (*RegisterRequest) ProtoMessage() {} func (x *RegisterRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_iop_runtime_proto_msgTypes[11] + mi := &file_proto_iop_runtime_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1313,7 +1658,7 @@ func (x *RegisterRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RegisterRequest.ProtoReflect.Descriptor instead. func (*RegisterRequest) Descriptor() ([]byte, []int) { - return file_proto_iop_runtime_proto_rawDescGZIP(), []int{11} + return file_proto_iop_runtime_proto_rawDescGZIP(), []int{13} } func (x *RegisterRequest) GetToken() string { @@ -1337,7 +1682,7 @@ type RegisterResponse struct { func (x *RegisterResponse) Reset() { *x = RegisterResponse{} - mi := &file_proto_iop_runtime_proto_msgTypes[12] + mi := &file_proto_iop_runtime_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1349,7 +1694,7 @@ func (x *RegisterResponse) String() string { func (*RegisterResponse) ProtoMessage() {} func (x *RegisterResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_iop_runtime_proto_msgTypes[12] + mi := &file_proto_iop_runtime_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1362,7 +1707,7 @@ func (x *RegisterResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RegisterResponse.ProtoReflect.Descriptor instead. func (*RegisterResponse) Descriptor() ([]byte, []int) { - return file_proto_iop_runtime_proto_rawDescGZIP(), []int{12} + return file_proto_iop_runtime_proto_rawDescGZIP(), []int{14} } func (x *RegisterResponse) GetAccepted() bool { @@ -1411,7 +1756,7 @@ type NodeConfigPayload struct { func (x *NodeConfigPayload) Reset() { *x = NodeConfigPayload{} - mi := &file_proto_iop_runtime_proto_msgTypes[13] + mi := &file_proto_iop_runtime_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1423,7 +1768,7 @@ func (x *NodeConfigPayload) String() string { func (*NodeConfigPayload) ProtoMessage() {} func (x *NodeConfigPayload) ProtoReflect() protoreflect.Message { - mi := &file_proto_iop_runtime_proto_msgTypes[13] + mi := &file_proto_iop_runtime_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1436,7 +1781,7 @@ func (x *NodeConfigPayload) ProtoReflect() protoreflect.Message { // Deprecated: Use NodeConfigPayload.ProtoReflect.Descriptor instead. func (*NodeConfigPayload) Descriptor() ([]byte, []int) { - return file_proto_iop_runtime_proto_rawDescGZIP(), []int{13} + return file_proto_iop_runtime_proto_rawDescGZIP(), []int{15} } func (x *NodeConfigPayload) GetAdapters() []*AdapterConfig { @@ -1478,7 +1823,7 @@ type AdapterConfig struct { func (x *AdapterConfig) Reset() { *x = AdapterConfig{} - mi := &file_proto_iop_runtime_proto_msgTypes[14] + mi := &file_proto_iop_runtime_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1490,7 +1835,7 @@ func (x *AdapterConfig) String() string { func (*AdapterConfig) ProtoMessage() {} func (x *AdapterConfig) ProtoReflect() protoreflect.Message { - mi := &file_proto_iop_runtime_proto_msgTypes[14] + mi := &file_proto_iop_runtime_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1503,7 +1848,7 @@ func (x *AdapterConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use AdapterConfig.ProtoReflect.Descriptor instead. func (*AdapterConfig) Descriptor() ([]byte, []int) { - return file_proto_iop_runtime_proto_rawDescGZIP(), []int{14} + return file_proto_iop_runtime_proto_rawDescGZIP(), []int{16} } func (x *AdapterConfig) GetType() string { @@ -1635,7 +1980,7 @@ type MockAdapterConfig struct { func (x *MockAdapterConfig) Reset() { *x = MockAdapterConfig{} - mi := &file_proto_iop_runtime_proto_msgTypes[15] + mi := &file_proto_iop_runtime_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1647,7 +1992,7 @@ func (x *MockAdapterConfig) String() string { func (*MockAdapterConfig) ProtoMessage() {} func (x *MockAdapterConfig) ProtoReflect() protoreflect.Message { - mi := &file_proto_iop_runtime_proto_msgTypes[15] + mi := &file_proto_iop_runtime_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1660,7 +2005,7 @@ func (x *MockAdapterConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use MockAdapterConfig.ProtoReflect.Descriptor instead. func (*MockAdapterConfig) Descriptor() ([]byte, []int) { - return file_proto_iop_runtime_proto_rawDescGZIP(), []int{15} + return file_proto_iop_runtime_proto_rawDescGZIP(), []int{17} } type CLIAdapterConfig struct { @@ -1672,7 +2017,7 @@ type CLIAdapterConfig struct { func (x *CLIAdapterConfig) Reset() { *x = CLIAdapterConfig{} - mi := &file_proto_iop_runtime_proto_msgTypes[16] + mi := &file_proto_iop_runtime_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1684,7 +2029,7 @@ func (x *CLIAdapterConfig) String() string { func (*CLIAdapterConfig) ProtoMessage() {} func (x *CLIAdapterConfig) ProtoReflect() protoreflect.Message { - mi := &file_proto_iop_runtime_proto_msgTypes[16] + mi := &file_proto_iop_runtime_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1697,7 +2042,7 @@ func (x *CLIAdapterConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use CLIAdapterConfig.ProtoReflect.Descriptor instead. func (*CLIAdapterConfig) Descriptor() ([]byte, []int) { - return file_proto_iop_runtime_proto_rawDescGZIP(), []int{16} + return file_proto_iop_runtime_proto_rawDescGZIP(), []int{18} } func (x *CLIAdapterConfig) GetProfiles() map[string]*CLIProfileConfig { @@ -1726,7 +2071,7 @@ type CLIProfileConfig struct { func (x *CLIProfileConfig) Reset() { *x = CLIProfileConfig{} - mi := &file_proto_iop_runtime_proto_msgTypes[17] + mi := &file_proto_iop_runtime_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1738,7 +2083,7 @@ func (x *CLIProfileConfig) String() string { func (*CLIProfileConfig) ProtoMessage() {} func (x *CLIProfileConfig) ProtoReflect() protoreflect.Message { - mi := &file_proto_iop_runtime_proto_msgTypes[17] + mi := &file_proto_iop_runtime_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1751,7 +2096,7 @@ func (x *CLIProfileConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use CLIProfileConfig.ProtoReflect.Descriptor instead. func (*CLIProfileConfig) Descriptor() ([]byte, []int) { - return file_proto_iop_runtime_proto_rawDescGZIP(), []int{17} + return file_proto_iop_runtime_proto_rawDescGZIP(), []int{19} } func (x *CLIProfileConfig) GetCommand() string { @@ -1841,7 +2186,7 @@ type CLICompletionMarker struct { func (x *CLICompletionMarker) Reset() { *x = CLICompletionMarker{} - mi := &file_proto_iop_runtime_proto_msgTypes[18] + mi := &file_proto_iop_runtime_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1853,7 +2198,7 @@ func (x *CLICompletionMarker) String() string { func (*CLICompletionMarker) ProtoMessage() {} func (x *CLICompletionMarker) ProtoReflect() protoreflect.Message { - mi := &file_proto_iop_runtime_proto_msgTypes[18] + mi := &file_proto_iop_runtime_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1866,7 +2211,7 @@ func (x *CLICompletionMarker) ProtoReflect() protoreflect.Message { // Deprecated: Use CLICompletionMarker.ProtoReflect.Descriptor instead. func (*CLICompletionMarker) Descriptor() ([]byte, []int) { - return file_proto_iop_runtime_proto_rawDescGZIP(), []int{18} + return file_proto_iop_runtime_proto_rawDescGZIP(), []int{20} } func (x *CLICompletionMarker) GetLine() string { @@ -1897,7 +2242,7 @@ type OllamaAdapterConfig struct { func (x *OllamaAdapterConfig) Reset() { *x = OllamaAdapterConfig{} - mi := &file_proto_iop_runtime_proto_msgTypes[19] + mi := &file_proto_iop_runtime_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1909,7 +2254,7 @@ func (x *OllamaAdapterConfig) String() string { func (*OllamaAdapterConfig) ProtoMessage() {} func (x *OllamaAdapterConfig) ProtoReflect() protoreflect.Message { - mi := &file_proto_iop_runtime_proto_msgTypes[19] + mi := &file_proto_iop_runtime_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1922,7 +2267,7 @@ func (x *OllamaAdapterConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use OllamaAdapterConfig.ProtoReflect.Descriptor instead. func (*OllamaAdapterConfig) Descriptor() ([]byte, []int) { - return file_proto_iop_runtime_proto_rawDescGZIP(), []int{19} + return file_proto_iop_runtime_proto_rawDescGZIP(), []int{21} } func (x *OllamaAdapterConfig) GetBaseUrl() string { @@ -1980,7 +2325,7 @@ type VllmAdapterConfig struct { func (x *VllmAdapterConfig) Reset() { *x = VllmAdapterConfig{} - mi := &file_proto_iop_runtime_proto_msgTypes[20] + mi := &file_proto_iop_runtime_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1992,7 +2337,7 @@ func (x *VllmAdapterConfig) String() string { func (*VllmAdapterConfig) ProtoMessage() {} func (x *VllmAdapterConfig) ProtoReflect() protoreflect.Message { - mi := &file_proto_iop_runtime_proto_msgTypes[20] + mi := &file_proto_iop_runtime_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2005,7 +2350,7 @@ func (x *VllmAdapterConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use VllmAdapterConfig.ProtoReflect.Descriptor instead. func (*VllmAdapterConfig) Descriptor() ([]byte, []int) { - return file_proto_iop_runtime_proto_rawDescGZIP(), []int{20} + return file_proto_iop_runtime_proto_rawDescGZIP(), []int{22} } func (x *VllmAdapterConfig) GetEndpoint() string { @@ -2058,7 +2403,7 @@ type OpenAICompatAdapterConfig struct { func (x *OpenAICompatAdapterConfig) Reset() { *x = OpenAICompatAdapterConfig{} - mi := &file_proto_iop_runtime_proto_msgTypes[21] + mi := &file_proto_iop_runtime_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2070,7 +2415,7 @@ func (x *OpenAICompatAdapterConfig) String() string { func (*OpenAICompatAdapterConfig) ProtoMessage() {} func (x *OpenAICompatAdapterConfig) ProtoReflect() protoreflect.Message { - mi := &file_proto_iop_runtime_proto_msgTypes[21] + mi := &file_proto_iop_runtime_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2083,7 +2428,7 @@ func (x *OpenAICompatAdapterConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use OpenAICompatAdapterConfig.ProtoReflect.Descriptor instead. func (*OpenAICompatAdapterConfig) Descriptor() ([]byte, []int) { - return file_proto_iop_runtime_proto_rawDescGZIP(), []int{21} + return file_proto_iop_runtime_proto_rawDescGZIP(), []int{23} } func (x *OpenAICompatAdapterConfig) GetProvider() string { @@ -2147,7 +2492,7 @@ type NodeRuntimeConfig struct { func (x *NodeRuntimeConfig) Reset() { *x = NodeRuntimeConfig{} - mi := &file_proto_iop_runtime_proto_msgTypes[22] + mi := &file_proto_iop_runtime_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2159,7 +2504,7 @@ func (x *NodeRuntimeConfig) String() string { func (*NodeRuntimeConfig) ProtoMessage() {} func (x *NodeRuntimeConfig) ProtoReflect() protoreflect.Message { - mi := &file_proto_iop_runtime_proto_msgTypes[22] + mi := &file_proto_iop_runtime_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2172,7 +2517,7 @@ func (x *NodeRuntimeConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use NodeRuntimeConfig.ProtoReflect.Descriptor instead. func (*NodeRuntimeConfig) Descriptor() ([]byte, []int) { - return file_proto_iop_runtime_proto_rawDescGZIP(), []int{22} + return file_proto_iop_runtime_proto_rawDescGZIP(), []int{24} } func (x *NodeRuntimeConfig) GetConcurrency() int32 { @@ -2194,7 +2539,7 @@ type NodeConfigRefreshRequest struct { func (x *NodeConfigRefreshRequest) Reset() { *x = NodeConfigRefreshRequest{} - mi := &file_proto_iop_runtime_proto_msgTypes[23] + mi := &file_proto_iop_runtime_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2206,7 +2551,7 @@ func (x *NodeConfigRefreshRequest) String() string { func (*NodeConfigRefreshRequest) ProtoMessage() {} func (x *NodeConfigRefreshRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_iop_runtime_proto_msgTypes[23] + mi := &file_proto_iop_runtime_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2219,7 +2564,7 @@ func (x *NodeConfigRefreshRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use NodeConfigRefreshRequest.ProtoReflect.Descriptor instead. func (*NodeConfigRefreshRequest) Descriptor() ([]byte, []int) { - return file_proto_iop_runtime_proto_rawDescGZIP(), []int{23} + return file_proto_iop_runtime_proto_rawDescGZIP(), []int{25} } func (x *NodeConfigRefreshRequest) GetRequestId() string { @@ -2256,7 +2601,7 @@ type NodeConfigRefreshResponse struct { func (x *NodeConfigRefreshResponse) Reset() { *x = NodeConfigRefreshResponse{} - mi := &file_proto_iop_runtime_proto_msgTypes[24] + mi := &file_proto_iop_runtime_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2268,7 +2613,7 @@ func (x *NodeConfigRefreshResponse) String() string { func (*NodeConfigRefreshResponse) ProtoMessage() {} func (x *NodeConfigRefreshResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_iop_runtime_proto_msgTypes[24] + mi := &file_proto_iop_runtime_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2281,7 +2626,7 @@ func (x *NodeConfigRefreshResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use NodeConfigRefreshResponse.ProtoReflect.Descriptor instead. func (*NodeConfigRefreshResponse) Descriptor() ([]byte, []int) { - return file_proto_iop_runtime_proto_rawDescGZIP(), []int{24} + return file_proto_iop_runtime_proto_rawDescGZIP(), []int{26} } func (x *NodeConfigRefreshResponse) GetRequestId() string { @@ -2359,6 +2704,53 @@ const file_proto_iop_runtime_proto_rawDesc = "" + "node_alias\x18\f \x01(\tR\tnodeAlias\x1a;\n" + "\rMetadataEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x97\x04\n" + + "\x15ProviderTunnelRequest\x12\x15\n" + + "\x06run_id\x18\x01 \x01(\tR\x05runId\x12\x1b\n" + + "\ttunnel_id\x18\x02 \x01(\tR\btunnelId\x12\x18\n" + + "\aadapter\x18\x03 \x01(\tR\aadapter\x12\x16\n" + + "\x06target\x18\x04 \x01(\tR\x06target\x12\x16\n" + + "\x06method\x18\x05 \x01(\tR\x06method\x12\x12\n" + + "\x04path\x18\x06 \x01(\tR\x04path\x12A\n" + + "\aheaders\x18\a \x03(\v2'.iop.ProviderTunnelRequest.HeadersEntryR\aheaders\x12\x12\n" + + "\x04body\x18\b \x01(\fR\x04body\x12\x16\n" + + "\x06stream\x18\t \x01(\bR\x06stream\x12\x1f\n" + + "\vtimeout_sec\x18\n" + + " \x01(\x05R\n" + + "timeoutSec\x12D\n" + + "\bmetadata\x18\v \x03(\v2(.iop.ProviderTunnelRequest.MetadataEntryR\bmetadata\x12\x1d\n" + + "\n" + + "session_id\x18\f \x01(\tR\tsessionId\x1a:\n" + + "\fHeadersEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1a;\n" + + "\rMetadataEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xea\x04\n" + + "\x13ProviderTunnelFrame\x12\x15\n" + + "\x06run_id\x18\x01 \x01(\tR\x05runId\x12\x1b\n" + + "\ttunnel_id\x18\x02 \x01(\tR\btunnelId\x12\x1a\n" + + "\bsequence\x18\x03 \x01(\x03R\bsequence\x120\n" + + "\x04kind\x18\x04 \x01(\x0e2\x1c.iop.ProviderTunnelFrameKindR\x04kind\x12\x1f\n" + + "\vstatus_code\x18\x05 \x01(\x05R\n" + + "statusCode\x12?\n" + + "\aheaders\x18\x06 \x03(\v2%.iop.ProviderTunnelFrame.HeadersEntryR\aheaders\x12\x12\n" + + "\x04body\x18\a \x01(\fR\x04body\x12\x10\n" + + "\x03end\x18\b \x01(\bR\x03end\x12\x14\n" + + "\x05error\x18\t \x01(\tR\x05error\x12 \n" + + "\x05usage\x18\n" + + " \x01(\v2\n" + + ".iop.UsageR\x05usage\x12B\n" + + "\bmetadata\x18\v \x03(\v2&.iop.ProviderTunnelFrame.MetadataEntryR\bmetadata\x12\x1c\n" + + "\ttimestamp\x18\f \x01(\x03R\ttimestamp\x12\x17\n" + + "\anode_id\x18\r \x01(\tR\x06nodeId\x12\x1d\n" + + "\n" + + "node_alias\x18\x0e \x01(\tR\tnodeAlias\x1a:\n" + + "\fHeadersEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1a;\n" + + "\rMetadataEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xb6\x02\n" + "\rEdgeNodeEvent\x12\x19\n" + "\bevent_id\x18\x01 \x01(\tR\aeventId\x12\x12\n" + @@ -2540,7 +2932,14 @@ const file_proto_iop_runtime_proto_rawDesc = "" + "\fCancelAction\x12\x1d\n" + "\x19CANCEL_ACTION_UNSPECIFIED\x10\x00\x12\x1c\n" + "\x18CANCEL_ACTION_CANCEL_RUN\x10\x01\x12#\n" + - "\x1fCANCEL_ACTION_TERMINATE_SESSION\x10\x02*\xea\x01\n" + + "\x1fCANCEL_ACTION_TERMINATE_SESSION\x10\x02*\x89\x02\n" + + "\x17ProviderTunnelFrameKind\x12*\n" + + "&PROVIDER_TUNNEL_FRAME_KIND_UNSPECIFIED\x10\x00\x12-\n" + + ")PROVIDER_TUNNEL_FRAME_KIND_RESPONSE_START\x10\x01\x12#\n" + + "\x1fPROVIDER_TUNNEL_FRAME_KIND_BODY\x10\x02\x12\"\n" + + "\x1ePROVIDER_TUNNEL_FRAME_KIND_END\x10\x03\x12$\n" + + " PROVIDER_TUNNEL_FRAME_KIND_ERROR\x10\x04\x12$\n" + + " PROVIDER_TUNNEL_FRAME_KIND_USAGE\x10\x05*\xea\x01\n" + "\x0fNodeCommandType\x12!\n" + "\x1dNODE_COMMAND_TYPE_UNSPECIFIED\x10\x00\x12\"\n" + "\x1eNODE_COMMAND_TYPE_USAGE_STATUS\x10\x01\x12\"\n" + @@ -2567,84 +2966,97 @@ func file_proto_iop_runtime_proto_rawDescGZIP() []byte { return file_proto_iop_runtime_proto_rawDescData } -var file_proto_iop_runtime_proto_enumTypes = make([]protoimpl.EnumInfo, 4) -var file_proto_iop_runtime_proto_msgTypes = make([]protoimpl.MessageInfo, 33) +var file_proto_iop_runtime_proto_enumTypes = make([]protoimpl.EnumInfo, 5) +var file_proto_iop_runtime_proto_msgTypes = make([]protoimpl.MessageInfo, 39) var file_proto_iop_runtime_proto_goTypes = []any{ (RunSessionMode)(0), // 0: iop.RunSessionMode (CancelAction)(0), // 1: iop.CancelAction - (NodeCommandType)(0), // 2: iop.NodeCommandType - (NodeConfigRefreshStatus)(0), // 3: iop.NodeConfigRefreshStatus - (*RunRequest)(nil), // 4: iop.RunRequest - (*RunEvent)(nil), // 5: iop.RunEvent - (*EdgeNodeEvent)(nil), // 6: iop.EdgeNodeEvent - (*Usage)(nil), // 7: iop.Usage - (*Heartbeat)(nil), // 8: iop.Heartbeat - (*CancelRequest)(nil), // 9: iop.CancelRequest - (*NodeCommandRequest)(nil), // 10: iop.NodeCommandRequest - (*NodeCommandResponse)(nil), // 11: iop.NodeCommandResponse - (*ProviderSnapshot)(nil), // 12: iop.ProviderSnapshot - (*AgentUsageStatus)(nil), // 13: iop.AgentUsageStatus - (*Error)(nil), // 14: iop.Error - (*RegisterRequest)(nil), // 15: iop.RegisterRequest - (*RegisterResponse)(nil), // 16: iop.RegisterResponse - (*NodeConfigPayload)(nil), // 17: iop.NodeConfigPayload - (*AdapterConfig)(nil), // 18: iop.AdapterConfig - (*MockAdapterConfig)(nil), // 19: iop.MockAdapterConfig - (*CLIAdapterConfig)(nil), // 20: iop.CLIAdapterConfig - (*CLIProfileConfig)(nil), // 21: iop.CLIProfileConfig - (*CLICompletionMarker)(nil), // 22: iop.CLICompletionMarker - (*OllamaAdapterConfig)(nil), // 23: iop.OllamaAdapterConfig - (*VllmAdapterConfig)(nil), // 24: iop.VllmAdapterConfig - (*OpenAICompatAdapterConfig)(nil), // 25: iop.OpenAICompatAdapterConfig - (*NodeRuntimeConfig)(nil), // 26: iop.NodeRuntimeConfig - (*NodeConfigRefreshRequest)(nil), // 27: iop.NodeConfigRefreshRequest - (*NodeConfigRefreshResponse)(nil), // 28: iop.NodeConfigRefreshResponse - nil, // 29: iop.RunRequest.MetadataEntry - nil, // 30: iop.RunEvent.MetadataEntry - nil, // 31: iop.EdgeNodeEvent.MetadataEntry - nil, // 32: iop.NodeCommandRequest.MetadataEntry - nil, // 33: iop.NodeCommandResponse.ResultEntry - nil, // 34: iop.AgentUsageStatus.MetadataEntry - nil, // 35: iop.CLIAdapterConfig.ProfilesEntry - nil, // 36: iop.OpenAICompatAdapterConfig.HeadersEntry - (*structpb.Struct)(nil), // 37: google.protobuf.Struct + (ProviderTunnelFrameKind)(0), // 2: iop.ProviderTunnelFrameKind + (NodeCommandType)(0), // 3: iop.NodeCommandType + (NodeConfigRefreshStatus)(0), // 4: iop.NodeConfigRefreshStatus + (*RunRequest)(nil), // 5: iop.RunRequest + (*RunEvent)(nil), // 6: iop.RunEvent + (*ProviderTunnelRequest)(nil), // 7: iop.ProviderTunnelRequest + (*ProviderTunnelFrame)(nil), // 8: iop.ProviderTunnelFrame + (*EdgeNodeEvent)(nil), // 9: iop.EdgeNodeEvent + (*Usage)(nil), // 10: iop.Usage + (*Heartbeat)(nil), // 11: iop.Heartbeat + (*CancelRequest)(nil), // 12: iop.CancelRequest + (*NodeCommandRequest)(nil), // 13: iop.NodeCommandRequest + (*NodeCommandResponse)(nil), // 14: iop.NodeCommandResponse + (*ProviderSnapshot)(nil), // 15: iop.ProviderSnapshot + (*AgentUsageStatus)(nil), // 16: iop.AgentUsageStatus + (*Error)(nil), // 17: iop.Error + (*RegisterRequest)(nil), // 18: iop.RegisterRequest + (*RegisterResponse)(nil), // 19: iop.RegisterResponse + (*NodeConfigPayload)(nil), // 20: iop.NodeConfigPayload + (*AdapterConfig)(nil), // 21: iop.AdapterConfig + (*MockAdapterConfig)(nil), // 22: iop.MockAdapterConfig + (*CLIAdapterConfig)(nil), // 23: iop.CLIAdapterConfig + (*CLIProfileConfig)(nil), // 24: iop.CLIProfileConfig + (*CLICompletionMarker)(nil), // 25: iop.CLICompletionMarker + (*OllamaAdapterConfig)(nil), // 26: iop.OllamaAdapterConfig + (*VllmAdapterConfig)(nil), // 27: iop.VllmAdapterConfig + (*OpenAICompatAdapterConfig)(nil), // 28: iop.OpenAICompatAdapterConfig + (*NodeRuntimeConfig)(nil), // 29: iop.NodeRuntimeConfig + (*NodeConfigRefreshRequest)(nil), // 30: iop.NodeConfigRefreshRequest + (*NodeConfigRefreshResponse)(nil), // 31: iop.NodeConfigRefreshResponse + nil, // 32: iop.RunRequest.MetadataEntry + nil, // 33: iop.RunEvent.MetadataEntry + nil, // 34: iop.ProviderTunnelRequest.HeadersEntry + nil, // 35: iop.ProviderTunnelRequest.MetadataEntry + nil, // 36: iop.ProviderTunnelFrame.HeadersEntry + nil, // 37: iop.ProviderTunnelFrame.MetadataEntry + nil, // 38: iop.EdgeNodeEvent.MetadataEntry + nil, // 39: iop.NodeCommandRequest.MetadataEntry + nil, // 40: iop.NodeCommandResponse.ResultEntry + nil, // 41: iop.AgentUsageStatus.MetadataEntry + nil, // 42: iop.CLIAdapterConfig.ProfilesEntry + nil, // 43: iop.OpenAICompatAdapterConfig.HeadersEntry + (*structpb.Struct)(nil), // 44: google.protobuf.Struct } var file_proto_iop_runtime_proto_depIdxs = []int32{ - 37, // 0: iop.RunRequest.policy:type_name -> google.protobuf.Struct - 37, // 1: iop.RunRequest.input:type_name -> google.protobuf.Struct - 29, // 2: iop.RunRequest.metadata:type_name -> iop.RunRequest.MetadataEntry + 44, // 0: iop.RunRequest.policy:type_name -> google.protobuf.Struct + 44, // 1: iop.RunRequest.input:type_name -> google.protobuf.Struct + 32, // 2: iop.RunRequest.metadata:type_name -> iop.RunRequest.MetadataEntry 0, // 3: iop.RunRequest.session_mode:type_name -> iop.RunSessionMode - 7, // 4: iop.RunEvent.usage:type_name -> iop.Usage - 30, // 5: iop.RunEvent.metadata:type_name -> iop.RunEvent.MetadataEntry - 31, // 6: iop.EdgeNodeEvent.metadata:type_name -> iop.EdgeNodeEvent.MetadataEntry - 1, // 7: iop.CancelRequest.action:type_name -> iop.CancelAction - 2, // 8: iop.NodeCommandRequest.type:type_name -> iop.NodeCommandType - 32, // 9: iop.NodeCommandRequest.metadata:type_name -> iop.NodeCommandRequest.MetadataEntry - 2, // 10: iop.NodeCommandResponse.type:type_name -> iop.NodeCommandType - 13, // 11: iop.NodeCommandResponse.usage_status:type_name -> iop.AgentUsageStatus - 33, // 12: iop.NodeCommandResponse.result:type_name -> iop.NodeCommandResponse.ResultEntry - 12, // 13: iop.NodeCommandResponse.provider_snapshots:type_name -> iop.ProviderSnapshot - 34, // 14: iop.AgentUsageStatus.metadata:type_name -> iop.AgentUsageStatus.MetadataEntry - 17, // 15: iop.RegisterResponse.config:type_name -> iop.NodeConfigPayload - 18, // 16: iop.NodeConfigPayload.adapters:type_name -> iop.AdapterConfig - 26, // 17: iop.NodeConfigPayload.runtime:type_name -> iop.NodeRuntimeConfig - 37, // 18: iop.AdapterConfig.settings:type_name -> google.protobuf.Struct - 20, // 19: iop.AdapterConfig.cli:type_name -> iop.CLIAdapterConfig - 23, // 20: iop.AdapterConfig.ollama:type_name -> iop.OllamaAdapterConfig - 24, // 21: iop.AdapterConfig.vllm:type_name -> iop.VllmAdapterConfig - 19, // 22: iop.AdapterConfig.mock:type_name -> iop.MockAdapterConfig - 25, // 23: iop.AdapterConfig.openai_compat:type_name -> iop.OpenAICompatAdapterConfig - 35, // 24: iop.CLIAdapterConfig.profiles:type_name -> iop.CLIAdapterConfig.ProfilesEntry - 22, // 25: iop.CLIProfileConfig.completion_marker:type_name -> iop.CLICompletionMarker - 36, // 26: iop.OpenAICompatAdapterConfig.headers:type_name -> iop.OpenAICompatAdapterConfig.HeadersEntry - 17, // 27: iop.NodeConfigRefreshRequest.config:type_name -> iop.NodeConfigPayload - 3, // 28: iop.NodeConfigRefreshResponse.status:type_name -> iop.NodeConfigRefreshStatus - 21, // 29: iop.CLIAdapterConfig.ProfilesEntry.value:type_name -> iop.CLIProfileConfig - 30, // [30:30] is the sub-list for method output_type - 30, // [30:30] is the sub-list for method input_type - 30, // [30:30] is the sub-list for extension type_name - 30, // [30:30] is the sub-list for extension extendee - 0, // [0:30] is the sub-list for field type_name + 10, // 4: iop.RunEvent.usage:type_name -> iop.Usage + 33, // 5: iop.RunEvent.metadata:type_name -> iop.RunEvent.MetadataEntry + 34, // 6: iop.ProviderTunnelRequest.headers:type_name -> iop.ProviderTunnelRequest.HeadersEntry + 35, // 7: iop.ProviderTunnelRequest.metadata:type_name -> iop.ProviderTunnelRequest.MetadataEntry + 2, // 8: iop.ProviderTunnelFrame.kind:type_name -> iop.ProviderTunnelFrameKind + 36, // 9: iop.ProviderTunnelFrame.headers:type_name -> iop.ProviderTunnelFrame.HeadersEntry + 10, // 10: iop.ProviderTunnelFrame.usage:type_name -> iop.Usage + 37, // 11: iop.ProviderTunnelFrame.metadata:type_name -> iop.ProviderTunnelFrame.MetadataEntry + 38, // 12: iop.EdgeNodeEvent.metadata:type_name -> iop.EdgeNodeEvent.MetadataEntry + 1, // 13: iop.CancelRequest.action:type_name -> iop.CancelAction + 3, // 14: iop.NodeCommandRequest.type:type_name -> iop.NodeCommandType + 39, // 15: iop.NodeCommandRequest.metadata:type_name -> iop.NodeCommandRequest.MetadataEntry + 3, // 16: iop.NodeCommandResponse.type:type_name -> iop.NodeCommandType + 16, // 17: iop.NodeCommandResponse.usage_status:type_name -> iop.AgentUsageStatus + 40, // 18: iop.NodeCommandResponse.result:type_name -> iop.NodeCommandResponse.ResultEntry + 15, // 19: iop.NodeCommandResponse.provider_snapshots:type_name -> iop.ProviderSnapshot + 41, // 20: iop.AgentUsageStatus.metadata:type_name -> iop.AgentUsageStatus.MetadataEntry + 20, // 21: iop.RegisterResponse.config:type_name -> iop.NodeConfigPayload + 21, // 22: iop.NodeConfigPayload.adapters:type_name -> iop.AdapterConfig + 29, // 23: iop.NodeConfigPayload.runtime:type_name -> iop.NodeRuntimeConfig + 44, // 24: iop.AdapterConfig.settings:type_name -> google.protobuf.Struct + 23, // 25: iop.AdapterConfig.cli:type_name -> iop.CLIAdapterConfig + 26, // 26: iop.AdapterConfig.ollama:type_name -> iop.OllamaAdapterConfig + 27, // 27: iop.AdapterConfig.vllm:type_name -> iop.VllmAdapterConfig + 22, // 28: iop.AdapterConfig.mock:type_name -> iop.MockAdapterConfig + 28, // 29: iop.AdapterConfig.openai_compat:type_name -> iop.OpenAICompatAdapterConfig + 42, // 30: iop.CLIAdapterConfig.profiles:type_name -> iop.CLIAdapterConfig.ProfilesEntry + 25, // 31: iop.CLIProfileConfig.completion_marker:type_name -> iop.CLICompletionMarker + 43, // 32: iop.OpenAICompatAdapterConfig.headers:type_name -> iop.OpenAICompatAdapterConfig.HeadersEntry + 20, // 33: iop.NodeConfigRefreshRequest.config:type_name -> iop.NodeConfigPayload + 4, // 34: iop.NodeConfigRefreshResponse.status:type_name -> iop.NodeConfigRefreshStatus + 24, // 35: iop.CLIAdapterConfig.ProfilesEntry.value:type_name -> iop.CLIProfileConfig + 36, // [36:36] is the sub-list for method output_type + 36, // [36:36] is the sub-list for method input_type + 36, // [36:36] is the sub-list for extension type_name + 36, // [36:36] is the sub-list for extension extendee + 0, // [0:36] is the sub-list for field type_name } func init() { file_proto_iop_runtime_proto_init() } @@ -2652,7 +3064,7 @@ func file_proto_iop_runtime_proto_init() { if File_proto_iop_runtime_proto != nil { return } - file_proto_iop_runtime_proto_msgTypes[14].OneofWrappers = []any{ + file_proto_iop_runtime_proto_msgTypes[16].OneofWrappers = []any{ (*AdapterConfig_Cli)(nil), (*AdapterConfig_Ollama)(nil), (*AdapterConfig_Vllm)(nil), @@ -2664,8 +3076,8 @@ func file_proto_iop_runtime_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_iop_runtime_proto_rawDesc), len(file_proto_iop_runtime_proto_rawDesc)), - NumEnums: 4, - NumMessages: 33, + NumEnums: 5, + NumMessages: 39, NumExtensions: 0, NumServices: 0, }, diff --git a/proto/iop/runtime.proto b/proto/iop/runtime.proto index 4e14412..cd936f6 100644 --- a/proto/iop/runtime.proto +++ b/proto/iop/runtime.proto @@ -49,6 +49,54 @@ message RunEvent { string node_alias = 12; } +enum ProviderTunnelFrameKind { + PROVIDER_TUNNEL_FRAME_KIND_UNSPECIFIED = 0; + PROVIDER_TUNNEL_FRAME_KIND_RESPONSE_START = 1; + PROVIDER_TUNNEL_FRAME_KIND_BODY = 2; + PROVIDER_TUNNEL_FRAME_KIND_END = 3; + PROVIDER_TUNNEL_FRAME_KIND_ERROR = 4; + PROVIDER_TUNNEL_FRAME_KIND_USAGE = 5; +} + +// ProviderTunnelRequest asks a node to open a provider HTTP request and relay +// the raw provider response over ProviderTunnelFrame messages on the existing +// Edge-Node socket. It is separate from RunRequest, which remains the +// normalized adapter execution path. +message ProviderTunnelRequest { + string run_id = 1; + string tunnel_id = 2; + string adapter = 3; + string target = 4; + string method = 5; + string path = 6; + map headers = 7; + bytes body = 8; + bool stream = 9; + int32 timeout_sec = 10; + map metadata = 11; + string session_id = 12; +} + +// ProviderTunnelFrame carries ordered raw provider response data back to Edge. +// Body bytes are the passthrough source of truth and must not be routed through +// RunEvent.delta or the lossy event bus fanout. +message ProviderTunnelFrame { + string run_id = 1; + string tunnel_id = 2; + int64 sequence = 3; + ProviderTunnelFrameKind kind = 4; + int32 status_code = 5; + map headers = 6; + bytes body = 7; + bool end = 8; + string error = 9; + Usage usage = 10; + map metadata = 11; + int64 timestamp = 12; // unix nano + string node_id = 13; + string node_alias = 14; +} + // EdgeNodeEvent is a general edge-node lifecycle/control event envelope. // It is separate from RunEvent, which is reserved for adapter execution streams. message EdgeNodeEvent {