diff --git a/agent-roadmap/phase/automation-runtime-bridge/PHASE.md b/agent-roadmap/phase/automation-runtime-bridge/PHASE.md index 8139068..67d51d6 100644 --- a/agent-roadmap/phase/automation-runtime-bridge/PHASE.md +++ b/agent-roadmap/phase/automation-runtime-bridge/PHASE.md @@ -54,10 +54,14 @@ CLI 실행, specialized agent 등록, bootstrap/enrollment, 원격 터미널 브 - 경로: `agent-roadmap/archive/phase/automation-runtime-bridge/milestones/domain-agent-message-boundary.md` - 요약: 독립형 실행 전환으로 Edge 직접 domain payload boundary 정리가 현재 범위에서 필요 없어져 폐기한다. -- [진행중] 워크스페이스 포트/환경 표준화 +- [검토중] 워크스페이스 포트/환경 표준화 - 경로: `agent-roadmap/phase/automation-runtime-bridge/milestones/workspace-port-env-standardization.md` - 요약: Control Plane, Edge, Node, Client, OpenAI-compatible, A2A, wire, metrics, DB/cache 포트를 workspace 공통 대역으로 정렬한다. +- [진행중] OpenAI Responses Input Surface + - 경로: `agent-roadmap/phase/automation-runtime-bridge/milestones/openai-responses-input-surface.md` + - 요약: NomadCode External Integration을 잠근 선행 작업으로, Edge OpenAI-compatible 입력 표면에 non-streaming `POST /v1/responses`와 metadata 전달 계약을 최우선으로 추가한다. + - [계획] 브리지 선행 경계 안정화 - 경로: `agent-roadmap/phase/automation-runtime-bridge/milestones/bridge-boundary-hardening.md` - 요약: 원격 터미널 브리지 POC 전에 남은 호환성/소유권 리스크를 Client HTTP lifecycle, Edge run surface, Node terminal core, typed adapter config 계약으로 고정한다. @@ -71,3 +75,4 @@ CLI 실행, specialized agent 등록, bootstrap/enrollment, 원격 터미널 브 - OpenAI-compatible API와 A2A API에 terminal 제어 기능을 억지로 싣지 않는다. - Edge는 실행 요청의 broker 역할을 하고, Node는 대상 transport 실행자 역할을 유지한다. - 설치 가능한 대상은 bootstrap/enrollment 경로로, 설치가 어렵거나 일회성 유지보수 대상은 remote terminal bridge 경로로 구분한다. +- OpenAI-compatible Responses 표면은 외부 모델 호출 호환을 위한 입력 표면이며, IOP 고유 운영 제어는 native protocol이나 명시 운영 API로 분리한다. diff --git a/agent-roadmap/phase/automation-runtime-bridge/milestones/openai-responses-input-surface.md b/agent-roadmap/phase/automation-runtime-bridge/milestones/openai-responses-input-surface.md new file mode 100644 index 0000000..3325b02 --- /dev/null +++ b/agent-roadmap/phase/automation-runtime-bridge/milestones/openai-responses-input-surface.md @@ -0,0 +1,113 @@ +# Milestone: OpenAI Responses Input Surface + +## 위치 + +- Roadmap: `agent-roadmap/ROADMAP.md` +- Phase: `agent-roadmap/phase/automation-runtime-bridge/PHASE.md` + +## 목표 + +Edge OpenAI-compatible 입력 표면을 `/v1/chat/completions` baseline에서 non-streaming `POST /v1/responses`까지 확장한다. OpenAI-compatible Responses request shape를 받아 기존 Edge service와 Node execution 경로로 수렴시키고, IOP 공통 실행 힌트와 requester별 문맥은 별도 `iop` wrapper가 아니라 `metadata` field 아래의 최소 namespace로 보존한다. + +## 상태 + +[진행중] + +## 구현 잠금 + +- 상태: 해제 +- 결정 필요: 없음 + +## 범위 + +- Edge OpenAI-compatible HTTP server에 non-streaming `POST /v1/responses` 표면 추가 +- NomadCode Core가 보내는 `model`, string `input`, optional `instructions`, optional structured `metadata`, `stream=false` 요청 shape 지원 +- Responses request를 기존 Edge service `SubmitRun` 경로의 `adapter + target` 실행으로 변환 +- request `metadata`를 IOP 공통 root와 requester namespace 기준으로 service boundary까지 전달하고, `metadata.inference.target`의 internal target override 정책을 테스트로 고정 +- NomadCode Core parser가 읽을 수 있는 Responses-compatible response subset 제공 +- `iop-edge smoke openai` 또는 동등한 command/smoke에서 `/v1/responses` non-streaming path 검증 + +## 기능 + +### Epic: [responses-surface] Responses API Input Surface + +- [ ] [responses-handler] Edge OpenAI-compatible server가 `POST /v1/responses` non-streaming 요청을 받고 기존 service execution 경로로 변환한다. 검증: `go test -count=1 ./apps/edge/internal/openai` +- [ ] [metadata-contract] request `metadata`가 `request_id`, `inference.target`, `nomadcode.task_id`, `nomadcode.source` 최소 계약으로 Edge service boundary까지 보존되고, `metadata.inference.target` target override 정책이 테스트로 고정된다. 검증: `go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/service` +- [ ] [response-shape] Responses 응답이 `id`, `model`, `output_text` 또는 `output[].content[].text`, `usage`를 제공해 NomadCode Core parser와 호환된다. 검증: OpenAI server tests가 NomadCode-compatible response subset을 assert한다. +- [ ] [responses-smoke] `iop-edge smoke openai` 또는 동등 smoke가 `/healthz`, `/v1/models`, `POST /v1/responses`를 확인한다. 검증: remote/local edge smoke에서 non-streaming Responses path가 실제 listener에 대해 PASS한다. + +## Metadata V1 계약 + +Responses API root field인 `model`, `input`, `instructions`, `stream`은 OpenAI-compatible request shape로 유지한다. `metadata`는 IOP 공통 실행 힌트와 requester별 문맥을 담는 object이며, 현재 Milestone에서는 아래 최소 필드만 공식 지원한다. + +```json +{ + "model": "qwen3.6:35b-a3b-bf16", + "input": "Do the task...", + "instructions": "Be concise.", + "stream": false, + "metadata": { + "request_id": "req-abc", + "inference": { + "target": "qwen3.6:35b-a3b-bf16" + }, + "nomadcode": { + "task_id": "task-123", + "source": "plane" + } + } +} +``` + +### 지원 필드 + +| field | 필수 | 의미 | +|-------|------|------| +| `metadata.request_id` | 선택 | 호출자와 IOP 로그를 묶는 correlation id | +| `metadata.inference.target` | 선택 | OpenAI-compatible `model`과 별도로 IOP 내부 inference target을 지정해야 할 때 쓰는 override | +| `metadata.nomadcode.task_id` | 선택 | NomadCode가 자기 task와 응답을 연결하기 위한 requester namespace 값 | +| `metadata.nomadcode.source` | 선택 | NomadCode 내부 task 출처. 예: `manual`, `plane`, `jira` | + +### Target 결정 규칙 + +- internal target 우선순위: `config.openai.target` > `metadata.inference.target` > request root `model` +- response `model` 우선순위: request root `model` > internal target +- `/v1/responses`는 inference 표면이므로 `metadata.cli`는 현재 Milestone에서 지원하지 않는다. +- `metadata.inference`와 `metadata.cli`가 동시에 오거나, `metadata.cli`만 오면 `400 invalid_request_error`로 거절한다. + +### 제외 + +- `metadata.route`, `metadata.routing` +- `metadata.execution`, `metadata.policy`, `metadata.requester` +- `metadata.schema`, `metadata.idempotency_key` +- `metadata.nomadcode.external`, `metadata.nomadcode.project` +- credential/token/raw secret/full prompt/local path + +## 완료 리뷰 + +- 상태: 없음 +- 요청일: 없음 +- 완료 근거: 모든 기능 Task와 Task 안에 명시된 검증이 아직 충족되지 않았다. +- 리뷰 필요: + - [ ] 사용자가 완료 결과를 확인했다 + - [ ] archive 이동을 승인했다 +- 리뷰 코멘트: 없음 + +## 범위 제외 + +- streaming Responses API +- OpenAI Responses API 전체 schema 호환 +- A2A task delegation 구현 +- IOP native protocol로 NomadCode Core 실행 호출을 대체하는 작업 +- NomadCode Core task create/enqueue/poll smoke 자체 구현. 해당 통합 smoke는 `../nomadcode`의 External Integration Milestone에서 수행한다. +- `metadata.cli` 기반 CLI 실행 라우팅 + +## 작업 컨텍스트 + +- 관련 경로: `apps/edge/internal/openai`, `apps/edge/internal/service`, `apps/edge/cmd/edge`, `agent-test/local/edge-smoke.md` +- 최우선 사유: `../nomadcode` External Integration의 `iop-responses` 완료와 archive를 해제하는 선행 Milestone이다. +- 표준선(선택): 외부 호출 표면은 OpenAI-compatible shape를 유지하고, IOP 공통 힌트는 `metadata` root에, requester-specific 문맥은 `metadata.` namespace 아래에 둔다. +- 선행 작업: Ollama 서빙 안정화 기반, Edge OpenAI-compatible chat completions baseline +- 후속 작업: `../nomadcode` External Integration의 Core -> IOP Responses enqueue smoke +- 외부 의존 잠금: `nomadcode:agent-roadmap/phase/external-integration/milestones/external-integration.md`는 이 Milestone이 `[검토중]` 또는 `[완료]`가 되어 workspace lock이 enable로 동기화되기 전까지 완료/archive할 수 없다. +- 확인 필요: 없음 diff --git a/agent-roadmap/phase/automation-runtime-bridge/milestones/workspace-port-env-standardization.md b/agent-roadmap/phase/automation-runtime-bridge/milestones/workspace-port-env-standardization.md index 911bc36..3249112 100644 --- a/agent-roadmap/phase/automation-runtime-bridge/milestones/workspace-port-env-standardization.md +++ b/agent-roadmap/phase/automation-runtime-bridge/milestones/workspace-port-env-standardization.md @@ -11,7 +11,7 @@ IOP의 Control Plane, Edge, Node, Client, OpenAI-compatible/A2A surface, native ## 상태 -[진행중] +[검토중] ## 구현 잠금 @@ -41,7 +41,7 @@ IOP runtime surface별 포트와 원격 테스트 환경을 한 번에 읽을 Inventory에서 확인된 drift를 실제 기본값, 문서, smoke evidence로 정리한다. -- [ ] [port-default-rollout] `.env.example`, `docker-compose.yml`, config/default, app README, `agent-test/local`의 포트 기본값과 검증 명령을 표준 후보에 맞춰 정렬한다. +- [x] [port-default-rollout] `.env.example`, `docker-compose.yml`, config/default, app README, `agent-test/local`의 포트 기본값과 검증 명령을 표준 후보에 맞춰 정렬한다. ## 작업 산출물 @@ -50,14 +50,14 @@ Inventory에서 확인된 drift를 실제 기본값, 문서, smoke evidence로 | Surface | 현재 source | 내부 listen/container | host publish 또는 외부 슬롯 | 표준 후보 | 상태 | |---------|-------------|-----------------------|-----------------------------|-----------|------| | Client web/dev preview | `docker-compose.yml`, `.env.example`, `scripts/dev/web.sh` | container/nginx `3000` | compose/script/env `13000` | frontend `13000` | `01`에서 compose/env 정렬 | -| Control Plane HTTP | `configs/control-plane.yaml`, `apps/control-plane/cmd/control-plane/main.go`, `docker-compose.yml` | `9080` | compose/env `18000`, app/config direct default `9080` | backend `18000` | `01`에서 compose/env 정렬, runtime/config/docs는 `02` active | +| Control Plane HTTP | `configs/control-plane.yaml`, `apps/control-plane/cmd/control-plane/main.go`, `docker-compose.yml` | `9080` | compose/env `18000`, app/config direct default `9080` | backend `18000` | `01`/`02`에서 compose/env/runtime/config/docs 정렬 | | Control Plane Client WS wire | `configs/control-plane.yaml`, client defaults, compose | `19080` | `19080` | wire `19080` | 일치 | | Control Plane-Edge TCP wire | `configs/control-plane.yaml`, compose | `19081` | `19081` | wire `19081` | 일치 | -| Edge-Node TCP transport | `configs/edge.yaml`, compose, node config | Edge internal `9090`, node local `localhost:9090` | compose/env/field `19090` | wire `19090` | host slot 일치, internal 유지 | +| Edge-Node TCP transport | `configs/edge.yaml`, compose, node config | Edge internal `9090`, node local `localhost:9090` | compose/env/field `19090` | wire `19090` | host slot 일치, internal 유지; `04`에서 code-server publish cleanup 확인 | | Edge artifact/bootstrap HTTP | `configs/edge*.yaml*`, host setup, bootstrap pack | `18080` | field `18080` | artifact `18080` | 일치 | -| Edge OpenAI-compatible HTTP | `configs/edge.yaml`, edge command/docs | internal/default `8080` | field/model endpoint `18081` | model `18081` | internal/default와 field slot 분리 필요 | +| Edge OpenAI-compatible HTTP | `configs/edge.yaml`, edge command/docs | internal/default `8080` | field/model endpoint `18081` | model `18081` | `02`에서 field/model slot `18081`과 docs 정렬, internal/default 분리 유지 | | Edge A2A HTTP | `configs/edge.yaml`, edge input server | internal/default `8081` | 명시 host publish 없음 | backend/model 후보 검토 | rollout 결정 필요 | -| Metrics | `configs/*.yaml`, host setup, local rules | node `9091`, edge `9092`, CP `9093`, worker `9094` | Edge metrics field `19092` | metrics `19092` | host publish 기준 보강 필요 | +| Metrics | `configs/*.yaml`, host setup, local rules | node `9091`, edge `9092`, CP `9093`, worker `9094` | Edge metrics field `19092` | metrics `19092` | `02`에서 Edge metrics field `19092` 정렬, `04`에서 code-server publish cleanup 확인 | | DB/cache | `docker-compose.yml`, `.env.example` | Postgres `5432`, Redis `6379` | compose/env host `15400/16300` | DB/cache `15400/16300` | `01`에서 compose/env 정렬 | ### Remote environment map @@ -88,19 +88,24 @@ Inventory에서 확인된 drift를 실제 기본값, 문서, smoke evidence로 ### 현 작업 현황 -- [x] `agent-task/archive/2026/06/m-workspace-port-env-standardization/01_compose_env_defaults/` — PASS. `.env.example`과 `docker-compose.yml`의 compose/env host publish defaults를 정렬했고, `complete.log`와 review log가 archive에 있다. `Roadmap Completion` 섹션이 없으므로 기능 Task 체크는 no-op이다. -- [ ] `agent-task/m-workspace-port-env-standardization/02+01_runtime_config_docs/` — active. `01` 선행 조건은 충족되었고, runtime/config/docs/test-rule defaults 정렬이 남아 있다. -- [ ] `agent-task/m-workspace-port-env-standardization/03+01,02_remote_smoke_evidence/` — active. `01`은 충족, `02`는 대기 중이다. 이 split이 `Roadmap Targets`의 `port-default-rollout` check-on-pass evidence를 담당한다. +- [x] `agent-task/archive/2026/06/m-workspace-port-env-standardization/01_compose_env_defaults/` — PASS. `.env.example`과 `docker-compose.yml`의 compose/env host publish defaults를 정렬했다. +- [x] `agent-task/archive/2026/06/m-workspace-port-env-standardization/02+01_runtime_config_docs/` — PASS. runtime/config/docs/test-rule defaults를 `18000`, `18081`, `19092` 기준으로 정렬했고 regression assertions를 보강했다. +- [x] `agent-task/archive/2026/06/m-workspace-port-env-standardization/03+01,02_remote_smoke_evidence/` — PASS. `Roadmap Completion`에서 `port-default-rollout` PASS를 선언했고, Go/Flutter/client web build 및 remote code-server compose config render 증거를 남겼다. +- [x] `agent-task/archive/2026/06/m-workspace-port-env-standardization/04+03_standard_slot_runtime_check/` — PASS. 사용자 결정에 따라 `code-server` 재시작/재생성 없이 remote compose config에서 `19090`, `19092`, `19190` publish 제거를 확인했고, container/host 접근 확인은 `192.168.0.97` 기준으로 기록했다. +- [ ] 후속 운영 확인 — 사용자 관리 `code-server` recreate 이후 standard-slot full runtime compose smoke를 다시 실행할 수 있다. 현재 마일스톤 완료 판정의 필수 조건으로는 남기지 않는다. ## 완료 리뷰 -- 상태: 없음 -- 요청일: 없음 -- 완료 근거: 없음 +- 상태: 요청됨 +- 요청일: 2026-06-08 +- 완료 근거: + - Inventory 기능 task 4개는 이미 완료 상태이며, rollout 기능 task `port-default-rollout`은 `03+01,02_remote_smoke_evidence/complete.log`의 `Roadmap Completion`에서 PASS로 선언됐다. + - `01`/`02`에서 compose/env/runtime/config/docs/test-rule defaults를 표준 후보에 맞춰 정렬했고, `go test`, `flutter test`, `make client-build-web` 검증 증거가 archive에 있다. + - `04`에서 사용자 금지 조건을 지켜 `code-server` stop/start/restart/recreate 없이 remote compose config만 수정했고, `19090`, `19092`, `19190` publish 제거와 `192.168.0.97` 기준 closed 확인을 기록했다. - 리뷰 필요: - [ ] 사용자가 완료 결과를 확인했다 - [ ] archive 이동을 승인했다 -- 리뷰 코멘트: 없음 +- 리뷰 코멘트: 기능 Task는 모두 충족되어 `[검토중]`으로 전환한다. 사용자 완료 확인과 archive 이동 승인을 받기 전까지 Milestone은 활성 경로에 유지한다. ## 범위 제외 @@ -114,5 +119,5 @@ Inventory에서 확인된 drift를 실제 기본값, 문서, smoke evidence로 - 관련 경로: `docker-compose.yml`, `.env.example`, `agent-test/local/`, `apps/client/`, `apps/control-plane/`, `apps/edge/`, `apps/node/`, `proto/` - 표준선(선택): container 내부 listen 포트는 runtime 기본값을 유지하고, 표준화 대상은 host publish, 문서화된 remote endpoint, smoke command다. - 선행 작업: workspace 공통 포트 inventory 정리 -- 후속 작업: compose/env/test smoke 갱신과 drift 방지 +- 후속 작업: 사용자 관리 `code-server` recreate 이후 standard-slot full runtime compose smoke 재실행, 사용자 완료 확인 후 Milestone archive 승인 처리 - 확인 필요: 없음 diff --git a/agent-task/archive/2026/06/m-openai-responses-input-surface/01_responses_api_surface/code_review_cloud_G06_0.log b/agent-task/archive/2026/06/m-openai-responses-input-surface/01_responses_api_surface/code_review_cloud_G06_0.log new file mode 100644 index 0000000..8b6afba --- /dev/null +++ b/agent-task/archive/2026/06/m-openai-responses-input-surface/01_responses_api_surface/code_review_cloud_G06_0.log @@ -0,0 +1,202 @@ + + +# 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 user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts 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 the needed 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-06-08 +task=m-openai-responses-input-surface/01_responses_api_surface, plan=0, tag=API + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/openai-responses-input-surface.md` +- Task ids: + - `responses-handler`: Edge OpenAI-compatible server가 `POST /v1/responses` non-streaming 요청을 받고 기존 service execution 경로로 변환한다. + - `metadata-contract`: request `metadata`가 `request_id`, `inference.target`, `nomadcode.task_id`, `nomadcode.source` 최소 계약으로 Edge service boundary까지 보존되고, `metadata.inference.target` target override 정책이 테스트로 고정된다. + - `response-shape`: Responses 응답이 `id`, `model`, `output_text` 또는 `output[].content[].text`, `usage`를 제공해 NomadCode Core parser와 호환된다. +- Completion mode: check-on-pass + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. +리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. + +1. 판정을 append한다. +2. `CODE_REVIEW-cloud-G06.md` → `code_review_cloud_G06_N.log`, `PLAN-cloud-G06.md` → `plan_cloud_G06_M.log`로 아카이브한다. +3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-openai-responses-input-surface/01_responses_api_surface/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다. +4. PASS이고 task group이 `m-`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [API-1] Responses Route And Handler | [x] | +| [API-2] Metadata Contract And Target Priority | [x] | +| [API-3] Responses Response Shape | [x] | + +## 구현 체크리스트 + +- [x] `/v1/responses` route와 non-streaming handler를 추가하고 `stream=true`, 잘못된 method, 빈 `input`을 명시적으로 거절한다. 검증: `go test -count=1 ./apps/edge/internal/openai`. +- [x] 최소 metadata 계약을 파싱해 dotted key flat metadata로 service boundary까지 전달하고 target priority를 고정한다. 검증: `go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/service`. +- [x] Responses-compatible response subset `id`, `model`, `output_text`, `output[].content[].text`, `usage`를 추가하고 OpenAI server tests가 이를 assert하게 한다. 검증: `go test -count=1 ./apps/edge/internal/openai`. +- [x] 전체 edge regression을 실행한다. 검증: `go test -count=1 ./apps/edge/...`. +- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 코드리뷰 전용 체크리스트 + +> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. +> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. + +- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G06_N.log`로 아카이브한다. +- [x] active `PLAN-*-G??.md`를 `plan_cloud_G06_M.log`로 아카이브한다. +- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다. +- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [x] PASS이면 active task 디렉터리 `agent-task/m-openai-responses-input-surface/01_responses_api_surface/`를 `agent-task/archive/YYYY/MM/m-openai-responses-input-surface/01_responses_api_surface/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [x] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-openai-responses-input-surface/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G06.md`와 `CODE_REVIEW-cloud-G06.md`를 작성하고 `complete.log`를 작성하지 않는다. +- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다. +- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다. + +## 계획 대비 변경 사항 + +없음. 계획과 동일하게 구현했다. + +## 주요 설계 결정 + +- `responsesRequest.Input`을 `json.RawMessage`로 받아 string 여부를 명시적으로 검증한다. non-string(array, object 등)은 400으로 거절한다. +- `responsesRequest.Metadata`도 `json.RawMessage`로 받아 두 단계로 파싱한다: 먼저 `map[string]json.RawMessage`로 unmarshal해 `cli` key 존재 여부를 확인하고, 이후 `responsesMetadata` struct로 unmarshal해 supported field만 추출한다. +- `responsesResponse.Usage`는 pointer가 아닌 value type(`openAIUsage`)으로 선언해 run result에 usage가 없어도 항상 zero-value object로 직렬화된다. +- `resolveTarget`은 `resolveTargetWithOverride(model, "")` 위임 호출로 변경했다. chat 경로의 동작은 동일하게 유지된다. +- `parseResponsesInput`, `buildResponsesPrompt`, `parseResponsesMetadata`는 `responses_handler.go`에 둔다. `metadataForRun()`만 `responsesMetadata` 메서드로 `types.go`에 둔다. + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- `/v1/responses`가 route-level로 등록되어 있고 method/stream/input boundary가 테스트된다. +- supported metadata 외 field를 공식 계약으로 확장하지 않았는지 확인한다. +- target priority가 `config.openai.target > metadata.inference.target > model`인지 확인한다. +- response `model`은 request model을 internal target보다 우선하는지 확인한다. +- `usage` field가 Responses response에 항상 object로 존재하는지 확인한다. + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +필수 규칙: +- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. +- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. +- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. +- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. +- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다. + +### API-1 중간 검증 +``` +$ go test -count=1 ./apps/edge/internal/openai +ok iop/apps/edge/internal/openai 1.507s +``` + +### API-2 중간 검증 +``` +$ go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/service +ok iop/apps/edge/internal/openai 1.507s +ok iop/apps/edge/internal/service 0.004s +``` + +### API-3 중간 검증 +``` +$ go test -count=1 ./apps/edge/internal/openai +ok iop/apps/edge/internal/openai 1.507s +``` + +### 최종 검증 +``` +$ go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/service +ok iop/apps/edge/internal/openai 1.507s +ok iop/apps/edge/internal/service 0.004s + +$ go test -count=1 ./apps/edge/... +ok iop/apps/edge/cmd/edge 0.035s +ok iop/apps/edge/internal/bootstrap 0.017s +ok iop/apps/edge/internal/controlplane 4.454s +ok iop/apps/edge/internal/edgecmd 0.010s +ok iop/apps/edge/internal/events 0.005s +ok iop/apps/edge/internal/input 0.016s +ok iop/apps/edge/internal/input/a2a 0.008s +ok iop/apps/edge/internal/node 0.009s +ok iop/apps/edge/internal/openai 1.508s +ok iop/apps/edge/internal/opsconsole 0.005s +ok iop/apps/edge/internal/service 0.007s +ok iop/apps/edge/internal/transport 2.015s +``` + +--- + +> **[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 Ownership + +| Section | Owner | Note | +|---------|-------|------| +| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these | +| Roadmap Targets | Fixed at stub creation from plan | Implementing agent must not modify; code-review copies it into `complete.log` as `Roadmap Completion` only on PASS | +| 구현 항목별 완료 여부 | Implementing agent | Check `[ ]` to `[x]` only | +| 구현 체크리스트 | Implementing agent | Check `[ ]` to `[x]` only; final checkbox is mandatory before saving | +| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section | +| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content | +| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless user input is required | +| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Pre-filled from plan | +| 검증 결과 | Implementing agent | Fill command output only | +| 코드리뷰 결과 | Review agent appends | Not included in stub | + +## 코드리뷰 결과 + +- 종합 판정: PASS +- 차원별 평가: + - correctness: Pass + - completeness: Pass + - test coverage: Pass + - API contract: Pass + - code quality: Pass + - plan deviation: Pass + - verification trust: Pass +- 발견된 문제: 없음 +- 다음 단계: PASS이므로 active PLAN/CODE_REVIEW를 로그로 아카이브하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다. + +### 리뷰 근거 + +- `/v1/responses` route, method/stream/input guard, metadata parser, target priority, response DTO가 계획 범위와 일치한다. +- `request_id`, `inference.target`, `nomadcode.task_id`, `nomadcode.source`는 service boundary의 flat metadata로 보존된다. +- Responses response는 `id`, `model`, `output_text`, nested output text, `usage` object를 제공한다. +- 재실행 검증: + - `go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/service` - PASS + - `go test -count=1 ./apps/edge/...` - PASS diff --git a/agent-task/archive/2026/06/m-openai-responses-input-surface/01_responses_api_surface/complete.log b/agent-task/archive/2026/06/m-openai-responses-input-surface/01_responses_api_surface/complete.log new file mode 100644 index 0000000..4edc62d --- /dev/null +++ b/agent-task/archive/2026/06/m-openai-responses-input-surface/01_responses_api_surface/complete.log @@ -0,0 +1,44 @@ +# Complete - m-openai-responses-input-surface/01_responses_api_surface + +## 완료 일시 + +2026-06-08 + +## 요약 + +OpenAI-compatible `/v1/responses` non-streaming API surface, metadata contract, response shape implementation was reviewed in loop 1 and closed with PASS. + +## 루프 이력 + +| Plan | Review | Verdict | 메모 | +|------|--------|---------|------| +| `plan_cloud_G06_0.log` | `code_review_cloud_G06_0.log` | PASS | `/v1/responses` route/handler, metadata boundary, response subset, and edge regression verification passed. | + +## 구현/정리 내용 + +- Added `/v1/responses` route and non-streaming handler that rejects unsupported methods, `stream=true`, missing/empty input, and non-string input. +- Added Responses metadata parsing for `request_id`, `inference.target`, `nomadcode.task_id`, and `nomadcode.source`, with target priority `config.openai.target > metadata.inference.target > model`. +- Added Responses-compatible response DTOs containing `id`, `model`, `output_text`, nested output text, and an always-present `usage` object. +- Added OpenAI server and service boundary regression tests for the new API surface. + +## 최종 검증 + +- `go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/service` - PASS; `ok` for `iop/apps/edge/internal/openai` and `iop/apps/edge/internal/service`. +- `go test -count=1 ./apps/edge/...` - PASS; all Edge packages returned `ok`. + +## Roadmap Completion + +- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/openai-responses-input-surface.md` +- Completed task ids: + - `responses-handler`: PASS; evidence=`plan_cloud_G06_0.log`, `code_review_cloud_G06_0.log`; verification=`go test -count=1 ./apps/edge/internal/openai`, `go test -count=1 ./apps/edge/...` + - `metadata-contract`: PASS; evidence=`plan_cloud_G06_0.log`, `code_review_cloud_G06_0.log`; verification=`go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/service`, `go test -count=1 ./apps/edge/...` + - `response-shape`: PASS; evidence=`plan_cloud_G06_0.log`, `code_review_cloud_G06_0.log`; verification=`go test -count=1 ./apps/edge/internal/openai`, `go test -count=1 ./apps/edge/...` +- Not completed task ids: 없음 + +## 잔여 Nit + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/archive/2026/06/m-openai-responses-input-surface/01_responses_api_surface/plan_cloud_G06_0.log b/agent-task/archive/2026/06/m-openai-responses-input-surface/01_responses_api_surface/plan_cloud_G06_0.log new file mode 100644 index 0000000..43787e0 --- /dev/null +++ b/agent-task/archive/2026/06/m-openai-responses-input-surface/01_responses_api_surface/plan_cloud_G06_0.log @@ -0,0 +1,329 @@ + + +# Plan - API + +## 이 파일을 읽는 구현 에이전트에게 + +`CODE_REVIEW-cloud-G06.md`의 구현 에이전트 소유 섹션을 채우는 것이 구현의 마지막 단계다. 구현 후 검증 명령의 실제 stdout/stderr를 붙이고 active 파일은 그대로 둔 채 리뷰 준비를 보고한다. 사용자 전용 결정, 외부 환경 준비, scope 충돌로 막히면 review stub의 `사용자 리뷰 요청` 섹션에 근거를 기록하고 중단한다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 닫을 수 있는 증거 공백은 사용자 리뷰 요청이 아니다. + +## 배경 + +현 Milestone의 첫 Epic은 Edge OpenAI-compatible 입력 표면을 `/v1/responses`까지 넓히는 작업이다. 현재 서버는 `/v1/chat/completions`와 `/v1/models`만 등록되어 있어 NomadCode Core가 Responses request shape로 실행을 요청할 수 없다. 이 계획은 non-streaming Responses 요청, 최소 metadata 계약, NomadCode-compatible response subset을 한 번에 고정한다. + +## 사용자 리뷰 요청 흐름 + +구현 중 차단은 active `CODE_REVIEW-cloud-G06.md`의 `사용자 리뷰 요청` 섹션에 기록한다. 직접 사용자 프롬프트는 금지되며, code-review가 요청 타당성을 검증하고 필요할 때만 `USER_REVIEW.md`를 작성한다. + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/openai-responses-input-surface.md` +- Task ids: + - `responses-handler`: Edge OpenAI-compatible server가 `POST /v1/responses` non-streaming 요청을 받고 기존 service execution 경로로 변환한다. + - `metadata-contract`: request `metadata`가 `request_id`, `inference.target`, `nomadcode.task_id`, `nomadcode.source` 최소 계약으로 Edge service boundary까지 보존되고, `metadata.inference.target` target override 정책이 테스트로 고정된다. + - `response-shape`: Responses 응답이 `id`, `model`, `output_text` 또는 `output[].content[].text`, `usage`를 제공해 NomadCode Core parser와 호환된다. +- Completion mode: check-on-pass + +## 분석 결과 + +### 읽은 파일 + +- `AGENTS.md` +- `agent-ops/rules/project/rules.md` +- `agent-ops/rules/private/rules.md` +- `agent-ops/rules/common/rules-roadmap.md` +- `agent-ops/rules/project/domain/edge/rules.md` +- `agent-ops/rules/project/domain/testing/rules.md` +- `agent-ops/skills/common/router.md` +- `agent-ops/skills/common/plan/SKILL.md` +- `agent-ops/skills/common/update-roadmap/SKILL.md` +- `agent-roadmap/current.md` +- `agent-roadmap/phase/automation-runtime-bridge/PHASE.md` +- `agent-roadmap/phase/automation-runtime-bridge/milestones/openai-responses-input-surface.md` +- `agent-test/local/rules.md` +- `agent-test/local/edge-smoke.md` +- `apps/edge/internal/openai/server.go` +- `apps/edge/internal/openai/routes.go` +- `apps/edge/internal/openai/chat_handler.go` +- `apps/edge/internal/openai/types.go` +- `apps/edge/internal/openai/run_result.go` +- `apps/edge/internal/openai/stream.go` +- `apps/edge/internal/openai/server_test.go` +- `apps/edge/internal/service/run_dispatch.go` +- `apps/edge/internal/service/service.go` +- `apps/edge/internal/service/service_test.go` +- `go.mod` + +### 테스트 환경 규칙 + +- `test_env=local`. +- `agent-test/local/rules.md`는 존재하며 읽었다. +- 적용 profile은 `agent-test/local/edge-smoke.md`다. `apps/edge/**`, OpenAI-compatible 입력 표면 변경에 해당한다. +- profile 명령: 변경한 edge 패키지 또는 `go test ./apps/edge/...`; OpenAI-compatible 경계는 `iop-edge smoke openai` 또는 동등한 `/healthz`, `/v1/models`, `/v1/chat/completions` 확인. +- 이 plan의 최종 검증은 신선한 실행이 필요하므로 `-count=1`을 사용한다. 현재 문서의 OpenAI-compatible smoke 기준은 chat completions라서 `/v1/responses` 실제 listener smoke 확장은 후속 `02+01_responses_smoke` plan에서 처리한다. +- 구조적 blank, `<확인 필요>` 값, missing profile은 없다. + +### 테스트 커버리지 공백 + +- `/v1/responses` route/handler: 기존 테스트 없음. +- string `input`, optional `instructions`, `stream=false` request parsing: 기존 테스트 없음. +- `metadata.request_id`, `metadata.inference.target`, `metadata.nomadcode.task_id`, `metadata.nomadcode.source` 보존: `BuildRunRequestCopiesMetadata`가 generic copy만 확인하고 Responses 계약은 없음. +- target priority `config.openai.target > metadata.inference.target > model`: 기존 chat target test는 request model/config target만 확인한다. +- Responses response subset `id`, `model`, `output_text`, `output[].content[].text`, `usage`: 기존 chat response test만 있음. + +### 심볼 참조 + +- renamed/removed symbol: none. +- 새 handler/type/helper를 추가한다. 기존 `resolveTarget`, `chatCompletionRequest`, `chatCompletionResponse`, `collectRunResult`, `SubmitRunRequest.Metadata` 참조는 `rg --sort path`로 확인했다. + +### 분할 판단 + +- split decision policy를 plan 파일 선택 전에 평가했다. +- shared task group: `agent-task/m-openai-responses-input-surface/` +- `01_responses_api_surface`: `/v1/responses` API, metadata contract, response shape 구현. 선행 없음. +- `02+01_responses_smoke`: smoke CLI와 edge-smoke profile 갱신. predecessor `01` 완료 후 실행한다. +- 분할 이유: public API 계약 구현과 실제 listener smoke/CLI 출력 계약은 검증 방식과 실패 원인이 다르다. API surface PASS가 먼저 완성되어야 smoke가 안정적으로 구현된다. + +### 범위 결정 근거 + +- streaming Responses API는 Milestone 범위 제외라 구현하지 않는다. `stream=true`는 `400 invalid_request_error`로 거절한다. +- OpenAI Responses 전체 schema, tool call, multimodal input, background mode는 제외한다. +- `metadata.cli`, `metadata.route`, `metadata.routing`, `metadata.execution`, `metadata.policy`, `metadata.requester`는 공식 지원하지 않는다. +- `apps/edge/internal/edgecmd/smoke_openai.go`, `apps/edge/cmd/edge/main_test.go`, `agent-test/local/edge-smoke.md`는 후속 smoke plan에서 다룬다. +- proto schema 변경은 하지 않는다. service boundary는 현재 `SubmitRunRequest.Metadata map[string]string`와 `RunRequest.Metadata` flat map으로 고정한다. + +### 빌드 등급 + +- build lane: `cloud-G06`; review lane: `cloud-G06`. +- 근거: public API/protocol shape와 metadata target override가 포함되지만 파일 범위와 검증은 Go unit test로 제한 가능하다. + +## 구현 체크리스트 + +- [ ] `/v1/responses` route와 non-streaming handler를 추가하고 `stream=true`, 잘못된 method, 빈 `input`을 명시적으로 거절한다. 검증: `go test -count=1 ./apps/edge/internal/openai`. +- [ ] 최소 metadata 계약을 파싱해 dotted key flat metadata로 service boundary까지 전달하고 target priority를 고정한다. 검증: `go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/service`. +- [ ] Responses-compatible response subset `id`, `model`, `output_text`, `output[].content[].text`, `usage`를 추가하고 OpenAI server tests가 이를 assert하게 한다. 검증: `go test -count=1 ./apps/edge/internal/openai`. +- [ ] 전체 edge regression을 실행한다. 검증: `go test -count=1 ./apps/edge/...`. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 의존 관계 및 구현 순서 + +1. API handler와 type을 먼저 추가한다. +2. metadata parser와 target helper를 붙인 뒤 service metadata test를 보강한다. +3. response shape test를 통과시킨다. +4. 후속 `02+01_responses_smoke`는 이 task directory의 `complete.log`가 생긴 뒤 시작한다. + +### [API-1] Responses Route And Handler + +#### 문제 + +`apps/edge/internal/openai/routes.go`는 `/v1/responses`를 등록하지 않는다. + +```go +// apps/edge/internal/openai/routes.go:11 +mux.HandleFunc("/healthz", s.handleHealthz) +mux.HandleFunc("/v1/models", s.handleModels) +mux.HandleFunc("/v1/chat/completions", s.handleChatCompletions) +mux.HandleFunc("/api/", s.handleOllamaAPI) +``` + +`apps/edge/internal/openai/chat_handler.go`의 handler는 chat completions 전용 request shape만 처리한다. + +```go +// apps/edge/internal/openai/chat_handler.go:17 +func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { +``` + +#### 해결 방법 + +- `routes.go`에 `mux.HandleFunc("/v1/responses", s.handleResponses)`를 추가한다. +- 새 파일 `apps/edge/internal/openai/responses_handler.go`를 만든다. +- 새 handler는 `POST`만 허용하고 `stream=true`를 거절한다. +- request `input`은 현재 Milestone에서 string만 지원한다. `instructions`가 있으면 prompt 앞에 붙이고, trim 후 빈 prompt면 400을 반환한다. +- 새 파일 imports: + +```go +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "go.uber.org/zap" + + edgeservice "iop/apps/edge/internal/service" +) +``` + +#### 수정 파일 및 체크리스트 + +- [ ] `apps/edge/internal/openai/routes.go`: `/v1/responses` route 추가. +- [ ] `apps/edge/internal/openai/responses_handler.go`: `handleResponses`, prompt builder, dispatch 호출 추가. +- [ ] `apps/edge/internal/openai/types.go`: Responses request type 추가. +- [ ] `apps/edge/internal/openai/server_test.go`: route-level 정상/400 테스트 추가. + +#### 테스트 작성 + +- `TestResponsesDispatchesNonStreamingRequest`: `srv.routes().ServeHTTP`로 `/v1/responses`를 호출하고 fake service dispatch, prompt, status 200을 검증한다. +- `TestResponsesRejectsUnsupportedRequests`: method, `stream=true`, 빈 `input`, non-string `input`을 400/405로 검증한다. + +#### 중간 검증 + +```bash +go test -count=1 ./apps/edge/internal/openai +``` + +Expected: `ok iop/apps/edge/internal/openai`. + +### [API-2] Metadata Contract And Target Priority + +#### 문제 + +현재 target resolution은 config target과 request model만 본다. + +```go +// apps/edge/internal/openai/chat_handler.go:128 +func (s *Server) resolveTarget(model string) string { + if s.cfg.Target != "" { + return s.cfg.Target + } + return strings.TrimSpace(model) +} +``` + +service boundary는 flat metadata map을 이미 지원하지만 Responses metadata 계약은 테스트로 고정되어 있지 않다. + +```go +// apps/edge/internal/service/run_dispatch.go:252 +metadata := make(map[string]string, len(req.Metadata)) +for k, v := range req.Metadata { + metadata[k] = v +} +``` + +#### 해결 방법 + +- `types.go`에 `responsesMetadata`, `responsesInferenceMetadata`, `responsesNomadCodeMetadata`를 추가한다. +- `metadata`는 JSON object만 허용한다. object가 아니면 400. +- 지원 field만 flat metadata key로 전달한다: `request_id`, `inference.target`, `nomadcode.task_id`, `nomadcode.source`. +- `metadata.cli`가 있거나 `metadata.inference`와 `metadata.cli`가 동시에 있으면 400. +- `func (s *Server) resolveTargetWithOverride(model, override string) string`를 추가하고 `resolveTarget(model)`은 기존 chat path 호환을 위해 override empty 호출로 유지한다. +- Responses internal target priority는 `s.cfg.Target` > `metadata.inference.target` > `req.Model`이다. +- response model은 `responseModel(req.Model, handle.Dispatch().Target)`을 사용해 request model을 우선한다. + +#### 수정 파일 및 체크리스트 + +- [ ] `apps/edge/internal/openai/types.go`: metadata structs와 `metadataForRun()` helper 추가. +- [ ] `apps/edge/internal/openai/chat_handler.go`: `resolveTargetWithOverride` 추가, 기존 `resolveTarget` 동작 유지. +- [ ] `apps/edge/internal/openai/responses_handler.go`: metadata validation, target override, `SubmitRunRequest.Metadata` 전달. +- [ ] `apps/edge/internal/openai/server_test.go`: metadata flat key와 target priority 테스트 추가. +- [ ] `apps/edge/internal/service/service_test.go`: dotted metadata key 보존 regression 추가. + +#### 테스트 작성 + +- `TestResponsesMetadataContractAndTargetOverride`: `metadata.inference.target`이 dispatch target으로 쓰이고 네 개 supported metadata key가 fake service에 전달되는지 검증한다. +- `TestResponsesConfiguredTargetWinsOverMetadataTarget`: config target이 metadata override보다 우선하고 response model은 request model을 유지하는지 검증한다. +- `TestResponsesRejectsCLIMetadata`: `metadata.cli`와 `metadata.inference + metadata.cli`를 400으로 검증한다. +- `TestBuildRunRequestPreservesResponsesMetadataKeys`: service boundary가 dotted keys를 그대로 복사하는지 검증한다. + +#### 중간 검증 + +```bash +go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/service +``` + +Expected: both packages `ok`. + +### [API-3] Responses Response Shape + +#### 문제 + +현재 response type은 chat completions shape만 있다. + +```go +// apps/edge/internal/openai/types.go:141 +type chatCompletionResponse struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + Choices []chatCompletionChoice `json:"choices"` + Usage *openAIUsage `json:"usage,omitempty"` +} +``` + +run result 수집은 공용으로 쓸 수 있지만 Responses shape로 변환하는 코드가 없다. + +```go +// apps/edge/internal/openai/run_result.go:12 +func collectRunResult(ctx context.Context, stream edgeservice.RunStream, timeout time.Duration) (string, string, *openAIUsage, error) { +``` + +#### 해결 방법 + +- `types.go`에 Responses response DTO를 추가한다. +- response는 최소 아래 field를 제공한다. + +```json +{ + "id": "resp-run-test", + "object": "response", + "created_at": 1234567890, + "model": "client-model", + "output_text": "answer", + "output": [ + { + "type": "message", + "role": "assistant", + "content": [ + {"type": "output_text", "text": "answer"} + ] + } + ], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2} +} +``` + +- `usage`는 node usage가 없으면 zero-value object로 내려 응답 subset field 존재를 보장한다. +- reasoning text는 Responses subset에 넣지 않는다. + +#### 수정 파일 및 체크리스트 + +- [ ] `apps/edge/internal/openai/types.go`: `responsesResponse`, output item/content DTO 추가. +- [ ] `apps/edge/internal/openai/responses_handler.go`: `completeResponse` 또는 동등 helper 추가. +- [ ] `apps/edge/internal/openai/server_test.go`: `output_text`, nested output text, usage object assert 추가. + +#### 테스트 작성 + +- `TestResponsesReturnsNomadCodeCompatibleShape`: fake run event의 delta/usage를 response JSON에서 `id`, `model`, `output_text`, `output[0].content[0].text`, `usage.total_tokens`로 검증한다. +- `TestResponsesUsageDefaultsToZeroObject`: usage event가 없어도 `usage` field가 object로 존재하는지 검증한다. + +#### 중간 검증 + +```bash +go test -count=1 ./apps/edge/internal/openai +``` + +Expected: `ok iop/apps/edge/internal/openai`. + +## 수정 파일 요약 + +| 파일 | 항목 | +|------|------| +| `apps/edge/internal/openai/routes.go` | API-1 | +| `apps/edge/internal/openai/responses_handler.go` | API-1, API-2, API-3 | +| `apps/edge/internal/openai/types.go` | API-1, API-2, API-3 | +| `apps/edge/internal/openai/chat_handler.go` | API-2 | +| `apps/edge/internal/openai/server_test.go` | API-1, API-2, API-3 | +| `apps/edge/internal/service/service_test.go` | API-2 | + +## 최종 검증 + +```bash +go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/service +go test -count=1 ./apps/edge/... +``` + +Expected: all listed packages return `ok`. Test cache output is not acceptable because new API behavior must be freshly exercised. + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/m-openai-responses-input-surface/02+01_responses_smoke/CODE_REVIEW-cloud-G06.md b/agent-task/m-openai-responses-input-surface/02+01_responses_smoke/CODE_REVIEW-cloud-G06.md new file mode 100644 index 0000000..24326c5 --- /dev/null +++ b/agent-task/m-openai-responses-input-surface/02+01_responses_smoke/CODE_REVIEW-cloud-G06.md @@ -0,0 +1,166 @@ + + +# Code Review Reference - TEST + +> **[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 user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts 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 the needed 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-06-08 +task=m-openai-responses-input-surface/02+01_responses_smoke, plan=0, tag=TEST + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/openai-responses-input-surface.md` +- Task ids: + - `responses-smoke`: `iop-edge smoke openai` 또는 동등 smoke가 `/healthz`, `/v1/models`, `POST /v1/responses`를 확인한다. +- Completion mode: check-on-pass + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. +리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. + +1. 판정을 append한다. +2. `CODE_REVIEW-cloud-G06.md` → `code_review_cloud_G06_N.log`, `PLAN-cloud-G07.md` → `plan_cloud_G07_M.log`로 아카이브한다. +3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-openai-responses-input-surface/02+01_responses_smoke/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다. +4. PASS이고 task group이 `m-`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [TEST-1] Smoke Command Uses Responses | [ ] | +| [TEST-2] Smoke Contract Documentation Tracks Responses | [ ] | +| [TEST-3] Actual Listener Smoke Evidence | [ ] | + +## 구현 체크리스트 + +- [ ] `iop-edge smoke openai`의 inference check를 `POST /v1/responses` 기준으로 갱신하고 response text extractor를 추가한다. 검증: `go test -count=1 ./apps/edge/cmd/edge ./apps/edge/internal/edgecmd`. +- [ ] smoke command 성공/실패 tests를 Responses shape와 error message 기준으로 갱신한다. 검증: `go test -count=1 ./apps/edge/cmd/edge`. +- [ ] `agent-test/local/edge-smoke.md`의 OpenAI-compatible smoke 기준을 `/healthz`, `/v1/models`, `/v1/responses`로 갱신한다. +- [ ] 전체 edge regression과 실제 listener smoke를 실행한다. 검증: `go test -count=1 ./apps/edge/...` 및 `go run ./apps/edge/cmd/edge smoke openai --model test-model --base-url http://127.0.0.1:18081 --prompt ping --timeout 15s`. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 코드리뷰 전용 체크리스트 + +> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. +> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. + +- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G06_N.log`로 아카이브한다. +- [ ] active `PLAN-*-G??.md`를 `plan_cloud_G07_M.log`로 아카이브한다. +- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다. +- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [ ] PASS이면 active task 디렉터리 `agent-task/m-openai-responses-input-surface/02+01_responses_smoke/`를 `agent-task/archive/YYYY/MM/m-openai-responses-input-surface/02+01_responses_smoke/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [ ] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-openai-responses-input-surface/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G06.md`를 작성하고 `complete.log`를 작성하지 않는다. +- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다. +- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다. + +## 계획 대비 변경 사항 + +_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._ + +## 주요 설계 결정 + +_구현 에이전트가 주요 설계 결정 사항을 기록한다._ + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- 02번 구현이 01번 complete.log 없이 시작되지 않았는지 확인한다. +- smoke Step 3가 `/v1/responses`를 호출하고 stdout도 Responses endpoint를 표시하는지 확인한다. +- response text extractor가 `output_text`와 nested `output[].content[].text`를 모두 처리하는지 확인한다. +- `agent-test/local/edge-smoke.md`의 OpenAI-compatible smoke 기준이 구현과 일치하는지 확인한다. +- 실제 listener smoke가 불가능했다면 user-owned blocker인지, unit evidence로 PASS 처리하려는 것은 아닌지 확인한다. + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +필수 규칙: +- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. +- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. +- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. +- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. +- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다. + +### TEST-1 중간 검증 +``` +$ go test -count=1 ./apps/edge/cmd/edge ./apps/edge/internal/edgecmd +(output) +``` + +### TEST-2 중간 검증 +``` +$ rg --sort path -n "/v1/chat/completions|/v1/responses" agent-test/local/edge-smoke.md apps/edge/internal/edgecmd/smoke_openai.go apps/edge/cmd/edge/main_test.go +(output) +``` + +### TEST-3 중간 검증 +``` +$ go run ./apps/edge/cmd/edge smoke openai --model test-model --base-url http://127.0.0.1:18081 --prompt ping --timeout 15s +(output) +``` + +### 최종 검증 +``` +$ go test -count=1 ./apps/edge/cmd/edge ./apps/edge/internal/edgecmd +(output) + +$ go test -count=1 ./apps/edge/... +(output) + +$ rg --sort path -n "/v1/chat/completions|/v1/responses" agent-test/local/edge-smoke.md apps/edge/internal/edgecmd/smoke_openai.go apps/edge/cmd/edge/main_test.go +(output) + +$ go run ./apps/edge/cmd/edge smoke openai --model test-model --base-url http://127.0.0.1:18081 --prompt ping --timeout 15s +(output) +``` + +--- + +> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?** +> If anything is blank, go back and fill it in before saving this file. +> Leave review-agent-only sections unchanged. + +## Sections and Ownership + +| Section | Owner | Note | +|---------|-------|------| +| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these | +| Roadmap Targets | Fixed at stub creation from plan | Implementing agent must not modify; code-review copies it into `complete.log` as `Roadmap Completion` only on PASS | +| 구현 항목별 완료 여부 | Implementing agent | Check `[ ]` to `[x]` only | +| 구현 체크리스트 | Implementing agent | Check `[ ]` to `[x]` only; final checkbox is mandatory before saving | +| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section | +| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content | +| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless user input is required | +| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Pre-filled from plan | +| 검증 결과 | Implementing agent | Fill command output only | +| 코드리뷰 결과 | Review agent appends | Not included in stub | diff --git a/agent-task/m-openai-responses-input-surface/02+01_responses_smoke/PLAN-cloud-G07.md b/agent-task/m-openai-responses-input-surface/02+01_responses_smoke/PLAN-cloud-G07.md new file mode 100644 index 0000000..8553444 --- /dev/null +++ b/agent-task/m-openai-responses-input-surface/02+01_responses_smoke/PLAN-cloud-G07.md @@ -0,0 +1,240 @@ + + +# Plan - TEST + +## 이 파일을 읽는 구현 에이전트에게 + +`CODE_REVIEW-cloud-G06.md`의 구현 에이전트 소유 섹션을 채우는 것이 구현의 마지막 단계다. 구현 후 검증 명령의 실제 stdout/stderr를 붙이고 active 파일은 그대로 둔 채 리뷰 준비를 보고한다. 사용자 전용 결정, 외부 환경 준비, scope 충돌로 막히면 review stub의 `사용자 리뷰 요청` 섹션에 근거를 기록하고 중단한다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 닫을 수 있는 증거 공백은 사용자 리뷰 요청이 아니다. + +## 배경 + +Responses API 구현만으로는 Milestone smoke 기준이 닫히지 않는다. 현재 `iop-edge smoke openai`는 `/v1/chat/completions`만 확인하므로 `/v1/responses` listener path가 실제 smoke 흐름에서 검증되지 않는다. 이 작업은 01번 API surface 완료 후 CLI smoke와 local test profile을 Responses 기준으로 갱신한다. + +## 사용자 리뷰 요청 흐름 + +구현 중 차단은 active `CODE_REVIEW-cloud-G06.md`의 `사용자 리뷰 요청` 섹션에 기록한다. 직접 사용자 프롬프트는 금지되며, code-review가 요청 타당성을 검증하고 필요할 때만 `USER_REVIEW.md`를 작성한다. + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/openai-responses-input-surface.md` +- Task ids: + - `responses-smoke`: `iop-edge smoke openai` 또는 동등 smoke가 `/healthz`, `/v1/models`, `POST /v1/responses`를 확인한다. +- Completion mode: check-on-pass + +## 분석 결과 + +### 읽은 파일 + +- `AGENTS.md` +- `agent-ops/rules/project/rules.md` +- `agent-ops/rules/private/rules.md` +- `agent-ops/rules/common/rules-roadmap.md` +- `agent-ops/rules/project/domain/edge/rules.md` +- `agent-ops/rules/project/domain/testing/rules.md` +- `agent-ops/skills/common/router.md` +- `agent-ops/skills/common/plan/SKILL.md` +- `agent-roadmap/current.md` +- `agent-roadmap/phase/automation-runtime-bridge/PHASE.md` +- `agent-roadmap/phase/automation-runtime-bridge/milestones/openai-responses-input-surface.md` +- `agent-test/local/rules.md` +- `agent-test/local/edge-smoke.md` +- `apps/edge/internal/edgecmd/smoke_openai.go` +- `apps/edge/internal/edgecmd/root.go` +- `apps/edge/internal/edgecmd/edgecmd_test.go` +- `apps/edge/cmd/edge/main.go` +- `apps/edge/cmd/edge/main_test.go` +- `apps/edge/internal/openai/types.go` +- `go.mod` + +### 테스트 환경 규칙 + +- `test_env=local`. +- `agent-test/local/rules.md`는 존재하며 읽었다. +- 적용 profile은 `agent-test/local/edge-smoke.md`다. +- profile 명령: 변경한 edge 패키지 또는 `go test ./apps/edge/...`; OpenAI-compatible 경계는 `iop-edge smoke openai` 또는 동등한 `/healthz`, `/v1/models`, `/v1/chat/completions` 확인. +- 이 plan은 profile 자체의 OpenAI-compatible smoke endpoint 기준을 `/v1/responses`로 갱신한다. +- 실제 listener smoke 명령은 user/runner가 Edge server와 node adapter를 준비해야 통과한다. 준비되지 않으면 구현 에이전트는 review stub의 `사용자 리뷰 요청`에 실행 명령, 실패 근거, 재개 조건을 남긴다. +- 구조적 blank, `<확인 필요>` 값, missing profile은 없다. + +### 테스트 커버리지 공백 + +- `TestSmokeOpenAICommandSuccess`는 현재 `/v1/chat/completions`만 mock/assert한다. +- `TestSmokeOpenAICommandFailure`는 chat completions non-200/empty content만 실패 path로 본다. +- `smoke_openai.go`는 Responses response shape에서 text를 추출하는 parser가 없다. +- `agent-test/local/edge-smoke.md` 판정 기준은 아직 `/v1/chat/completions`를 OpenAI-compatible smoke 기준으로 적고 있다. + +### 심볼 참조 + +- renamed/removed symbol: none. +- `smokeOpenAICmd`, `chatCompletionResponse`, `/v1/chat/completions` 참조는 `rg --sort path`로 확인했다. + +### 분할 판단 + +- split decision policy를 plan 파일 선택 전에 평가했다. +- shared task group: `agent-task/m-openai-responses-input-surface/` +- sibling `01_responses_api_surface`: `/v1/responses` API 구현. predecessor 없음. +- this subtask `02+01_responses_smoke`: predecessor index `01`. +- predecessor state: active `agent-task/m-openai-responses-input-surface/01_responses_api_surface/complete.log` 없음. archive 후보는 현재 새 task group이므로 없음. 구현은 01번 PASS archive 또는 active complete.log 확인 뒤 시작한다. + +### 범위 결정 근거 + +- `/v1/responses` API 구현 자체는 01번 plan 범위다. +- chat completions endpoint 제거는 하지 않는다. 이 작업은 `smoke openai`의 inference check를 Responses로 전환하는 데 집중한다. +- Docker compose, code-server recreate, toki-labs.com port open 작업은 제외한다. 실제 listener가 필요하면 user-owned environment blocker로 기록한다. +- NomadCode Core 통합 smoke는 `../nomadcode` External Integration Milestone 범위라 제외한다. + +### 빌드 등급 + +- build lane: `cloud-G07`; review lane: `cloud-G06`. +- 근거: CLI smoke command, HTTP response parsing, stdout/error contract가 중심이고 실제 listener 검증은 외부 runtime 준비 상태에 영향을 받는다. + +## 구현 체크리스트 + +- [ ] `iop-edge smoke openai`의 inference check를 `POST /v1/responses` 기준으로 갱신하고 response text extractor를 추가한다. 검증: `go test -count=1 ./apps/edge/cmd/edge ./apps/edge/internal/edgecmd`. +- [ ] smoke command 성공/실패 tests를 Responses shape와 error message 기준으로 갱신한다. 검증: `go test -count=1 ./apps/edge/cmd/edge`. +- [ ] `agent-test/local/edge-smoke.md`의 OpenAI-compatible smoke 기준을 `/healthz`, `/v1/models`, `/v1/responses`로 갱신한다. +- [ ] 전체 edge regression과 실제 listener smoke를 실행한다. 검증: `go test -count=1 ./apps/edge/...` 및 `go run ./apps/edge/cmd/edge smoke openai --model test-model --base-url http://127.0.0.1:18081 --prompt ping --timeout 15s`. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 의존 관계 및 구현 순서 + +`02+01_responses_smoke`는 directory name 기준으로 predecessor `01`에 의존한다. 구현 시작 전 `agent-task/m-openai-responses-input-surface/01_responses_api_surface/complete.log` 또는 matching archive complete.log를 확인한다. 01이 완료되지 않았으면 이 plan은 대기한다. + +### [TEST-1] Smoke Command Uses Responses + +#### 문제 + +`smoke_openai.go` help text와 Step 3은 chat completions를 기준으로 한다. + +```go +// apps/edge/internal/edgecmd/smoke_openai.go:105 +Long: `Validate the IOP Edge OpenAI-compatible HTTP API surface. +It runs a three-step diagnostics check: + 1. Checks GET /healthz (server status). + 2. Checks GET /v1/models (advertised model compatibility). + 3. Checks POST /v1/chat/completions (inference request processing). +``` + +```go +// apps/edge/internal/edgecmd/smoke_openai.go:190 +fmt.Fprintf(cmd.OutOrStdout(), "Step 3: Checking /v1/chat/completions ... ") +``` + +#### 해결 방법 + +- Step 3 endpoint를 `/v1/responses`로 바꾼다. +- request payload는 최소 shape로 보낸다: `model`, `input`, `stream:false`. +- response parser는 `output_text`를 우선 읽고, 없으면 `output[].content[].text`에서 첫 non-empty text를 읽는다. +- empty response text는 `POST ... returned empty response text`로 실패 처리한다. +- 기존 model warning과 `/healthz`, `/v1/models` 검증은 유지한다. + +#### 수정 파일 및 체크리스트 + +- [ ] `apps/edge/internal/edgecmd/smoke_openai.go`: help text, Step 3 label, payload, response DTO, text extractor 갱신. +- [ ] `apps/edge/cmd/edge/main_test.go`: success/failure expectations 갱신. + +#### 테스트 작성 + +- `TestSmokeOpenAICommandSuccess`: mock server가 `/v1/responses`를 받고 `output_text` 또는 nested output content를 반환하도록 변경한다. +- `TestSmokeOpenAICommandFailure`: `/v1/responses` non-200와 empty response text 실패를 검증한다. + +#### 중간 검증 + +```bash +go test -count=1 ./apps/edge/cmd/edge ./apps/edge/internal/edgecmd +``` + +Expected: both packages `ok`. + +### [TEST-2] Smoke Contract Documentation Tracks Responses + +#### 문제 + +`agent-test/local/edge-smoke.md`의 필수 검증과 판정 기준은 아직 chat completions를 OpenAI-compatible smoke 기준으로 둔다. + +```md + +- OpenAI-compatible 경계를 바꾼 경우 `iop-edge smoke openai` 또는 동등한 `/healthz`, `/v1/models`, `/v1/chat/completions` 확인으로 edge service와 node adapter 경로 수렴을 확인한다. +``` + +```md + +- OpenAI-compatible smoke에서 `/healthz`, `/v1/models`, `/v1/chat/completions`가 기대 상태로 응답한다. +``` + +#### 해결 방법 + +- OpenAI-compatible smoke 기준을 `/healthz`, `/v1/models`, `/v1/responses`로 변경한다. +- chat completions baseline은 기존 unit tests가 유지한다는 범위 설명을 덧붙인다. + +#### 수정 파일 및 체크리스트 + +- [ ] `agent-test/local/edge-smoke.md`: 필수 검증과 판정 기준의 endpoint를 Responses 기준으로 갱신. + +#### 테스트 작성 + +- 문서/규칙 변경이라 별도 test file은 없다. `rg --sort path -n "/v1/chat/completions|/v1/responses" agent-test/local/edge-smoke.md apps/edge/internal/edgecmd/smoke_openai.go apps/edge/cmd/edge/main_test.go`로 기준 drift를 확인한다. + +#### 중간 검증 + +```bash +rg --sort path -n "/v1/chat/completions|/v1/responses" agent-test/local/edge-smoke.md apps/edge/internal/edgecmd/smoke_openai.go apps/edge/cmd/edge/main_test.go +``` + +Expected: smoke command/test/profile의 inference smoke 기준이 `/v1/responses`를 가리키며, chat completions 참조가 남아 있다면 legacy unit test나 기존 API 문맥으로만 남아야 한다. + +### [TEST-3] Actual Listener Smoke Evidence + +#### 문제 + +Milestone task는 remote/local edge smoke에서 non-streaming Responses path가 실제 listener에 대해 PASS해야 한다. unit `httptest`만으로는 실제 listener와 node adapter 경로 수렴을 증명하지 못한다. + +```go +// apps/edge/cmd/edge/main_test.go:1043 +func TestSmokeOpenAICommandSuccess(t *testing.T) { + // Start an httptest server to mock the OpenAI-compatible endpoint +``` + +#### 해결 방법 + +- 구현 후 local 또는 remote runner에서 실제 Edge OpenAI-compatible listener를 준비하고 smoke command를 실행한다. +- 현재 agent가 listener/node 준비 권한이나 port open 권한이 없으면 `사용자 리뷰 요청`에 blocker를 남긴다. +- user가 code-server/Edge runtime을 준비한 뒤 같은 plan을 재개할 수 있도록 exact command와 실패 근거를 기록한다. + +#### 수정 파일 및 체크리스트 + +- [ ] `CODE_REVIEW-cloud-G06.md`: 실제 listener smoke 결과 또는 user-owned blocker 기록. + +#### 테스트 작성 + +- 별도 test file은 없다. 실제 listener command output이 evidence다. + +#### 중간 검증 + +```bash +go run ./apps/edge/cmd/edge smoke openai --model test-model --base-url http://127.0.0.1:18081 --prompt ping --timeout 15s +``` + +Expected: command exits 0 and output includes `Step 3: Checking /v1/responses ... [OK]`. + +## 수정 파일 요약 + +| 파일 | 항목 | +|------|------| +| `apps/edge/internal/edgecmd/smoke_openai.go` | TEST-1 | +| `apps/edge/cmd/edge/main_test.go` | TEST-1 | +| `agent-test/local/edge-smoke.md` | TEST-2 | +| `CODE_REVIEW-cloud-G06.md` | TEST-3 | + +## 최종 검증 + +```bash +go test -count=1 ./apps/edge/cmd/edge ./apps/edge/internal/edgecmd +go test -count=1 ./apps/edge/... +rg --sort path -n "/v1/chat/completions|/v1/responses" agent-test/local/edge-smoke.md apps/edge/internal/edgecmd/smoke_openai.go apps/edge/cmd/edge/main_test.go +go run ./apps/edge/cmd/edge smoke openai --model test-model --base-url http://127.0.0.1:18081 --prompt ping --timeout 15s +``` + +Expected: Go tests return `ok`; rg output shows `/v1/responses` as smoke inference target; actual listener smoke exits 0 with `Step 3: Checking /v1/responses ... [OK]`. Test cache output is not acceptable. + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/apps/edge/README.md b/apps/edge/README.md index 68fb8b5..63f3814 100644 --- a/apps/edge/README.md +++ b/apps/edge/README.md @@ -2,7 +2,7 @@ 여러 Node를 하나의 로컬 실행 그룹으로 묶고, IOP 내부 TCP/protobuf 프로토콜을 통해 adapter execution 요청과 이벤트 스트림을 중계한다. -Edge는 Node registry, node configuration, runtime routing, stream relay를 담당하는 백엔드 실행 그룹 컨트롤러다. 현재는 진단용 ops console과 함께 최소 OpenAI-compatible HTTP API 표면(`/v1/models`, `/v1/chat/completions`)을 제공하며, 이 HTTP 표면은 edge service를 통해 기존 edge-node transport를 사용한다. +Edge는 Node registry, node configuration, runtime routing, stream relay를 담당하는 백엔드 실행 그룹 컨트롤러다. 현재는 진단용 ops console과 함께 최소 OpenAI-compatible HTTP API 표면(`/v1/models`, `/v1/chat/completions`, `/v1/responses`)을 제공하며, 이 HTTP 표면은 edge service를 통해 기존 edge-node transport를 사용한다. **현재 상태: 초기 구현** node 등록/레지스트리/transport와 edge 콘솔 기반 수동 통신 테스트가 구현되어 있다. @@ -191,7 +191,7 @@ nodes: `openai.target`이 비어 있으면 HTTP 요청의 `model` 값을 내부 target으로 사용한다. 값이 있으면 Cline 같은 외부 agent가 보낸 `model`과 무관하게 YAML의 target으로 고정 라우팅한다. -기본 non-streaming과 streaming SSE 응답을 모두 지원한다. +`/v1/chat/completions`는 기본 non-streaming과 streaming SSE 응답을 모두 지원한다. `/v1/responses`는 현재 non-streaming 요청만 지원하며, `metadata.request_id`, `metadata.inference.target`, `metadata.nomadcode.task_id`, `metadata.nomadcode.source`를 service boundary까지 전달한다. ```bash curl -s http://127.0.0.1:18081/v1/chat/completions \ @@ -199,6 +199,12 @@ curl -s http://127.0.0.1:18081/v1/chat/completions \ -d '{"model":"qwen3.6:35b-a3b-bf16","messages":[{"role":"user","content":"hello"}]}' ``` +```bash +curl -s http://127.0.0.1:18081/v1/responses \ + -H 'Content-Type: application/json' \ + -d '{"model":"qwen3.6:35b-a3b-bf16","input":"hello","stream":false,"metadata":{"request_id":"example","nomadcode":{"task_id":"task-123","source":"manual"}}}' +``` + vLLM은 같은 `adapter + target` 경계를 쓰도록 설정과 `/v1/models` capability 조회 skeleton만 먼저 열어두었다. 실제 completion 실행은 다음 단계에서 붙인다. ### A2A Agent Input diff --git a/apps/edge/cmd/edge/main_test.go b/apps/edge/cmd/edge/main_test.go index 495c26e..2ac8707 100644 --- a/apps/edge/cmd/edge/main_test.go +++ b/apps/edge/cmd/edge/main_test.go @@ -1056,6 +1056,10 @@ func TestSmokeOpenAICommandSuccess(t *testing.T) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) w.Write([]byte(`{"id":"chat-123","object":"chat.completion","created":123456,"model":"test-model","choices":[{"index":0,"message":{"role":"assistant","content":"pong response"},"finish_reason":"stop"}]}`)) + case "/v1/responses": + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"id":"resp-123","object":"response","created_at":123456,"model":"test-model","output_text":"responses pong","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"responses pong"}]}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`)) default: w.WriteHeader(http.StatusNotFound) } @@ -1076,7 +1080,9 @@ func TestSmokeOpenAICommandSuccess(t *testing.T) { "Step 1: Checking /healthz ... [OK]", "Step 2: Checking /v1/models ... [OK]", "Step 3: Checking /v1/chat/completions ... [OK]", - "Response Content: \"pong response\"", + "Chat Response Content: \"pong response\"", + "Step 4: Checking /v1/responses ... [OK]", + "Responses Output Text: \"responses pong\"", "IOP Edge OpenAI Smoke Test SUCCESS!", } for _, w := range wants { @@ -1168,4 +1174,68 @@ func TestSmokeOpenAICommandFailure(t *testing.T) { if !strings.Contains(err3.Error(), "returned empty assistant message content") { t.Errorf("unexpected error message: %v", err3) } + + // 4. Responses non-200 error + server4 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/healthz": + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status":"ok"}`)) + case "/v1/models": + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"object":"list","data":[{"id":"test-model","object":"model"}]}`)) + case "/v1/chat/completions": + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"id":"chat-123","object":"chat.completion","created":123456,"model":"test-model","choices":[{"index":0,"message":{"role":"assistant","content":"pong"},"finish_reason":"stop"}]}`)) + case "/v1/responses": + w.WriteHeader(http.StatusServiceUnavailable) + w.Write([]byte("node offline")) + } + })) + defer server4.Close() + + root = rootCmd() + out.Reset() + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"smoke", "openai", "--model", "test-model", "--base-url", server4.URL}) + err4 := root.Execute() + if err4 == nil { + t.Fatalf("expected error from non-200 responses, got nil") + } + if !strings.Contains(err4.Error(), "returned non-200 status 503") { + t.Errorf("unexpected error message: %v", err4) + } + + // 5. Responses empty text error + server5 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/healthz": + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status":"ok"}`)) + case "/v1/models": + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"object":"list","data":[{"id":"test-model","object":"model"}]}`)) + case "/v1/chat/completions": + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"id":"chat-123","object":"chat.completion","created":123456,"model":"test-model","choices":[{"index":0,"message":{"role":"assistant","content":"pong"},"finish_reason":"stop"}]}`)) + case "/v1/responses": + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"id":"resp-123","object":"response","created_at":123456,"model":"test-model","output_text":"","output":[]}`)) + } + })) + defer server5.Close() + + root = rootCmd() + out.Reset() + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"smoke", "openai", "--model", "test-model", "--base-url", server5.URL}) + err5 := root.Execute() + if err5 == nil { + t.Fatalf("expected error from empty responses text, got nil") + } + if !strings.Contains(err5.Error(), "returned empty response text") { + t.Errorf("unexpected error message: %v", err5) + } } diff --git a/apps/edge/internal/edgecmd/root.go b/apps/edge/internal/edgecmd/root.go index be02868..d71b714 100644 --- a/apps/edge/internal/edgecmd/root.go +++ b/apps/edge/internal/edgecmd/root.go @@ -31,7 +31,7 @@ The official local development and field test flow is as follows: 4. config check - Validate the edge.yaml configuration structure and constraints. 5. serve (or console) - Start the Edge server (or interactive console) to accept connections. 6. nodes list - List configured nodes and show their connection status. - 7. smoke openai - Perform an E2E health and chat completion smoke test on the OpenAI endpoint.`, + 7. smoke openai - Perform an E2E health, models, chat, and responses smoke test on the OpenAI endpoint.`, Example: ` iop-edge config init iop-edge env iop-edge node register my-node --adapter cli diff --git a/apps/edge/internal/edgecmd/smoke_openai.go b/apps/edge/internal/edgecmd/smoke_openai.go index f44ae31..eafa06f 100644 --- a/apps/edge/internal/edgecmd/smoke_openai.go +++ b/apps/edge/internal/edgecmd/smoke_openai.go @@ -76,6 +76,41 @@ type chatCompletionResponse struct { Usage *chatCompletionUsage `json:"usage"` } +type responsesContentItem struct { + Type string `json:"type"` + Text string `json:"text"` +} + +type responsesOutputItem struct { + Type string `json:"type"` + Role string `json:"role"` + Content []responsesContentItem `json:"content"` +} + +type responsesResponse struct { + ID string `json:"id"` + Object string `json:"object"` + CreatedAt int64 `json:"created_at"` + Model string `json:"model"` + OutputText string `json:"output_text"` + Output []responsesOutputItem `json:"output"` + Usage *chatCompletionUsage `json:"usage"` +} + +func (r responsesResponse) text() string { + if strings.TrimSpace(r.OutputText) != "" { + return r.OutputText + } + for _, item := range r.Output { + for _, content := range item.Content { + if strings.TrimSpace(content.Text) != "" { + return content.Text + } + } + } + return "" +} + func resolveSmokeBaseURL(cmd *cobra.Command) (string, error) { if smokeBaseURL != "" { return strings.TrimSuffix(smokeBaseURL, "/"), nil @@ -103,10 +138,11 @@ func smokeOpenAICmd() *cobra.Command { Use: "openai", Short: "Perform OpenAI-compatible endpoint smoke test", Long: `Validate the IOP Edge OpenAI-compatible HTTP API surface. -It runs a three-step diagnostics check: +It runs a four-step diagnostics check: 1. Checks GET /healthz (server status). 2. Checks GET /v1/models (advertised model compatibility). 3. Checks POST /v1/chat/completions (inference request processing). + 4. Checks POST /v1/responses (non-streaming Responses API request processing). If --base-url is not provided, it auto-discovers the OpenAI URL from the effective config's listen address.`, Example: ` iop-edge smoke openai --model gemma4:26b @@ -231,7 +267,49 @@ If --base-url is not provided, it auto-discovers the OpenAI URL from the effecti return fmt.Errorf("POST %s returned empty assistant message content", chatURL) } fmt.Fprintf(cmd.OutOrStdout(), "[OK]\n") - fmt.Fprintf(cmd.OutOrStdout(), "Response Content: %q\n\n", content) + fmt.Fprintf(cmd.OutOrStdout(), "Chat Response Content: %q\n\n", content) + + fmt.Fprintf(cmd.OutOrStdout(), "Step 4: Checking /v1/responses ... ") + responsesPayload := map[string]interface{}{ + "model": smokeModel, + "input": prompt, + "stream": false, + "metadata": map[string]interface{}{ + "request_id": "iop-edge-smoke", + "inference": map[string]string{ + "target": smokeModel, + }, + }, + } + payloadBytes, err = json.Marshal(responsesPayload) + if err != nil { + return fmt.Errorf("failed to marshal responses request payload: %w", err) + } + + responsesURL := baseURL + "/v1/responses" + resp, err = client.Post(responsesURL, "application/json", bytes.NewReader(payloadBytes)) + if err != nil { + fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]") + return fmt.Errorf("POST %s failed: %w (make sure active nodes are online)", responsesURL, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]") + return fmt.Errorf("POST %s returned non-200 status %d: %s", responsesURL, resp.StatusCode, string(body)) + } + var responsesResp responsesResponse + if err := json.NewDecoder(resp.Body).Decode(&responsesResp); err != nil { + fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]") + return fmt.Errorf("failed to parse /v1/responses JSON response: %w", err) + } + outputText := responsesResp.text() + if strings.TrimSpace(outputText) == "" { + fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]") + return fmt.Errorf("POST %s returned empty response text", responsesURL) + } + fmt.Fprintf(cmd.OutOrStdout(), "[OK]\n") + fmt.Fprintf(cmd.OutOrStdout(), "Responses Output Text: %q\n\n", outputText) fmt.Fprintf(cmd.OutOrStdout(), "IOP Edge OpenAI Smoke Test SUCCESS!\n") return nil diff --git a/apps/edge/internal/openai/chat_handler.go b/apps/edge/internal/openai/chat_handler.go index beb2908..8ed074d 100644 --- a/apps/edge/internal/openai/chat_handler.go +++ b/apps/edge/internal/openai/chat_handler.go @@ -126,9 +126,16 @@ func (s *Server) resolveAdapter() string { } func (s *Server) resolveTarget(model string) string { + return s.resolveTargetWithOverride(model, "") +} + +func (s *Server) resolveTargetWithOverride(model, override string) string { if s.cfg.Target != "" { return s.cfg.Target } + if override != "" { + return strings.TrimSpace(override) + } return strings.TrimSpace(model) } diff --git a/apps/edge/internal/openai/responses_handler.go b/apps/edge/internal/openai/responses_handler.go new file mode 100644 index 0000000..efc148c --- /dev/null +++ b/apps/edge/internal/openai/responses_handler.go @@ -0,0 +1,185 @@ +package openai + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "go.uber.org/zap" + + edgeservice "iop/apps/edge/internal/service" +) + +func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + return + } + defer r.Body.Close() + + var req responsesRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request_error", "invalid JSON request") + return + } + + if req.Stream { + writeError(w, http.StatusBadRequest, "invalid_request_error", "streaming is not supported for /v1/responses") + return + } + + inputStr, err := parseResponsesInput(req.Input) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_request_error", err.Error()) + return + } + + prompt := buildResponsesPrompt(req.Instructions, inputStr) + outputPolicy := s.resolveOutputPolicy(prompt) + if instruction := strictOutputContractInstruction(outputPolicy); instruction != "" { + prompt = instruction + "\n" + prompt + } + + runMeta, inferenceTarget, err := parseResponsesMetadata(req.Metadata) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_request_error", err.Error()) + return + } + + target := s.resolveTargetWithOverride(req.Model, inferenceTarget) + if target == "" { + writeError(w, http.StatusBadRequest, "invalid_request_error", "model is required") + return + } + + runMeta["source"] = "openai-responses" + runMeta["openai_model"] = req.Model + runMeta["openai_stream"] = fmt.Sprintf("%t", req.Stream) + runMeta["strict_output"] = fmt.Sprintf("%t", outputPolicy.Strict) + input := map[string]any{"prompt": prompt} + if outputPolicy.Strict { + input["think"] = false + } + + s.logger.Info("openai responses input", + zap.String("model", req.Model), + zap.String("target", target), + zap.String("adapter", s.resolveAdapter()), + zap.Bool("strict_output", outputPolicy.Strict), + zap.String("xml_completion_tool", outputPolicy.XMLCompletionTool), + zap.Bool("contract_instruction", outputPolicy.ContractInstruction), + zap.Int("prompt_len", len(prompt)), + zap.String("prompt_preview", previewString(prompt, 1000)), + ) + + handle, err := s.service.SubmitRun(r.Context(), edgeservice.SubmitRunRequest{ + NodeRef: s.cfg.NodeRef, + Adapter: s.resolveAdapter(), + Target: target, + SessionID: s.resolveSessionID(), + Prompt: prompt, + Input: input, + TimeoutSec: s.resolveTimeoutSec(), + Metadata: runMeta, + }) + if err != nil { + writeError(w, http.StatusBadGateway, "node_dispatch_error", err.Error()) + return + } + defer handle.Close() + + s.completeResponse(w, r, req, handle, outputPolicy) +} + +func (s *Server) completeResponse(w http.ResponseWriter, r *http.Request, req responsesRequest, handle edgeservice.RunResult, outputPolicy strictOutputPolicy) { + text, reasoning, usage, err := collectRunResult(r.Context(), handle.Stream(), handle.WaitTimeout()) + if err != nil { + writeError(w, httpStatusForRunError(err), "run_error", err.Error()) + return + } + text, reasoning, normalized := normalizeCompletionOutput(outputPolicy, text, reasoning) + s.logger.Info("openai responses output", + zap.String("run_id", handle.Dispatch().RunID), + zap.Bool("strict_output", outputPolicy.Strict), + zap.String("xml_completion_tool", outputPolicy.XMLCompletionTool), + zap.Bool("normalized", normalized), + zap.Int("content_len", len(text)), + zap.Int("reasoning_len", len(reasoning)), + zap.String("content_preview", previewString(text, 1000)), + ) + + var u openAIUsage + if usage != nil { + u = *usage + } + + writeJSON(w, http.StatusOK, responsesResponse{ + ID: "resp-" + handle.Dispatch().RunID, + Object: "response", + CreatedAt: time.Now().Unix(), + Model: responseModel(req.Model, handle.Dispatch().Target), + OutputText: text, + Output: []responsesOutputItem{{ + Type: "message", + Role: "assistant", + Content: []responsesContentItem{{ + Type: "output_text", + Text: text, + }}, + }}, + Usage: u, + }) +} + +func parseResponsesInput(raw json.RawMessage) (string, error) { + if len(raw) == 0 { + return "", fmt.Errorf("input is required") + } + var s string + if err := json.Unmarshal(raw, &s); err != nil { + return "", fmt.Errorf("input must be a string") + } + if strings.TrimSpace(s) == "" { + return "", fmt.Errorf("input is required") + } + return s, nil +} + +func buildResponsesPrompt(instructions, input string) string { + instructions = strings.TrimSpace(instructions) + input = strings.TrimSpace(input) + if instructions == "" { + return input + } + return instructions + "\n" + input +} + +func parseResponsesMetadata(raw json.RawMessage) (map[string]string, string, error) { + if len(raw) == 0 || string(raw) == "null" { + return make(map[string]string), "", nil + } + + var rawMap map[string]json.RawMessage + if err := json.Unmarshal(raw, &rawMap); err != nil { + return nil, "", fmt.Errorf("metadata must be an object") + } + + if _, hasCLI := rawMap["cli"]; hasCLI { + return nil, "", fmt.Errorf("metadata.cli is not supported") + } + + var meta responsesMetadata + if err := json.Unmarshal(raw, &meta); err != nil { + return nil, "", fmt.Errorf("invalid metadata format") + } + + flat := meta.metadataForRun() + inferenceTarget := "" + if meta.Inference != nil { + inferenceTarget = meta.Inference.Target + } + + return flat, inferenceTarget, nil +} diff --git a/apps/edge/internal/openai/routes.go b/apps/edge/internal/openai/routes.go index e214b42..acf750b 100644 --- a/apps/edge/internal/openai/routes.go +++ b/apps/edge/internal/openai/routes.go @@ -11,6 +11,7 @@ func (s *Server) routes() *http.ServeMux { mux.HandleFunc("/healthz", s.handleHealthz) mux.HandleFunc("/v1/models", s.handleModels) mux.HandleFunc("/v1/chat/completions", s.handleChatCompletions) + mux.HandleFunc("/v1/responses", s.handleResponses) mux.HandleFunc("/api/", s.handleOllamaAPI) return mux } diff --git a/apps/edge/internal/openai/server_test.go b/apps/edge/internal/openai/server_test.go index 18e09af..a8c10d4 100644 --- a/apps/edge/internal/openai/server_test.go +++ b/apps/edge/internal/openai/server_test.go @@ -403,6 +403,285 @@ func TestOllamaAPIPassthrough(t *testing.T) { } } +func TestResponsesDispatchesNonStreamingRequest(t *testing.T) { + fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)} + fake.events <- &iop.RunEvent{Type: "delta", Delta: "hello"} + fake.events <- &iop.RunEvent{Type: "delta", Delta: " world"} + fake.events <- &iop.RunEvent{Type: "complete", Usage: &iop.Usage{InputTokens: 1, OutputTokens: 1}} + + srv := NewServer(config.EdgeOpenAIConf{ + Adapter: "ollama", + Target: "llama-fixed", + }, fake, nil) + req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{ + "model":"client-model", + "input":"say hello" + }`)) + w := httptest.NewRecorder() + + srv.routes().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) + } + if fake.req.Target != "llama-fixed" { + t.Fatalf("target: got %q, want llama-fixed", fake.req.Target) + } + if fake.req.Prompt != "say hello" { + t.Fatalf("prompt: got %q", fake.req.Prompt) + } + + var resp responsesResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp.OutputText != "hello world" { + t.Fatalf("output_text: got %q", resp.OutputText) + } + if len(resp.Output) == 0 || len(resp.Output[0].Content) == 0 || resp.Output[0].Content[0].Text != "hello world" { + t.Fatalf("output[0].content[0].text wrong: %+v", resp.Output) + } +} + +func TestResponsesRejectsUnsupportedRequests(t *testing.T) { + srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "llama"}, &fakeRunService{}, nil) + + cases := []struct { + name string + method string + body string + want int + }{ + {"wrong method", http.MethodGet, "", http.StatusMethodNotAllowed}, + {"stream=true", http.MethodPost, `{"model":"m","input":"hi","stream":true}`, http.StatusBadRequest}, + {"empty input", http.MethodPost, `{"model":"m","input":""}`, http.StatusBadRequest}, + {"non-string input", http.MethodPost, `{"model":"m","input":["a","b"]}`, http.StatusBadRequest}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(tc.method, "/v1/responses", strings.NewReader(tc.body)) + w := httptest.NewRecorder() + srv.routes().ServeHTTP(w, req) + if w.Code != tc.want { + t.Fatalf("got %d want %d body=%s", w.Code, tc.want, w.Body.String()) + } + }) + } +} + +func TestResponsesMetadataContractAndTargetOverride(t *testing.T) { + fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)} + fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"} + fake.events <- &iop.RunEvent{Type: "complete"} + + srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil) + req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{ + "model":"client-model", + "input":"test", + "metadata":{ + "request_id":"req-001", + "inference":{"target":"metadata-target"}, + "nomadcode":{"task_id":"task-123","source":"nomadcode"} + } + }`)) + w := httptest.NewRecorder() + srv.routes().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) + } + if fake.req.Target != "metadata-target" { + t.Fatalf("target: got %q, want metadata-target", fake.req.Target) + } + if fake.req.Metadata["request_id"] != "req-001" { + t.Fatalf("request_id: got %q", fake.req.Metadata["request_id"]) + } + if fake.req.Metadata["inference.target"] != "metadata-target" { + t.Fatalf("inference.target: got %q", fake.req.Metadata["inference.target"]) + } + if fake.req.Metadata["nomadcode.task_id"] != "task-123" { + t.Fatalf("nomadcode.task_id: got %q", fake.req.Metadata["nomadcode.task_id"]) + } + if fake.req.Metadata["nomadcode.source"] != "nomadcode" { + t.Fatalf("nomadcode.source: got %q", fake.req.Metadata["nomadcode.source"]) + } + if fake.req.Metadata["source"] != "openai-responses" { + t.Fatalf("source: got %q", fake.req.Metadata["source"]) + } + if fake.req.Metadata["openai_model"] != "client-model" { + t.Fatalf("openai_model: got %q", fake.req.Metadata["openai_model"]) + } + if fake.req.Metadata["openai_stream"] != "false" { + t.Fatalf("openai_stream: got %q", fake.req.Metadata["openai_stream"]) + } + if fake.req.Metadata["strict_output"] != "false" { + t.Fatalf("strict_output: got %q", fake.req.Metadata["strict_output"]) + } +} + +func TestResponsesConfiguredTargetWinsOverMetadataTarget(t *testing.T) { + fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)} + fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"} + fake.events <- &iop.RunEvent{Type: "complete"} + + srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "config-target"}, fake, nil) + req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{ + "model":"client-model", + "input":"test", + "metadata":{"inference":{"target":"metadata-target"}} + }`)) + w := httptest.NewRecorder() + srv.routes().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) + } + if fake.req.Target != "config-target" { + t.Fatalf("target: got %q, want config-target", fake.req.Target) + } + + var resp responsesResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Model != "client-model" { + t.Fatalf("model: got %q, want client-model", resp.Model) + } +} + +func TestResponsesRejectsCLIMetadata(t *testing.T) { + srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "llama"}, &fakeRunService{}, nil) + + cases := []struct { + name string + body string + }{ + {"cli only", `{"model":"m","input":"hi","metadata":{"cli":{"flag":"x"}}}`}, + {"inference and cli", `{"model":"m","input":"hi","metadata":{"inference":{"target":"t"},"cli":{"flag":"x"}}}`}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(tc.body)) + w := httptest.NewRecorder() + srv.routes().ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("got %d want 400, body=%s", w.Code, w.Body.String()) + } + }) + } +} + +func TestResponsesReturnsNomadCodeCompatibleShape(t *testing.T) { + fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)} + fake.events <- &iop.RunEvent{Type: "delta", Delta: "answer"} + fake.events <- &iop.RunEvent{Type: "complete", Usage: &iop.Usage{InputTokens: 1, OutputTokens: 1}} + + srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "llama"}, fake, nil) + req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{ + "model":"client-model", + "input":"say hello" + }`)) + w := httptest.NewRecorder() + srv.routes().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) + } + + var resp responsesResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if !strings.HasPrefix(resp.ID, "resp-") { + t.Fatalf("id: got %q, want resp- prefix", resp.ID) + } + if resp.Model != "client-model" { + t.Fatalf("model: got %q", resp.Model) + } + if resp.OutputText != "answer" { + t.Fatalf("output_text: got %q", resp.OutputText) + } + if len(resp.Output) == 0 || len(resp.Output[0].Content) == 0 || resp.Output[0].Content[0].Text != "answer" { + t.Fatalf("output[0].content[0].text wrong: %+v", resp.Output) + } + if resp.Usage.TotalTokens != 2 { + t.Fatalf("usage.total_tokens: got %d", resp.Usage.TotalTokens) + } +} + +func TestResponsesUsageDefaultsToZeroObject(t *testing.T) { + fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)} + fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"} + fake.events <- &iop.RunEvent{Type: "complete"} + + srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "llama"}, fake, nil) + req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{ + "model":"m", + "input":"hi" + }`)) + w := httptest.NewRecorder() + srv.routes().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) + } + + var body map[string]json.RawMessage + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("decode: %v", err) + } + if _, ok := body["usage"]; !ok { + t.Fatal("usage field missing from response") + } + var usage openAIUsage + if err := json.Unmarshal(body["usage"], &usage); err != nil { + t.Fatalf("usage is not an object: %s", body["usage"]) + } +} + +func TestResponsesStrictOutputNormalizesAgentResponse(t *testing.T) { + fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)} + fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "hidden"} + fake.events <- &iop.RunEvent{Type: "delta", Delta: "plain answer"} + fake.events <- &iop.RunEvent{Type: "complete"} + + srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "llama", StrictOutput: true}, fake, nil) + req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{ + "model":"client-model", + "instructions":"Once you've completed the user's task, you must use the attempt_completion tool to present the result.\n\n\ndone\n", + "input":"finish" + }`)) + w := httptest.NewRecorder() + srv.routes().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) + } + if fake.req.Metadata["strict_output"] != "true" { + t.Fatalf("strict_output metadata: got %q", fake.req.Metadata["strict_output"]) + } + if fake.req.Input["think"] != false { + t.Fatalf("strict output should disable thinking by default: %+v", fake.req.Input) + } + if !strings.Contains(fake.req.Prompt, "output exactly one block") { + t.Fatalf("strict contract instruction not injected:\n%s", fake.req.Prompt) + } + + var resp responsesResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + want := "\nplain answer\n" + if resp.OutputText != want { + t.Fatalf("output_text:\ngot %q\nwant %q", resp.OutputText, want) + } + if len(resp.Output) == 0 || len(resp.Output[0].Content) == 0 || resp.Output[0].Content[0].Text != want { + t.Fatalf("output[0].content[0].text wrong: %+v", resp.Output) + } +} + func TestCollectRunResultTimesOut(t *testing.T) { handle := &edgeservice.RunHandle{ RunDispatch: edgeservice.RunDispatch{TimeoutSec: 1}, diff --git a/apps/edge/internal/openai/types.go b/apps/edge/internal/openai/types.go index 2be6914..e2f4c73 100644 --- a/apps/edge/internal/openai/types.go +++ b/apps/edge/internal/openai/types.go @@ -199,3 +199,68 @@ type errorBody struct { Type string `json:"type"` Message string `json:"message"` } + +// Responses API types + +type responsesRequest struct { + Model string `json:"model"` + Input json.RawMessage `json:"input"` + Instructions string `json:"instructions,omitempty"` + Stream bool `json:"stream"` + Metadata json.RawMessage `json:"metadata,omitempty"` +} + +type responsesMetadata struct { + RequestID string `json:"request_id,omitempty"` + Inference *responsesInferenceMetadata `json:"inference,omitempty"` + NomadCode *responsesNomadCodeMetadata `json:"nomadcode,omitempty"` +} + +type responsesInferenceMetadata struct { + Target string `json:"target,omitempty"` +} + +type responsesNomadCodeMetadata struct { + TaskID string `json:"task_id,omitempty"` + Source string `json:"source,omitempty"` +} + +func (m *responsesMetadata) metadataForRun() map[string]string { + out := make(map[string]string) + if m.RequestID != "" { + out["request_id"] = m.RequestID + } + if m.Inference != nil && m.Inference.Target != "" { + out["inference.target"] = m.Inference.Target + } + if m.NomadCode != nil { + if m.NomadCode.TaskID != "" { + out["nomadcode.task_id"] = m.NomadCode.TaskID + } + if m.NomadCode.Source != "" { + out["nomadcode.source"] = m.NomadCode.Source + } + } + return out +} + +type responsesResponse struct { + ID string `json:"id"` + Object string `json:"object"` + CreatedAt int64 `json:"created_at"` + Model string `json:"model"` + OutputText string `json:"output_text"` + Output []responsesOutputItem `json:"output"` + Usage openAIUsage `json:"usage"` +} + +type responsesOutputItem struct { + Type string `json:"type"` + Role string `json:"role"` + Content []responsesContentItem `json:"content"` +} + +type responsesContentItem struct { + Type string `json:"type"` + Text string `json:"text"` +} diff --git a/apps/edge/internal/service/service_test.go b/apps/edge/internal/service/service_test.go index a9a5dc0..a2f13ba 100644 --- a/apps/edge/internal/service/service_test.go +++ b/apps/edge/internal/service/service_test.go @@ -161,6 +161,28 @@ func TestBuildRunRequestCopiesMetadata(t *testing.T) { } } +func TestBuildRunRequestPreservesResponsesMetadataKeys(t *testing.T) { + req, _, err := edgeservice.BuildRunRequest(edgeservice.SubmitRunRequest{ + Adapter: "ollama", + Target: "llama", + Prompt: "test", + Metadata: map[string]string{ + "request_id": "req-001", + "inference.target": "metadata-target", + "nomadcode.task_id": "task-123", + "nomadcode.source": "nomadcode", + }, + }) + if err != nil { + t.Fatalf("BuildRunRequest: %v", err) + } + for _, key := range []string{"request_id", "inference.target", "nomadcode.task_id", "nomadcode.source"} { + if req.GetMetadata()[key] == "" { + t.Errorf("metadata key %q not preserved", key) + } + } +} + func TestListNodesReturnsSnapshots(t *testing.T) { reg := edgenode.NewRegistry() reg.Register(&edgenode.NodeEntry{NodeID: "node-1", Alias: "alpha"})