# OpenAI-Compatible API Contract ## 계약 메타 - id: `iop.openai-compatible-api` - boundary: `outer` - status: active - 원본 경로: - `apps/edge/internal/openai/routes.go` - `apps/edge/internal/openai/chat_handler.go` - `apps/edge/internal/openai/responses_handler.go` - `apps/edge/internal/openai/types.go` - `apps/node/internal/adapters/openai_compat/openai_compat.go` - `packages/go/config/config.go` - `configs/edge.yaml` - human docs: `docs/openai-compatible-api-contract.md` ## 범위 이 문서는 외부 프로젝트가 IOP Edge의 OpenAI-compatible HTTP 표면을 호출할 때 확인할 계약 원문이다. IOP 내부 실행은 `adapter + target` 기준이며, OpenAI-compatible 경계에서는 호환성을 위해 `model`을 사용한다. IOP 고유 실행 문맥은 별도 `iop` wrapper field를 만들지 않고 OpenAI request의 `metadata`에 둔다. ## Auth Edge 설정의 `openai.bearer_token`이 비어 있지 않으면 OpenAI-compatible HTTP 표면은 다음 헤더를 요구한다. ```http Authorization: Bearer ``` 토큰이 없거나 일치하지 않으면 `401 unauthorized` OpenAI-compatible error response를 반환한다. `openai.bearer_token`이 빈 값이면 auth를 적용하지 않는다. ## Responses API Endpoint: ```http POST /v1/responses Content-Type: application/json ``` CLI agent 실행으로 라우팅되는 요청의 최소 형태: ```json { "model": "codex", "input": "현재 워크스페이스의 테스트 상태를 확인해줘.", "metadata": { "workspace": "/config/workspace/iop" } } ``` 현재 `/v1/responses`에서 허용하는 표준형 요청 예시: ```json { "model": "codex", "instructions": "응답은 짧게 작성해.", "input": "현재 워크스페이스의 테스트 상태를 확인해줘.", "stream": false, "background": false, "max_output_tokens": 4096, "temperature": 0, "top_p": 1, "metadata": { "workspace": "/config/workspace/iop", "request_id": "req-001", "task_id": "task-123" } } ``` 필드 의미: - `model`: Edge가 내부 `adapter + target`으로 해석할 외부 route 이름이다. IOP Edge에서는 라우팅을 위해 필수다. - `instructions`: OpenAI Responses API의 top-level instruction field다. 있으면 `input` 앞에 배치해 agent 실행 prompt를 만든다. - `input`: agent에게 전달할 사용자 요청이다. 현재 구현은 string input만 지원한다. - `stream`: 현재 구현은 `false` 또는 생략만 지원한다. - `background`: 현재 구현은 `false` 또는 생략만 지원한다. - `metadata.workspace`: CLI process를 실행할 작업 디렉터리다. CLI agent route에서는 필수 실행 문맥이다. - `metadata`: OpenAI 표준 metadata container다. string key/value를 허용하고, IOP는 `workspace`만 실행 문맥으로 해석한다. 나머지 key는 caller-defined metadata로 보존하되 `source`는 지원하지 않는다. - `metadata.request_id`, `metadata.task_id`: caller-defined metadata 예시다. 특별한 wrapper나 제품 전용 field가 아니다. - `max_output_tokens`: 출력 길이 상한이다. 내부 provider option의 `max_tokens`로 전달된다. - `temperature`: 생성 다양성 option이다. 대상 adapter가 지원하지 않으면 무시될 수 있다. - `top_p`: nucleus sampling option이다. 대상 adapter가 지원하지 않으면 무시될 수 있다. 금지: - `metadata.cli` 같은 CLI 전용 wrapper를 추가하지 않는다. - `metadata.inference`처럼 `model` route와 겹치는 target wrapper를 추가하지 않는다. - `metadata.nomadcode`처럼 특정 소비자 제품명에 묶인 wrapper를 추가하지 않는다. - `metadata.source`처럼 의미가 불명확한 호출 출처 field를 추가하지 않는다. - root-level `iop` 같은 별도 wrapper field를 추가하지 않는다. - `/v1/responses`에 `options` wrapper를 추가하지 않는다. Responses API option은 OpenAI 표준 top-level field를 따른다. - `session_id`, `timeout_sec` 같은 IOP 실행 제어 field를 request body 계약에 추가하지 않는다. logical session과 timeout은 route/config 기본값을 따른다. - workspace를 prompt 본문에 섞어 전달하지 않는다. 현재 구현 메모: - `/v1/responses`는 non-streaming 요청만 지원한다. - `metadata`는 최대 16개 string key/value를 허용한다. key는 64자 이하, value는 512자 이하를 기준으로 한다. - CLI route의 `metadata.workspace`는 이 문서의 계약 기준이다. 구현은 이 값을 Edge service의 run workspace와 Node CLI adapter의 process working directory로 전달해야 한다. - `metadata.workspace`는 `RunRequest.Workspace`로 전달하고 generic run metadata에는 복사하지 않는다. - 다른 Responses API 표준 field는 구현 필요가 생길 때 계약을 갱신한 뒤 추가한다. ## Generic Authoring Handoff 외부 caller가 IOP Edge HTTP 표면으로 workspace authoring 작업을 넘길 때의 최소 요청 형태: ```json { "model": "codex", "input": "Todo 항목에 필요한 산출물을 현재 checkout에 작성해줘.", "metadata": { "workspace": "/config/workspace/work-slot-123", "task_id": "todo-123" } } ``` 이 handoff는 `model`, `input`, `metadata.workspace`, 필요한 caller-defined metadata만으로 충분해야 한다. 호출자는 `metadata.cli`, 소비자 전용 metadata wrapper, root-level `iop` wrapper, IOP CLI 직접 실행, prompt 본문 workspace 주입을 요구받지 않는다. Workspace-bound route는 workspace가 없거나 상대 경로이면 OpenAI-compatible error로 거부한다. 존재하지 않는 경로, 권한 오류, agent process exit failure는 기본 cwd fallback으로 숨기지 않고 호출자가 실패로 구분할 수 있어야 한다. ## Chat Completions `/v1/chat/completions`도 같은 metadata 원칙을 따른다. CLI route의 workspace는 `metadata.workspace`에 둔다. Chat Completions의 provider sampling option은 해당 endpoint의 OpenAI-compatible top-level request field를 따르며, `/v1/responses`와 마찬가지로 별도 `options` wrapper를 두지 않는다. ```json { "model": "codex", "messages": [ { "role": "user", "content": "현재 워크스페이스의 테스트 상태를 확인해줘." } ], "metadata": { "workspace": "/config/workspace/iop" } } ``` 현재 지원하는 Chat Completions request field: - `model` - `messages` - `stream` - `metadata` - `max_tokens` - `max_completion_tokens` - `temperature` - `top_p` - `presence_penalty` - `frequency_penalty` - `seed` - `stop` - `response_format` - `tools` - `tool_choice` - `parallel_tool_calls` - `stream_options` - `store` - `think` - `reasoning_effort` - `thinking_token_budget` - `include_reasoning` Think 제어 field: - `think` (bool, optional): thinking/reasoning 생성 활성화 여부. 생략하면 provider 기본값을 유지한다. `false`는 thinking 생성을 끄도록 요청하고, `true`는 provider가 지원하면 thinking 생성을 명시 활성화한다. - `reasoning_effort` (string, optional): `none`, `low`, `medium`, `high` 중 하나. `none`은 `think=false`와 같은 disable 의미로 처리한다. `low`/`medium`/`high`는 provider가 지원하는 경우에만 전달한다. - `thinking_token_budget` (int, optional): thinking token budget. 0 이상이어야 한다. - `include_reasoning` (bool, optional): OpenAI-compatible 응답에서 `reasoning_content` 노출 여부. 생략하거나 `true`이면 provider reasoning delta/message를 노출할 수 있고, `false`이면 provider가 reasoning을 생성해도 response의 `reasoning_content`를 제거한다. Reasoning-only 완료 처리: - provider가 reasoning은 생성했지만 최종 assistant `content`와 `tool_calls` 없이 완료하면 Edge는 성공 응답을 빈 content로 끝내지 않는다. - `include_reasoning` 생략 또는 `true`인 요청은 기존 reasoning 본문을 `reasoning_content`에 유지하고, `content`가 비어 있으면 reasoning 본문을 fallback content로도 반환한다. `finish_reason`이 `stop`이 아니면 fallback content 뒤에 IOP notice를 붙인다. - `include_reasoning=false`인 요청은 reasoning 본문을 노출하지 않는다. 대신 `content`에 IOP notice를 넣어 "reasoning was hidden" 상태와 `finish_reason`을 알린다. - streaming 응답도 같은 정책을 따른다. reasoning-only 완료 시 최종 finish chunk와 `[DONE]` 전에 fallback 또는 hidden-reasoning notice를 `content` delta로 한 번 전송한다. Provider별 think-control 정책: - `vLLM`: - `think=false` 또는 `reasoning_effort=none` -> 내부 `chat_template_kwargs.enable_thinking=false` - `think=true` 또는 budget-only -> 내부 `chat_template_kwargs.enable_thinking=true` - `thinking_token_budget` -> 내부 `chat_template_kwargs.thinking_token_budget` - `reasoning_effort=low|medium|high` -> `unsupported think control` 오류 반환 - `vLLM-MLX`: - `think=true` 또는 budget-only -> 내부 `chat_template_kwargs.enable_thinking=true` - `thinking_token_budget` -> 내부 `chat_template_kwargs.thinking_token_budget` - `think=false` 또는 `reasoning_effort=none` -> `unsupported think control` 오류 반환. vLLM-MLX 런타임은 스트리밍 응답 전체를 `reasoning_content`로 표기하고 `enable_thinking=false`로도 reasoning 생성을 멈추지 않으므로, think disable을 조용한 성공(200 reasoning stream)으로 처리하지 않고 명시 오류를 반환한다. - `reasoning_effort=low|medium|high` -> `unsupported think control` 오류 반환 - `Lemonade`: - `think=false` 또는 `reasoning_effort=none` -> 내부 `chat_template_kwargs.enable_thinking=false`. 이 런타임은 top-level `think` field를 무시하므로 top-level `think`를 사용하지 않는다. - `think=true` 또는 budget-only -> 내부 `chat_template_kwargs.enable_thinking=true` - `thinking_token_budget` -> 내부 `chat_template_kwargs.thinking_token_budget` - `reasoning_effort=low|medium|high` -> `unsupported think control` 오류 반환 - Unknown / 기타 provider: 요청 field를 그대로 전달하되, provider가 지원하지 않는 값은 backend 또는 adapter error가 될 수 있다. Provider pool model catalog의 `models[]` entry가 generation policy를 제공하면 Edge는 요청을 내부 실행으로 넘기기 전에 다음 값을 보정한다. - `default_max_tokens`: caller가 출력 token limit을 생략했을 때 `max_tokens` 또는 `max_output_tokens`로 주입한다. - `min_max_tokens`: caller가 너무 작은 출력 token limit을 보냈을 때 해당 값까지 올린다. caller 값이 더 크면 보존한다. - `default_thinking_token_budget`: caller가 `thinking_token_budget`을 생략했고 strict output이 reasoning을 금지하지 않을 때 내부 실행 입력에 주입한다. Conflict 정책: - `reasoning_effort`가 비어 있거나 `none|low|medium|high` 외 값이면 400 에러. - `thinking_token_budget`가 음수이면 400 에러. - `think=false`와 `reasoning_effort=low|medium|high`가 함께 있으면 400 에러. - `think=false`일 때 `thinking_token_budget`를 설정하면 400 에러. - `reasoning_effort=none`일 때 `thinking_token_budget`를 설정하면 400 에러. Strict output 모드: - strict output가 활성화되면 `think=true`가 명시되지 않은 요청은 내부 실행 입력에서 `think=false`로 낮춘다. `tools`가 있는 Chat Completions 요청에서 provider route(`openai_compat`, `vllm`, `ollama`, provider pool)는 forced tool 선택 객체와 `"none"` 같은 명시적 `tool_choice`를 backend에 전달한다. 단, `"auto"`는 OpenAI-compatible 기본값과 같으므로 provider request에서는 생략한다. 일부 vLLM 계열 backend는 explicit/default `"auto"`를 `--enable-auto-tool-choice`/`--tool-call-parser` 없이 400으로 거부한다. 이 400이 발생하고 요청 tool이 정확히 1개이면 Node adapter는 해당 tool에 대한 forced `tool_choice`로 1회 재시도한다. forced tool도 `--tool-call-parser` 요구로 거부되거나 여러 tool이라 forced를 고를 수 없으면, Node adapter는 `tools`/`tool_choice`를 제거하고 text tool-call system instruction을 leading system message에 병합해 1회 재시도하며 완료 metadata에 `openai_text_tool_fallback: "true"`를 싣는다. provider가 native OpenAI-compatible `tool_calls`를 반환하면 Node는 내부 `RunEvent.metadata["openai_tool_calls"]` JSON으로 보존하고, Edge는 이를 OpenAI-compatible `message.tool_calls` 또는 stream `delta.tool_calls`로 반환하며 `finish_reason: "tool_calls"`를 사용한다. provider native `tool_calls[].function.arguments`는 OpenAI 계약에 맞는 JSON string으로 반환한다. 단, provider가 요청 `tools[].function.parameters` schema상 배열/객체여야 하는 값을 JSON 문자열로 이중 인코딩한 경우 Edge는 해당 `arguments` JSON만 schema 기준으로 복원해 다시 JSON string으로 직렬화한다. 요청에 `tools[]`가 있고 provider가 native `tool_calls` 없이 assistant content에 raw text tool-call 블록을 담아 응답하면, provider route(`openai_compat`, `vllm`, `ollama`, provider pool)와 CLI route(`adapter: "cli"`) 모두에서 Edge는 그 블록을 요청 tool schema 기준으로 구조화하거나 차단한다. 이 정규화가 인식하는 텍스트 블록의 최소 형태는 `>>JSON-or-text` XML 형식과 `{{function_name(key=Python/JSON-like-literal)}}` mustache 형식이다. 후보 tool 이름이 요청 `tools[]`에 있고 arguments가 파싱되어 해당 tool의 `function.parameters` schema를 만족하면, Edge는 이를 OpenAI `tool_calls`로 정규화하고 raw 블록을 `content`에서 제거한 뒤 `finish_reason: "tool_calls"`로 반환한다. 요청 `tools[]`에 없는 tool 이름, unclosed/function 정의 누락 같은 malformed 블록, schema를 위반하는 arguments는 성공 content로 반환하지 않고 tool validation 실패로 처리한다. non-stream과 strict buffered stream 응답은 bounded tool-validation attempt 한도까지 run을 재시도하고, 그래도 실패하면 `tool_validation_error`로 응답한다. live SSE 스트림은 raw 블록을 content delta로 flush하지 않고 `tool_validation_error` 이벤트로 스트림을 종료한다. 요청에 `tools[]`가 없으면 assistant content의 tool-call 유사 텍스트는 파싱하거나 합성하지 않고 backend content 원문으로 그대로 둔다. 자연어 추론은 어떤 경우에도 `tool_calls`로 변환하지 않는다. raw ``/`{{...}}` 블록과 `<|mask_end|>` 같은 알려진 chat-template sentinel은 성공 응답의 `content`나 SSE delta에 노출하지 않는다. sentinel은 content와 reasoning 양쪽에서, streaming chunk 경계에 걸쳐 분할되더라도 sanitize한다. text tool-call을 구조화할 때 Edge는 route와 무관하게 요청의 `tools[].function.parameters` schema를 기준으로 arguments를 정규화한다. 예를 들어 tool schema가 `commands: string[]`만 허용하면 command 객체 입력도 shell string 배열로 접고, `commands: {command,args}[]`를 허용하면 shell 문법이 없는 명령을 structured argv로 만든다. schema에 없는 UI 설명용 `description`이나 실행 위치 힌트용 `runInTerminal`은 command 객체와 최상위 args에서 제거하되, `cd`, `command -v`, `&&`, pipe, redirect, quote 등 shell 해석이 필요한 명령은 schema가 허용할 때 `commands: ["cd /work && git status"]` 같은 shell string으로 유지한다. CLI route(`adapter: "cli"`)는 native backend tool calling이 없어 이 text tool-call 구조화가 유일한 `tool_calls` 경로이며, backend auto tool-calling 요구 조건으로 요청이 실패하지 않도록 내부 실행 입력의 `tool_choice`를 `"none"`으로 낮춘다. `parallel_tool_calls`, `stream_options`, `store`는 클라이언트 호환성을 위해 수신하지만 현재 Edge 실행 의미에는 반영하지 않는다. 금지: - `metadata.source`, `metadata.cli`, `metadata.inference`, `metadata.nomadcode` - `options`, `chat_template_kwargs`, `format`, `keep_alive` 같은 provider/Ollama 전용 request field - `session_id`, `timeout_sec` 같은 IOP 실행 제어 field ## Legacy Completions `POST /v1/completions`는 현재 IOP Edge OpenAI-compatible 표면에서 제공하지 않는다. text completion 형태의 신규 호출은 `/v1/responses`를 사용하고, message 기반 호출은 `/v1/chat/completions`를 사용한다. ## Routing Edge 설정이 `openai.model_routes[]`를 제공하면 `model`은 먼저 route catalog에서 해석된다. 매칭 route가 없으면 기존 fallback 규칙에 따라 `openai.target` 또는 요청의 `model`을 내부 target으로 사용한다. CLI agent를 OpenAI-compatible API로 노출할 때는 route catalog에서 해당 `model`을 명시적으로 `adapter: "cli"`와 target profile로 매핑하는 방식을 우선한다.