feat(edge): Responses 입력 표면을 추가한다
NomadCode Core가 OpenAI Responses shape로 Edge 실행을 요청할 수 있도록 non-streaming /v1/responses 경계와 metadata 전달, smoke 검증을 함께 고정한다.
This commit is contained in:
parent
18843b3dd7
commit
80fcbca74f
18 changed files with 1838 additions and 21 deletions
|
|
@ -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로 분리한다.
|
||||
|
|
|
|||
|
|
@ -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.<requester>` 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할 수 없다.
|
||||
- 확인 필요: 없음
|
||||
|
|
@ -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 승인 처리
|
||||
- 확인 필요: 없음
|
||||
|
|
|
|||
|
|
@ -0,0 +1,202 @@
|
|||
<!-- task=m-openai-responses-input-surface/01_responses_api_surface plan=0 tag=API -->
|
||||
|
||||
# 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-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. 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-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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
|
||||
|
|
@ -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
|
||||
|
||||
- 없음
|
||||
|
||||
## 후속 작업
|
||||
|
||||
- 없음
|
||||
|
|
@ -0,0 +1,329 @@
|
|||
<!-- task=m-openai-responses-input-surface/01_responses_api_surface plan=0 tag=API -->
|
||||
|
||||
# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
<!-- task=m-openai-responses-input-surface/02+01_responses_smoke plan=0 tag=TEST -->
|
||||
|
||||
# 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-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. 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-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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 |
|
||||
|
|
@ -0,0 +1,240 @@
|
|||
<!-- task=m-openai-responses-input-surface/02+01_responses_smoke plan=0 tag=TEST -->
|
||||
|
||||
# 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
|
||||
<!-- agent-test/local/edge-smoke.md:48 -->
|
||||
- OpenAI-compatible 경계를 바꾼 경우 `iop-edge smoke openai` 또는 동등한 `/healthz`, `/v1/models`, `/v1/chat/completions` 확인으로 edge service와 node adapter 경로 수렴을 확인한다.
|
||||
```
|
||||
|
||||
```md
|
||||
<!-- agent-test/local/edge-smoke.md:63 -->
|
||||
- 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
185
apps/edge/internal/openai/responses_handler.go
Normal file
185
apps/edge/internal/openai/responses_handler.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<attempt_completion>\n<result>done</result>\n</attempt_completion>",
|
||||
"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 <attempt_completion> 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 := "<attempt_completion>\n<result>plain answer</result>\n</attempt_completion>"
|
||||
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},
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
|
|
|||
Loading…
Reference in a new issue