<!-- 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
