feat(openai): extend Anthropic bridge compatibility

This commit is contained in:
toki 2026-08-06 07:04:03 +09:00
parent 495996fee4
commit 4b3b5debae
26 changed files with 856 additions and 194 deletions

View file

@ -95,12 +95,15 @@ anthropic-version: 2023-06-01
지원하는 `Anthropic-Beta` 값:
- `claude-code-20250219`
- `effort-2025-11-24`
- `fine-grained-tool-streaming-2025-05-14`
- `interleaved-thinking-2025-05-14`
- `mid-conversation-system-2026-04-07`
- `prompt-caching-2024-07-31`
- `structured-outputs-2025-12-15`
지원하지 않는 beta 값을 보내면 `400 invalid_request_error`를 반환한다.
Chat bridge 경로는 `Anthropic-Beta`를 지원하지 않으며, bridge로 라우팅될 때 beta 값이 있으면 `400 invalid_request_error`를 반환한다.
Native Messages 경로는 지원 beta 헤더를 upstream으로 전달한다. Chat bridge 경로는 지원 beta 헤더를 upstream으로 전달하지 않고, 아래에 명시한 대응 field만 Chat Completions 형식으로 변환한다.
## Routes
@ -146,7 +149,14 @@ Wrong methods on Anthropic-selected endpoints return `405 invalid_request_error`
}
],
"tool_choice": { "type": "auto" },
"thinking": { "type": "enabled", "budget_tokens": 1000 },
"thinking": { "type": "adaptive" },
"output_config": {
"effort": "high",
"format": {
"type": "json_schema",
"schema": { "type": "object" }
}
},
"metadata": { "user_id": "user-123" }
}
```
@ -164,8 +174,11 @@ Wrong methods on Anthropic-selected endpoints return `405 invalid_request_error`
- `stop_sequences`: 빈 문자열은 허용되지 않는다.
- `tools`: 각 tool은 `name`, `input_schema`를 필수로 가진다.
- `tool_choice`: `auto`, `any`, `none`, `tool` 타입만 허용한다.
- `thinking`: `type="enabled"`와 양수 `budget_tokens`만 허용한다.
- `metadata`: caller-defined metadata로 보존하되 IOP identity source로 사용하지 않는다.
- `thinking`: 양수 `budget_tokens`가 있는 `type="enabled"` 또는 budget 없는 `type="adaptive"`를 허용한다. Chat bridge의 `enabled`는 profile의 thinking/reasoning extension이 필요하고, `adaptive``output_config.effort` 기반 provider 제어를 사용한다.
- `output_config.effort`: `low`, `medium`, `high`를 허용하며 Chat bridge에서 `reasoning_effort`로 변환한다.
- `output_config.format`: `type="json_schema"`와 object `schema`를 허용하며 Chat bridge에서 OpenAI-compatible `response_format.json_schema`로 변환한다.
- `cache_control`: text/image/tool/tool-result/thinking block과 tool declaration의 compatibility annotation을 수용하되 Chat bridge에서는 정책으로 해석하거나 provider body에 전달하지 않는다.
- `metadata`: caller-defined object이며 IOP identity source로 사용하지 않는다. Native Messages 경로는 원문을 보존하고, Chat bridge는 object 여부만 검증한 뒤 provider body에서는 제거한다.
### Response (non-streaming)
@ -287,6 +300,8 @@ Top-level `models[]` is the static catalog source for IOP model discovery and pr
`openai_chat`이면 Edge는 Anthropic Messages request를 Chat Completions request로 bridge하고, Chat bridge 응답을 다시 Anthropic Messages response로 변환한다. Authorized virtual presets retain their requested virtual response model identity through that conversion; ordinary bridge responses use the bridge's converted response model semantics.
그 외 driver는 `502 api_error` "selected provider returned an unsupported protocol driver"를 반환한다.
Chat bridge는 Gemini OpenAI-compatible tool call의 `extra_content.google.thought_signature`를 opaque Anthropic `tool_use.id`에 담아 caller에게 전달한다. Caller는 해당 id를 tool result까지 변경 없이 replay해야 하며, 다음 요청에서 Edge는 원래 tool call id와 signature를 복원한다. Signature가 없는 provider의 tool id는 변경하지 않는다.
### Authorized virtual-preset Hot Path
Ordinary native Messages routes preserve selected-provider status, allowlisted headers,
@ -319,8 +334,8 @@ capability 불만족은 `400 not_supported_error`로 종료한다.
### Profile thinking support
Chat bridge provider profile의 `extensions.thinking` 또는 `extensions.reasoning``true`일 때만 `thinking` block을 지원한다.
thinking 미지원 profile로 bridge하면 `400 invalid_request_error` "selected Chat profile does not support thinking"를 반환한다.
Chat bridge의 explicit `thinking.type="enabled"`와 assistant thinking block 전달은 provider profile의 `extensions.thinking` 또는 `extensions.reasoning``true`일 때만 지원한다. Claude Code가 이전 응답에서 받은 빈 signature의 thinking block을 generic Chat profile 요청에 replay하면 private reasoning block만 제거하고 visible text/tool history는 유지한다. Signed thinking block은 profile과 관계없이 Chat bridge에서 거부한다.
해당 profile extension 없이 explicit enabled thinking으로 bridge하면 `400 invalid_request_error` "selected Chat profile does not support thinking"를 반환한다. `thinking.type="adaptive"`는 별도 budget field를 만들지 않고 `output_config.effort``reasoning_effort`로 변환한다.
## Usage Attribution

View file

@ -1,7 +1,7 @@
---
domain: edge
last_rule_review_commit: 4695bcbc60322b567a6e76d872490e696df672ed
last_rule_updated_at: 2026-07-30
last_rule_review_commit: 495996fee4b55eabef58505f73ab23848794eeef
last_rule_updated_at: 2026-08-06
---
# edge
@ -100,6 +100,7 @@ last_rule_updated_at: 2026-07-30
- OpenAI-compatible 경계의 `model`과 A2A 경계의 `Task`/JSON-RPC 표현은 입력 표면 안에서만 유지하고, edge 내부 실행은 `service.SubmitRun()``adapter + target` 요청으로 변환한다.
- provider pool에서는 top-level `models[]`의 id를 canonical model group key로 보고, `models[].providers``nodes[].providers[]`를 통해 provider id별 served model로 rewrite한다. caller metadata나 request body의 임의 field가 provider 선택권을 갖지 않게 한다.
- OpenAI-compatible raw passthrough는 `ProviderTunnelRequest`/`ProviderTunnelFrame` 경계와 `service.SubmitProviderTunnel()`을 통해서만 수행한다. HTTP handler가 node transport client에 직접 provider tunnel message를 쓰지 않는다.
- Execution preset의 request-scoped workspace/tool loop는 Edge의 표면 중립 coordinator/service가 admission, immutable preset/workspace binding, stage 전이와 terminal을 소유한다. Anthropic handler가 Node transport에 직접 tool request를 보내거나 외부 caller에게 내부 tool-result continuation을 위임하지 않는다.
- Stream Evidence Gate가 활성화된 요청은 request-start config/filter snapshot에 고정하고, blocking filter의 safe release 전에는 response start나 opening event를 commit하지 않는다. release, terminal, bounded recovery는 공통 `streamgate` runtime을 통해 단일 수명주기로 수렴시킨다.
- Stream Evidence Gate의 endpoint codec, provider-tunnel 변환, request rebuild와 OpenAI-compatible 오류 projection은 Edge가 소유하고, transport-neutral event/filter/commit/recovery 상태 머신은 `packages/go/streamgate`를 재사용한다.
- Output filter 선택은 endpoint, environment, model group/model, 실제 provider와 execution path를 기준으로 하며 caller SDK나 제품명을 정책 selector로 사용하지 않는다.
@ -118,6 +119,7 @@ last_rule_updated_at: 2026-07-30
## 다른 도메인과의 경계
- **node**: edge는 node 내부 adapter를 직접 실행하지 않는다. edge는 사전 등록 정보와 연결 registry를 기반으로 요청을 보낼 대상과 실행 설정을 관리하고, TCP/protobuf로 `RunRequest`/`CancelRequest`/`NodeCommandRequest`를 보낸다.
- **request-scoped workspace**: Edge는 preset과 principal에 승인된 Node/workspace capability를 고정하고 전용 typed request/result로 실행을 조정한다. Node가 실제 bounded file/command operation과 process cleanup을 수행하며 Edge는 workspace path를 직접 실행하지 않는다.
- **platform-common**: edge 설정, metrics, protobuf 타입과 transport-neutral `streamgate` event/filter/commit/recovery runtime은 platform-common 계약을 따른다. Edge는 OpenAI endpoint adapter와 정책 조립만 소유한다.
- **external input surfaces**: OpenAI-compatible HTTP와 A2A JSON-RPC는 edge inbound adapter이며, 내부 transport/protobuf 경계를 대체하지 않는다.
- **control-plane**: control-plane은 Edge를 통해 시스템을 제어한다. Edge domain은 outbound connector와 Edge-owned status/event/command 응답을 소유하고, control-plane domain은 server endpoint와 Edge connection/control view를 소유한다. Control Plane 없는 bootstrap/local/field/진단 fallback은 `iop-edge` command 표면에 남긴다.

View file

@ -1,14 +1,14 @@
---
domain: node
last_rule_review_commit: 4695bcbc60322b567a6e76d872490e696df672ed
last_rule_updated_at: 2026-08-02
last_rule_review_commit: 495996fee4b55eabef58505f73ab23848794eeef
last_rule_updated_at: 2026-08-06
---
# Node
## Responsibility
Node connects to Edge and executes provider requests. It owns transport handlers, provider adapter construction, local run tracking, runtime config swaps, provider tunnels, and execution event translation.
Node connects to Edge and executes provider requests. It owns transport handlers, provider adapter construction, local run tracking, runtime config swaps, provider tunnels, and execution event translation. For an approved execution preset, Node also owns bounded request-scoped workspace/file/command execution behind a dedicated typed Edge-Node boundary.
## Owned paths
@ -32,10 +32,12 @@ Node connects to Edge and executes provider requests. It owns transport handlers
- Base local concurrency on adapter capability. Edge remains the owner of distributed provider-pool admission and leases.
- Preserve standard inference, structured tools, usage, provider lifecycle, reconnect, and tunnel behavior.
- Regenerate bindings from protobuf source; never edit generated files.
- Keep request-scoped workspace execution separate from provider `RunRequest`, `packages/go/execution`, caller metadata, and the closed provider `NodeCommand` allowlist.
- Admit workspace operations only for an operator-approved root and immutable request binding. Enforce path/symlink containment, fixed cwd, environment allowlist, bounded process group/output/timeout/cancel, and terminal cleanup.
## Prohibited ownership
Node must not implement persistent host programs, interactive terminals, conversation resume, arbitrary host command execution, local filesystem context mutation, or quota/status scraping. It must not accept direct scheduling from Control Plane or Client.
Node must not implement persistent host programs, interactive terminals, conversation resume, unbounded or caller-selected host command execution, caller-selected filesystem roots, or quota/status scraping. The only workspace mutation exception is the bounded request-scoped executor admitted by an Edge-owned execution preset; it must not become a reusable shell/session service. Node must not accept direct scheduling from Control Plane or Client.
## Contracts and verification

View file

@ -1,7 +1,7 @@
---
domain: testing
last_rule_review_commit: 8760d165105fb03b0b8b62b55dd31c90f34daa44
last_rule_updated_at: 2026-07-31
last_rule_review_commit: 495996fee4b55eabef58505f73ab23848794eeef
last_rule_updated_at: 2026-08-06
---
# testing
@ -55,7 +55,7 @@ last_rule_updated_at: 2026-07-31
- client 개발 진단 흐름 검증 — `scripts/dev/web.sh`로 Flutter Web dev server를 띄우고 Control Plane HTTP/WS URL 주입과 `/client` wire 연결 상태를 확인하는 저수준 검증이다.
- 보조 E2E smoke — 임시 설정과 mock adapter로 최소 생존을 빠르게 확인하는 보조 검증이다. 이 결과만으로 완료 처리하지 않는다.
- OpenAI-compatible Ollama smoke — `scripts/e2e-openai-ollama.sh`로 OpenAI HTTP 입력 표면이 edge service와 node adapter 경로로 수렴하는지 확인하는 보조 검증이다.
- OpenAI-compatible smoke coverage must exercise standard inference, streaming, tools, cancellation, and provider-pool routing without relying on host process or filesystem execution context.
- Generic OpenAI-compatible provider smoke covers standard inference, streaming, caller tools, cancellation, and provider-pool routing without host process or filesystem context. A dedicated execution-preset smoke may use only the approved request-scoped Node workspace executor and must separately prove containment, cleanup, no external tool continuation, and exact caller ingress count.
- OpenAI-compatible provider smoke — `scripts/e2e-openai-vllm.sh``scripts/e2e-openai-lemonade.sh`로 provider API route, request body, expected output을 확인하는 live-dependency 보조 검증이다.
- Long-context admission smoke — `scripts/e2e-long-context-admission-smoke.sh`로 provider pool capacity, queue, long-context slot, Control Plane status snapshot 회복을 live dev provider pool에서 확인하는 보조 검증이다.
- Control Plane-Edge wire smoke — `scripts/e2e-control-plane-edge-wire.sh`로 실제 Control Plane/Edge 프로세스의 Edge hello, 연결 성공, disconnect marker를 확인하는 보조 검증이다.
@ -101,6 +101,7 @@ last_rule_updated_at: 2026-07-31
- Client-Control Plane wire나 client UI를 바꾸면 `make client-test`를 기본 검증으로 기록한다. Web build/deploy 경로를 바꾸면 `make client-build-web`, `scripts/dev/web.sh`, compose build 중 변경 범위에 맞는 경로를 추가 확인한다.
- Control Plane-Edge wire나 Edge outbound connector를 바꾸면 대상 Go 테스트와 함께 `make test-control-plane-edge-wire`를 보조 검증으로 기록한다. status snapshot, node event relay, HTTP `/edges` 조회를 바꾼 경우 해당 동작을 별도로 확인한다.
- OpenAI-compatible route, `/v1/responses`, CLI workspace handoff, provider tunnel, provider auth, tool validation, usage metering을 바꾸면 대상 Go 테스트와 함께 관련 OpenAI smoke(`test-openai-ollama`, `scripts/e2e-openai-cli-workspace.sh`, `scripts/e2e-openai-vllm.sh`, `scripts/e2e-openai-lemonade.sh`) 중 변경 범위에 맞는 것을 보조 검증으로 기록한다.
- Anthropic single-request execution preset과 Node workspace executor를 바꾸면 실제 Claude Code에서 작은 작업을 한 번 요청하고 Edge `/v1/messages` ingress 1회, Gemini plan → ornith-fast work → Gemini review/repair, bounded Node tool lifecycle, 최종 workspace 결과와 terminal 1회를 redacted evidence로 확인한다. generic provider smoke나 caller tool round-trip으로 대체하지 않는다.
- provider pool, model catalog, queue admission, long-context capacity, Control Plane provider snapshot을 바꾸면 `scripts/e2e-long-context-admission-smoke.sh --preflight`와 필요한 `--scenario`를 live 환경 가용성에 따라 실행하고, 실행 불가/실패는 profile별 blocker로 보고한다.
- `iop-edge bootstrap pack`, `make pack-edge`, 내장 artifact server 변경 시 최소 현재 host target build를 실행하고 archive 압축 해제, artifact 폴더 위치, checksum 생성, node bootstrap script가 positional token UX를 유지하는지 확인한다.
- 풀테스트에서는 실제 외부 CLI profile 검증을 필수로 수행한다. 환경, 계정, provider, 원격 endpoint 문제로 호출할 수 없거나 실패한 profile은 누락하지 말고 profile별 실패 또는 blocker로 보고한다.
@ -165,7 +166,7 @@ terminated session default node=test-node
- `make test-e2e`, `scripts/e2e-smoke.sh`, `scripts/e2e-openai-ollama.sh`, `scripts/e2e-control-plane-edge-wire.sh`, 또는 smoke 통과 출력만으로 완료 처리하지 않는다.
- 관련 작업 후 full-cycle 실제 구동을 비용이 크다는 이유만으로 생략하지 않는다.
- task-loop unit/integration test에서 실제 provider CLI 또는 provider session을 시작하지 않는다.
- production dispatcher의 대체 실행 경로를 사용하지 않는다. 활성 작업 실행은 명시적 사용자 요청에 따른 Python dispatcher만 허용한다.
- Agent-Ops task-loop dispatcher의 대체 실행 경로를 사용하지 않는다. 활성 `agent-task`의 worker/review 실행은 명시적 사용자 요청에 따른 Python dispatcher만 허용한다. 이 dispatcher는 Agent-Ops 작업 진행 도구일 뿐 IOP 제품 runtime/API 경로가 아니며, execution preset이나 `/v1/messages` 단일 요청의 내부 stage/tool loop 구현·검증에 사용하거나 참조하지 않는다.
- action item이 없는 plan fixture를 live task-loop worker/review 입력으로 사용하지 않는다.
- state-only test가 실제 runner 호출을 필요로 한다고 가정하지 않는다. fake runner 또는 empty scan으로 state transition을 격리하지 못하면 test plan을 먼저 보완한다.
- provider 실행을 mock하지 않은 채 실제 provider가 우연히 종료·응답했다는 결과를 unit/integration test evidence로 기록하지 않는다.

View file

@ -10,7 +10,7 @@
## 주요 구조
- `apps/node/` — Edge에 연결되는 실행자. 런타임 라우팅, adapter execution, CLI/model runtime 실행, 현재 단계의 로컬 실행 이력 저장을 담당한다.
- `apps/node/` — Edge에 연결되는 실행자. provider adapter execution과 runtime 실행을 담당하며, 승인된 execution preset의 request-scoped workspace/tool 실행은 provider runtime과 분리된 전용 경계로 수용한다.
- `apps/edge/` — 여러 Node를 묶는 백엔드 실행 그룹 컨트롤러. token 기반 등록, node registry, node 설정 전달, routing, stream relay, ops console, OpenAI-compatible/A2A 입력 표면을 담당한다.
- `apps/control-plane/` — 여러 Edge를 연결하고 상태 조회, 설정 변경 요청, 명령 전달, 이벤트 수신, 운영 제어 API 제공을 담당할 Go 기반 제어 서버이다. Edge 데이터의 canonical store가 아니다.
- `apps/client/` — Control Plane을 통해 Edge/Node 운영 상태를 보여주는 Flutter client이다.
@ -44,6 +44,8 @@
## 프로젝트 특화 컨벤션
- Preserve the existing hexagonal structure. Keep host-neutral provider interfaces in `packages/go/execution`, protobuf translation at `apps/node/internal/node`, and adapter/store implementations outside that core.
- Execution preset의 request-scoped workspace/tool 실행은 IOP Edge가 조정하고 선택된 IOP Node가 수행한다. provider `RunRequest`, caller metadata, closed `NodeCommand` 또는 `packages/go/execution`에 이 책임을 섞지 않고 전용 typed Edge-Node request/result 경계로 둔다.
- request-scoped tool executor는 operator가 승인한 workspace root, path containment, bounded process/output/timeout/cancel을 강제한다. 범용 interactive shell, persistent host process, desktop session, scheduler 또는 caller가 고른 임의 Node/path 실행으로 확대하지 않는다.
- 새 node 어댑터는 `runtime.Adapter`를 구현하고 `apps/node/internal/bootstrap/module.go`에서 registry에 등록한다.
- 내부 실행 요청과 상태 저장에서는 `adapter`, `target`, `execution` 용어를 우선한다. `model`은 외부 API 호환이나 legacy placeholder일 때만 허용한다.
- Control Plane은 Node를 직접 연결/스케줄링하지 않고 Edge를 통해 시스템을 제어한다. Edge는 자신의 설정, 로컬 런타임 상태, Node registry의 원본을 소유한다. 여러 Control Plane이 있더라도 Edge는 실질 데이터 이전 없이 다른 Control Plane으로 연결 대상을 옮길 수 있어야 한다.
@ -56,7 +58,7 @@
- Edge/Node 앱 설정 구조 변경 시 `packages/go/config`의 struct/default와 `configs/*.yaml` 예시를 함께 확인한다. Control Plane 로컬 설정 구조 변경 시 `apps/control-plane`의 config loader와 `configs/control-plane.yaml` 예시를 함께 확인한다.
- 테스트는 변경 범위에 맞춰 `go test ./...` 또는 대상 패키지 테스트를 실행한다.
- 사용자 실행 파이프라인에 닿는 작업을 한 경우, 작업 완료 후 `agent-ops/rules/project/domain/testing/rules.md`의 검증 기준을 따른다.
- 활성 `agent-task`의 dry-run, worker/review 실행, blocked retry와 상태 관찰은 사용자의 명시적 실행 요청이 있을 때만 `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py` dispatcher로 수행한다. dispatcher는 이 프로젝트의 production orchestration 경로로 유지한다.
- 활성 `agent-task`의 dry-run, worker/review 실행, blocked retry와 상태 관찰은 사용자의 명시적 실행 요청이 있을 때만 `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py` dispatcher로 수행한다. 이 dispatcher는 Agent-Ops 작업 진행 전용이며 IOP 제품 runtime/API orchestration 경로가 아니다. execution preset, `/v1/messages` 단일 요청, provider stage와 workspace tool loop의 설계·구현·검증에서 dispatcher를 architecture component, caller continuation 또는 test harness로 사용하거나 참조하지 않는다.
- 이 프로젝트에서는 `agent-ops/rules/common/rules-roadmap.md`의 기존 task-group-only 및 `Roadmap Completion` 단건 반영 문구를 legacy 호환 규칙으로 한정한다. 새 `m-*` PLAN/CODE_REVIEW/complete.log는 첫 줄의 `milestone-task=<id>[,<id>...]`로 Milestone Task 기여 범위를 보존한다. 이 metadata나 단건 PASS는 완료 선언이 아니며, `sync-milestone-workstate`가 같은 Milestone task group의 완료 로그를 id별로 집계해 현재 Task 설명·검증·SDD evidence가 모두 충족된 경우에만 체크한다. 기존 `Roadmap Completion`은 first-line metadata가 없는 archive 로그의 호환 evidence로만 취급한다.
- field/bootstrap 작업은 `testing` domain rule을 따르고, 실제 local 환경값이 필요하면 `agent-test/local/rules.md`를 따른다.
- Node, specialized agent, domain agent, Control Plane enrollment 등 사용자가 대상 host에서 실행하는 bootstrap/install command 작업은 `agent-ops/rules/project/domain/testing/rules.md`의 one-line bootstrap UX 기준을 따른다.

View file

@ -18,26 +18,26 @@
IOP(Inference Operations Platform)는 Control Plane - Edge - IOP Node 계층 구조를 기반으로 모델·provider·device의 서빙과 운영을 담당하는 추론 운영 플랫폼을 만든다.
내부 실행 모델은 `adapter + target`을 기준으로 하며, Edge가 로컬 provider 실행 그룹의 상태와 라우팅을 소유하고 Control Plane은 Edge를 통해 IOP 시스템을 관찰하고 제어한다.
IOP는 특정 agent 제품에 종속된 Shell이 아니라, 외부 agent·client·자동화 도구가 추론 API를 통해 소비할 수 있는 범용 추론 운영 엔진이다. execution preset이 여러 model call과 agent tool round-trip을 하나의 논리 요청으로 조정할 수는 있지만, 실제 workspace·terminal 실행 소유권, 독립 automation process, scheduler와 사람 승인 workflow는 IOP 제품 경계에 포함하지 않는다.
IOP는 특정 agent 제품에 종속된 Shell이 아니라, 외부 agent·client·자동화 도구가 추론 API를 통해 소비할 수 있는 범용 추론 운영 엔진이다. 동시에 execution preset이 작업을 수행하는 데 필요한 **request-scoped workspace와 도구 실행은 IOP가 선택한 IOP Node에서 소유**하며, 외부 agent에 후속 model/tool 요청을 위임하지 않는다. 이 요청 단위 실행 책임은 범용 interactive shell, desktop session, 독립 scheduler와 사람 승인 workflow를 IOP에 포함한다는 뜻이 아니다.
로드맵 전반에서 OpenAI-compatible API와 Anthropic-compatible Messages API는 외부 클라이언트의 모델 기반 호출 표면으로, IOP native protocol은 provider 실행·취소·상태·usage와 provider/device/model lifecycle 같은 IOP 고유 운영 기능의 기준으로 둔다.
OpenAI-compatible API는 현재 chat completions baseline을 넘어 Responses API 호환 표면까지 지원해야 한다.
Anthropic-compatible Messages API는 Edge가 직접 제공해 Claude Code를 포함한 client가 별도 agent-client gateway 없이 IOP를 호출하게 하며, Chat-only upstream은 IOP의 protocol bridge로 연결한다.
IOP의 외부 추론 호출 계약은 OpenAI-compatible API 방식을 기본 표면으로 채택하고, model/provider route, 요청 상관관계, usage, 취소·상태처럼 IOP가 소유하는 의미만 제한된 `metadata` 또는 IOP native endpoint의 명시 필드로 전달한다.
IOP native protocol은 proto-socket을 기본으로 하며, HTTP는 OpenAI-compatible/A2A/health/bootstrap처럼 필요한 경계에서만 사용한다.
A2A는 provider-backed 요청을 수용하는 호환 표면으로 유지하며, workflow 의미를 도입하지 않는다.
`iop-agent` 자산의 Chronos 수용 bundle 전달과 IOP의 workspace agent·CLI agent session·terminal·Chronos 연결 surface 제거는 완료됐다. 현재 active delivery는 [IOP 실행 프리셋과 Hot Path](phase/knowledge-tool-optimization-extension/milestones/iop-hot-path-one-shot-execution.md)이며, IOP Node에는 추론 provider 운영 경계만 유지한다.
IOP 내부 라우팅 축은 외부 model을 전체 execution preset에 매핑하고 `direct/light` Hot Path와 논리 `request_id` coordinator를 구축한 뒤, `heavy` Plan/Review, cloud-first preset mode 라우팅과 routing evidence 기반 local selector 전환으로 확장한다.
`iop-agent` 자산의 Chronos 수용 bundle 전달과 IOP의 장기 실행 agent session·desktop terminal·Chronos 연결 surface 제거는 완료됐다. [[route-01] IOP 실행 프리셋과 Hot Path](archive/phase/knowledge-tool-optimization-extension/milestones/iop-hot-path-one-shot-execution.md)는 완료·아카이빙했으며, 현재 active delivery인 [[route-02] IOP 단일 요청 Agent 실행](phase/knowledge-tool-optimization-extension/milestones/iop-owned-single-request-agent-execution.md)에서 execution preset과 Mac IOP Node의 request-scoped workspace/tool runtime을 제품 경계로 도입한다.
IOP 내부 라우팅 축은 Claude Code→Gemini provider bridge 호환을 정리한 뒤, 외부 model을 fixed `light` execution preset에 매핑하고 Claude의 단일 Anthropic Messages 요청 안에서 Gemini plan → ornith-fast work → Gemini review/repair를 끝내는 one-shot coordinator를 구축한다. 이후 `heavy` Plan/Review, cloud-first preset mode 라우팅과 routing evidence 기반 local selector 전환으로 확장한다.
모델 선택, 요청 난이도에 따른 execution mode, 로컬/클라우드 라우팅, 외부 model별 execution preset, token/속도/품질 최적화, 모델 호출 로그와 품질 평가는 IOP 책임으로 둔다. 외부 model 선택이 preset을 고정하고 Edge가 model advisory와 deterministic hard gate를 결합해 allowed mode와 stage binding을 확정하며, Node는 확정된 provider stage를 실행한다. Control Plane은 principal과 IOP token, 사용자별 provider credential slot의 원장을 소유하고 Edge는 principal별 route와 제한된 credential lease를 실행에 사용한다.
모델 선택, 요청 난이도에 따른 execution mode, 로컬/클라우드 라우팅, 외부 model별 execution preset, token/속도/품질 최적화, 모델 호출 로그와 품질 평가는 IOP 책임으로 둔다. 외부 model 선택이 preset을 고정하고 Edge가 model advisory와 deterministic hard gate를 결합해 allowed mode와 stage binding을 확정하며, Node는 확정된 provider stage와 preset이 승인한 request-scoped workspace 도구를 실행한다. Control Plane은 principal과 IOP token, 사용자별 provider credential slot의 원장을 소유하고 Edge는 principal별 route와 제한된 credential lease를 실행에 사용한다.
또한 원격지와 로컬의 Ollama, vLLM, SGLang, Lemonade 같은 추론 엔진은 단순 endpoint가 아니라 provider/device/model 조합으로 관리하고, provider별 lifecycle capability, device 상태, 모델 qualification, 테스트 결과 리포트를 운영 데이터로 축적하는 방향을 목표로 한다.
초기 하이브리드 라우팅은 cloud frontier model을 semantic judge/teacher로 활용해 route evidence를 축적하고, 충분한 품질·규모 gate를 통과하면 RAG 기반 local routing model을 운영 기본으로 점진 전환하되 cloud fallback과 품질 평가를 유지한다.
RAG, context 구성/압축, web search, MCP 정책, tool policy, output validation, retry/fallback은 기본 모델 서빙과 부하 라우팅이 가능해진 뒤 확장한다.
## MVP 경계
1차 MVP는 다중 IOP Node/디바이스의 model group queue와 추가 provider 검증, provider 요청 사용량·실행 로그와 운영 관측, 사용자/토큰/credential 추적, provider catalog와 로컬 디바이스 상태 관찰, request-local 단계 호출과 runtime schema 검증의 최소 실행 모드를 기준으로 둔다. standalone workflow, agent automation, terminal과 desktop delivery는 IOP 제품 범위 밖의 별도 제품 축으로 둔다.
1차 MVP는 다중 IOP Node/디바이스의 model group queue와 추가 provider 검증, provider 요청 사용량·실행 로그와 운영 관측, 사용자/토큰/credential 추적, provider catalog와 로컬 디바이스 상태 관찰, request-local 단계 호출·workspace 도구 실행과 runtime schema 검증의 최소 실행 모드를 기준으로 둔다. request-scoped tool executor는 IOP 범위에 포함하고, standalone 장기 workflow, 범용 interactive terminal과 desktop delivery는 별도 제품 축으로 둔다.
provider/device/model별 qualification report와 모델 lifecycle 관리는 provider serving 경로와 capacity/concurrency 기준선이 잡힌 뒤 `운영 관측과 Provider 관리` Phase의 후반부에서 깊게 구체화한다.
`(2차)`로 분류한 누적 요청 컨텍스트 최적화, 장기 기억/RAG update loop, advisor와 Context Hook, cross-Edge/cloud fallback 고도화는 IOP MVP 이후 스케치로 잠근다. 특정 Node CLI agent, 원격 터널링과 oto 기반 scheduler/CI-CD는 IOP 후속 후보에서 제외한다.
`(2차)`로 분류한 누적 요청 컨텍스트 최적화, 장기 기억/RAG update loop, advisor와 Context Hook, cross-Edge/cloud fallback 고도화는 IOP MVP 이후 스케치로 잠근다. 특정 제품 전용 CLI agent, 범용 원격 terminal과 oto 기반 scheduler/CI-CD는 IOP 후속 후보에서 제외하되 execution preset의 request-scoped Node tool executor는 이 제외에 포함하지 않는다.
새로 추가되는 MVP/2차 Milestone은 모두 사용자 검토 전까지 `구현 잠금: 잠금` 상태를 유지하고, 구현 계획이나 세부 API 확정은 별도 구체화 요청에서 다룬다.
## Phase 흐름
@ -67,6 +67,10 @@ Phase는 실행 순서가 아니라 도메인/책임 영역의 구조적 지도
- 경로: [PHASE.md](archive/phase/routing-policy-model-orchestration/PHASE.md)
- 요약: OpenAI-compatible raw tunnel, provider 연동, mixed provider dispatch와 provider capability 기반 passthrough 계약을 완료했다. 과도하게 결합됐던 과거 Hybrid Routing 스케치는 폐기했지만, IOP Edge의 요청 난이도·실행 형태·local/cloud 판정 책임은 `지식과 도구 최적화 확장` Phase에서 현재 경계에 맞게 복원한다.
- [완료] Automation Runtime과 Bridge 확장
- 경로: [PHASE.md](archive/phase/automation-runtime-bridge/PHASE.md)
- 요약: `iop-agent`의 source·contract·test·config·state·build·document 자산을 repository-neutral Chronos acceptance bundle로 전달하고 IOP의 관련 surface와 의존성을 제거했다. 완료 evidence로 Chronos Roadmap의 외부 잠금을 해제했으며, 이후 Chronos Server/Node의 장기 작업 루프·agent session·terminal 제어는 Chronos가 소유한다. 이 이관은 IOP 후속 execution preset의 bounded request-scoped workspace/tool runtime을 금지하거나 Chronos에 연결한다는 의미가 아니다.
- [진행중] 운영 관측과 Provider 관리
- 경로: [PHASE.md](phase/operational-observability-provider-management/PHASE.md)
- 요약: 사용자/IOP token/provider credential/사용량/로그 추적과 cloud API protocol profile, native Messages, API/CLI/local inference provider catalog, 로컬 디바이스 provider 상태 관리, provider/device/model qualification report와 모델 lifecycle 관리 방향을 MVP 운영 축과 후속 심화 축으로 스케치한다.
@ -75,13 +79,9 @@ Phase는 실행 순서가 아니라 도메인/책임 영역의 구조적 지도
- 경로: [PHASE.md](phase/update-plane-self-update-foundation/PHASE.md)
- 요약: frontend와 Control Plane만 재배포해도 Edge/Node가 안정 업데이트 프로토콜, 로컬 상태 캐시, host-local manager를 통해 스스로 버전 수렴하는 기반을 정리한다.
- [완료] Automation Runtime과 Bridge 확장
- 경로: [PHASE.md](archive/phase/automation-runtime-bridge/PHASE.md)
- 요약: `iop-agent`의 source·contract·test·config·state·build·document 자산을 repository-neutral Chronos acceptance bundle로 전달하고 IOP의 관련 surface와 의존성을 제거했다. 완료 evidence로 Chronos Roadmap의 외부 잠금을 해제했으며, 이후 Chronos Server/Node의 작업 루프·agent·terminal 제어는 Chronos가 소유한다. IOP Node에는 추론 provider 운영 경계만 남기고 Chronos 연결점을 두지 않는다.
- [계획] 지식과 도구 최적화 확장
- [진행중] 지식과 도구 최적화 확장
- 경로: [PHASE.md](phase/knowledge-tool-optimization-extension/PHASE.md)
- 요약: 외부 model에 연결되는 execution preset과 `request_id` coordinator를 만들고 `direct/light` Hot Path, `heavy` Plan/Review, cloud-first preset mode 라우팅으로 확장한다. 운영 evidence가 충분해지면 routing 전용 RAG local selector로 점진 전환하며, repository 장기 기억 RAG와 Advisor/Context Hook은 별도 책임으로 유지한다.
- 요약: Claude Code용 Gemini Chat bridge 호환을 정리한 뒤, fixed `light` execution preset과 Claude 단일 요청 안에서 Mac IOP Node가 workspace 도구를 실행하는 Gemini plan → ornith-fast work → Gemini review/repair를 구현한다. 이후 `heavy` Plan/Review와 cloud-first preset mode 라우팅으로 확장하고 routing 전용 RAG local selector로 점진 전환한다.
- [스케치] Personal Edge 패키징과 배포 프로파일
- 경로: [PHASE.md](phase/personal-edge-packaging-deployment/PHASE.md)

View file

@ -2,8 +2,8 @@
## 위치
- Roadmap: [ROADMAP.md](../../../ROADMAP.md)
- Phase: [PHASE.md](../PHASE.md)
- Roadmap: [ROADMAP.md](../../../../ROADMAP.md)
- Phase: [PHASE.md](../../../../phase/knowledge-tool-optimization-extension/PHASE.md)
- SDD: [SDD.md](../../../sdd/knowledge-tool-optimization-extension/iop-hot-path-one-shot-execution/SDD.md)
## 목표
@ -13,9 +13,11 @@
초기 Hot Path preset은 `direct`와 lightweight Plan/Review인 `light`를 제공한다. `direct`는 고성능·high-thinking·tool 사용도 가능한 Plan/Review 없는 경로이고, `light`는 cloud plan, local agent work, cloud review와 defect repair를 하나의 논리적 `request_id`로 연결한다.
이 마일스톤은 후속 `heavy` Plan/Review와 cloud-first 하이브리드 라우팅, RAG local router가 같은 preset·mode·decision contract를 확장할 수 있는 첫 vertical slice다.
이 구현은 execution preset, logical request coordinator, direct/light stage, endpoint codec과 관측 기반을 완료했지만 caller tool continuation을 사용하는 과도기 구조였다. 이는 “workspace 도구 실행은 외부 agent가 소유한다”는 제품 원칙이 아니며 최종 one-shot acceptance도 아니다. 사용자가 확정한 Claude 요청 정확히 1회와 IOP-owned Mac Node workspace/tool loop는 별도 후속 [[route-02] IOP 단일 요청 Agent 실행](../../../../phase/knowledge-tool-optimization-extension/milestones/iop-owned-single-request-agent-execution.md)이 소유한다.
## 상태
[진행중]
[완료]
## 구현 잠금
@ -60,14 +62,14 @@
- `direct``.iop/job/<request_id>/`를 만들지 않는다.
- `.iop/job/<request_id>/`는 해당 logical request의 reserved namespace다. write binding이 missing parent 생성을 보장하지 않으면 최초 `light` tool turn은 이 directory를 준비하는 정확히 하나의 tool call로 제한한다. 그 뒤 Plan/Review 생성 turn은 정확한 pair tool call만 허용하고 같은 응답의 다른 작업 tool call이나 임의 sibling path는 거부한다.
- pair tool result는 다음 continuation frontier 하나에서 순서와 무관하게 각각 한 번만 소비하며, 둘 다 성공할 때만 local stage를 시작한다. 누락·중복·unknown result는 표준 validation error다.
- IOP Edge/Node는 agent workspace를 직접 소유하지 않는다. 실제 file/tool 실행은 Claude/Pi 같은 호출 agent가 자신에게 전달된 tool call을 수행한다.
- 구현된 route-01 경로는 caller tool continuation으로 workspace 작업을 왕복한다. 이 동작은 당시 구현 경계의 기록일 뿐 IOP의 workspace ownership 원칙이 아니며, route-02에서 IOP Edge/Mac Node 내부 실행으로 대체한다.
### 3. `direct``light` 실행 흐름
- 최초 cloud selector/planner stage는 사용자 요청과 preset control을 한 번에 받아 `direct` 응답·tool 작업을 시작하거나, `light`를 선택해 artifact 작성을 수행한다. 현재 Hot Path에서 mode 판정만을 위한 별도 model stage를 추가하지 않는다. missing parent 준비가 필요하면 `light`를 고정한 채 같은 selector/planner stage의 정상 tool continuation으로 처리하고 mode를 다시 판정하지 않는다.
- `direct`는 같은 logical request에서 직접 응답하거나 agent tool을 사용해 작업하고, Plan/Review stage 없이 완료한다. 빠르거나 약한 model만을 뜻하지 않는다.
- `light`는 최초 stage가 낸 두 artifact tool result가 성공한 뒤 local worker로 전환한다.
- local prompt는 immutable 사용자 작업과 `plan.md`·paired `review.md` 경로를 명시한다. local model은 두 파일을 agent tool로 읽고 정상 tool round-trip을 반복하며 작업·검증한 뒤 completion candidate를 낸다. IOP가 workspace 파일을 대신 읽어 prompt에 복제하지 않는다.
- local prompt는 immutable 사용자 작업과 `plan.md`·paired `review.md` 경로를 명시한다. route-01 구현에서는 local model이 caller tool round-trip으로 두 파일을 읽고 작업·검증한 뒤 completion candidate를 냈다.
- Stream Evidence Gate가 local completion terminal을 판정하면 cloud reviewer로 전환한다. reviewer는 필요한 inspection tool round-trip 뒤 `review.md`를 채운다.
- reviewer는 immutable 사용자 작업, artifact path와 committed local 결과 correlation을 입력으로 받는다. review write tool result가 돌아오면 같은 cloud `review` stage/model이 `review.md`를 읽는다. pass이면 cleanup으로 진행하고, defect이면 agent와 정상 tool round-trip으로 수정·검증한 뒤 cleanup으로 진행한다. Edge가 workspace file 내용을 직접 읽거나 review text를 파싱해 verdict를 재판정하지 않는다.
- 현재 `light`는 review transition을 한 번만 수행한다. repair 완료 뒤 두 번째 review loop를 만들지 않으며, repair stage의 정상 tool turn 수를 별도 “수정 횟수” 성공 상태로 제한하지 않는다.
@ -104,32 +106,33 @@
### Epic: [stream-protocol] Stream과 Agent Protocol
- [ ] [terminal-control] Stream Evidence Gate를 terminal-only hold로 재사용하고 cross-stage response envelope, block/tool id, usage/output cap을 endpoint codec에서 일관되게 합성해 HTTP turn terminal과 logical completion의 exactly-once 경계를 분리한다.
- [ ] [anthropic-gate] Claude가 사용하는 native Anthropic `/v1/messages` streaming에 normalized event codec, terminal gate와 request continuation correlation을 연결한다.
- [ ] [chat-gate] Pi가 사용하는 OpenAI `/v1/chat/completions` streaming에서 tool call/result와 stage 전이를 동일한 `request_id`로 연결한다.
- [ ] [error-cancel] endpoint별 표준 오류, timeout, cancellation과 length terminal을 유지하고 custom partial-success 상태를 만들지 않는다.
- [x] [terminal-control] Stream Evidence Gate를 terminal-only hold로 재사용하고 cross-stage response envelope, block/tool id, usage/output cap을 endpoint codec에서 일관되게 합성해 HTTP turn terminal과 logical completion의 exactly-once 경계를 분리한다.
- [x] [anthropic-gate] Claude가 사용하는 native Anthropic `/v1/messages` streaming에 normalized event codec, terminal gate와 request continuation correlation을 연결한다.
- [x] [chat-gate] Pi가 사용하는 OpenAI `/v1/chat/completions` streaming에서 tool call/result와 stage 전이를 동일한 `request_id`로 연결한다.
- [x] [error-cancel] endpoint별 표준 오류, timeout, cancellation과 length terminal을 유지하고 custom partial-success 상태를 만들지 않는다.
### Epic: [quality-ops] 검증과 운영
- [x] [preset-validation] model/preset one-of, stage route authorization, mode handler, declarative workspace tool schema·argument·result·containment binding, reserved path와 option 범위를 load/admission에서 fail-closed 검증한다.
- [ ] [route-observability] request/preset/mode/stage/attempt identity, route 근거, timing과 terminal outcome을 raw prompt·output·credential 없이 관측한다.
- [ ] [hot-smoke] Claude Messages와 Pi Chat에서 direct, light pass, defect repair, write unavailable, timeout·cancel과 cleanup을 실제 streaming smoke로 검증한다.
- [x] [route-observability] request/preset/mode/stage/attempt identity, route 근거, timing과 terminal outcome을 raw prompt·output·credential 없이 관측한다.
- [x] [hot-smoke] 기존 Claude/Pi caller-continuation smoke는 최종 제품 구조를 검증하지 않으므로 수행하지 않고 종료 범위에서 제외했다. 실제 검증은 route-02의 Claude 단일 POST smoke로 이관했다.
## 완료 리뷰
- 상태: 없음
- 요청일: 없음
- 완료 근거: 구현 가능한 계획과 승인된 SDD로 승격했으며 기능 Task와 검증 evidence는 아직 완료되지 않았다.
- 상태: 통과
- 요청일: 2026-08-06
- 완료 근거: execution preset/config generation, request coordinator, direct/light flow, artifact pair, endpoint terminal/error, cleanup과 raw-free observability 구현 및 각 `agent-task/m-iop-hot-path-one-shot-execution/**/complete.log` 근거를 완료했다. 사용자가 기존 route-01을 완료·아카이빙하고 최종 one-shot 구조를 route-02로 분리하도록 승인했다.
- 검토 항목: 없음
- 리뷰 코멘트: 없음
- 리뷰 코멘트: 기존 Claude/Pi cross-call live smoke는 사용자가 확정한 제품 구조가 아니므로 완료 조건에서 제외했다. route-01 산출물은 route-02가 재사용할 구현 기반이며 exact single-request 제품 acceptance로 해석하지 않는다.
## 범위 제외
- `heavy`의 장기 Plan/Review lifecycle, 재계획, 여러 review cycle와 사람 승인
- Claude→IOP `/v1/messages` POST 정확히 1회 안에서 IOP Edge/Mac Node가 workspace tool loop와 Gemini plan → ornith-fast work → Gemini review/repair를 완료하는 구조
- cloud evidence를 학습 corpus로 승격하거나 RAG local router를 운영하는 기능
- 범용 DAG/workflow/plugin engine과 미래 mode를 위한 manifest·revision·빈 디렉터리
- target agent별 hook/adapter 설치, Claude/Pi 프로세스 패치 또는 agent update 수명주기 추적
- target agent 또는 외부 workflow 제품의 process/state/contract, terminal/PTY/workspace runtime을 IOP에 포함하거나 연결하는 작업
- 범용 interactive terminal/PTY, desktop session, 독립 scheduler와 장기 workflow
- `/v1/responses`, A2A와 IOP native protocol의 execution preset 지원
- cross-Edge state replication, Edge restart 뒤 continuation과 durable resume
- provider 설치, model 다운로드, hardware qualification과 credential 관리
@ -137,13 +140,14 @@
## 작업 컨텍스트
- 관련 경로: `apps/edge/internal/openai`, `apps/edge/internal/service`, `apps/edge/internal/authprojection`, `apps/edge/internal/controlplane`, `packages/go/config`, `packages/go/streamgate`, `configs/edge.yaml`
- 관련 계약: [OpenAI-Compatible API Contract](../../../../agent-contract/outer/openai-compatible-api.md), [Anthropic-Compatible Messages API Contract](../../../../agent-contract/outer/anthropic-compatible-api.md), [Edge Config And Runtime Refresh Contract](../../../../agent-contract/inner/edge-config-runtime-refresh.md), [Control Plane-Edge Wire Contract](../../../../agent-contract/inner/control-plane-edge-wire.md), [Edge-Node Runtime Wire Contract](../../../../agent-contract/inner/edge-node-runtime-wire.md)
- 현재 구현 기준: [Stream Evidence Gate 구현 스펙](../../../../agent-spec/runtime/stream-evidence-gate.md)
- 관련 계약: [OpenAI-Compatible API Contract](../../../../../agent-contract/outer/openai-compatible-api.md), [Anthropic-Compatible Messages API Contract](../../../../../agent-contract/outer/anthropic-compatible-api.md), [Edge Config And Runtime Refresh Contract](../../../../../agent-contract/inner/edge-config-runtime-refresh.md), [Control Plane-Edge Wire Contract](../../../../../agent-contract/inner/control-plane-edge-wire.md), [Edge-Node Runtime Wire Contract](../../../../../agent-contract/inner/edge-node-runtime-wire.md)
- 현재 구현 기준: [Stream Evidence Gate 구현 스펙](../../../../../agent-spec/runtime/stream-evidence-gate.md)
- 표준선(선택): preset stage의 model reference는 기존 canonical model/provider resolution을 재사용하며 provider id나 target 의미를 core에 하드코딩하지 않는다.
- 표준선(선택): `request_id`는 하나의 사용자 작업 identity이고 각 HTTP call의 endpoint request id, tool call id와 provider session/attempt id는 그 하위 correlation이다.
- 표준선(선택): agent tool round-trip 때문에 개별 HTTP stream은 endpoint-native terminal로 닫힐 수 있다. “하나의 model”은 하나의 논리 요청과 외부 identity·오류 의미를 뜻하며 하나의 TCP/SSE 연결을 강제하지 않는다.
- 표준선(선택): workspace 변경은 IOP가 생성한 정상 tool call을 외부 agent가 실행하며 IOP는 agent/workflow process나 workspace runtime을 소유하지 않는다.
- 선행 작업: [Stream Evidence Gate Core](../../../archive/phase/knowledge-tool-optimization-extension/milestones/stream-evidence-gate-core.md)
- 후속 작업: [Heavy Plan/Review 실행과 검증 MVP](knowledge-tool-validation-optimization.md), [Execution Preset 하이브리드 Mode 라우팅](openai-compatible-hybrid-request-execution-routing.md), [RAG 기반 Local Routing Model 운영 전환](rag-local-routing-model-operations.md)
- 큐 배치: `[route-01]` 1번이다. `[output-01]`과의 동시 변경은 차단한다.
- 구현 당시 경계: caller tool round-trip 때문에 개별 HTTP stream이 endpoint-native terminal로 닫히고 다음 ingress가 같은 logical request를 이어갈 수 있었다. 이 경계는 route-01의 과거 구현 사실이며 최종 one-shot 정의가 아니다.
- 후속 제품 표준선: [[route-02] IOP 단일 요청 Agent 실행](../../../../phase/knowledge-tool-optimization-extension/milestones/iop-owned-single-request-agent-execution.md)은 실제 Claude→IOP POST 1회 안에서 IOP Edge/Mac Node가 request-scoped workspace/tool execution을 소유한다.
- 선행 작업: [Stream Evidence Gate Core](stream-evidence-gate-core.md)
- 후속 작업: [[route-02] IOP 단일 요청 Agent 실행](../../../../phase/knowledge-tool-optimization-extension/milestones/iop-owned-single-request-agent-execution.md), [Heavy Plan/Review 실행과 검증 MVP](../../../../phase/knowledge-tool-optimization-extension/milestones/knowledge-tool-validation-optimization.md), [Execution Preset 하이브리드 Mode 라우팅](../../../../phase/knowledge-tool-optimization-extension/milestones/openai-compatible-hybrid-request-execution-routing.md), [RAG 기반 Local Routing Model 운영 전환](../../../../phase/knowledge-tool-optimization-extension/milestones/rag-local-routing-model-operations.md)
- 종료 정리: 기존 이름과 `[route-01]` identity를 유지해 완료·아카이빙했으며 활성 실행 큐에서는 제거했다.
- 실행 순서와 차단 관계: [전역 마일스톤 실행 순서](../../../../priority-queue.md)
- 확인 필요: 없음

View file

@ -2,8 +2,8 @@
## 위치
- Milestone: [IOP 실행 프리셋과 Hot Path](../../../phase/knowledge-tool-optimization-extension/milestones/iop-hot-path-one-shot-execution.md)
- Phase: [PHASE.md](../../../phase/knowledge-tool-optimization-extension/PHASE.md)
- Milestone: [IOP 실행 프리셋과 Hot Path](../../../../archive/phase/knowledge-tool-optimization-extension/milestones/iop-hot-path-one-shot-execution.md)
- Phase: [PHASE.md](../../../../phase/knowledge-tool-optimization-extension/PHASE.md)
## 상태
@ -19,11 +19,13 @@
- [x] [D03] mode key는 확장 가능하게 두되 현재 구현 handler는 `direct``light`로 제한한다.
- [x] [D04] `request_id`를 여러 HTTP/tool/provider 호출을 묶는 사용자 작업 identity로 사용한다.
- [x] [D05] plan-bearing route는 `.iop/job/<request_id>/plan.md``review.md` pair만 만든다.
- [x] [D06] workspace artifact는 caller tool schema에 대한 IOP의 declarative binding과 agent의 기존 workspace-capable tool call로 준비·생성·갱신·삭제하며 agent adapter를 설치하지 않는다.
- [x] [D06] route-01 구현은 caller tool schema에 대한 declarative binding과 다음 HTTP continuation으로 workspace artifact를 왕복했다. 이는 과거 구현 경계이지 “workspace 도구 실행은 외부 agent가 소유한다”는 제품 원칙이 아니다.
- [x] [D07] routing부터 repair까지 의미 있는 모든 stage 출력을 사용자 stream에 표시한다.
- [x] [D08] IOP는 하나의 model처럼 endpoint 표준 성공·오류·취소·length 의미를 유지한다.
- [x] [D09] 현재 target protocol은 Claude native Messages streaming과 Pi Chat Completions streaming이다.
- [x] [D10] target agent나 외부 workflow 제품의 runtime·config·contract를 IOP 실행 의존성으로 연결하지 않는다.
- [x] [D11] 최종 one-shot은 실제 Claude→IOP `/v1/messages` POST 1회이며, request-scoped workspace/tool execution은 IOP Edge와 승인된 Mac IOP Node가 소유한다.
- [x] [D12] D11의 구조와 Gemini 3.6 Flash high plan → ornith-fast work → Gemini 3.6 Flash high review/repair acceptance는 별도 [IOP 단일 요청 Agent 실행 SDD](../../../../sdd/knowledge-tool-optimization-extension/iop-owned-single-request-agent-execution/SDD.md)가 소유한다.
## 문제 / 비목표
@ -32,7 +34,7 @@
- `heavy` Plan/Review handler, 재계획, 반복 review와 사람 승인
- 범용 DAG/workflow/plugin runtime
- target agent별 hook/adapter 설치나 agent process 수정
- target agent나 외부 workflow 제품의 runtime, terminal/PTY 또는 workspace owner를 IOP에 도입
- 범용 interactive terminal/PTY, desktop session, 독립 scheduler 또는 장기 agent process를 IOP에 도입
- `/v1/responses`, A2A와 IOP native protocol 지원
- route evidence 학습, RAG local router와 production rollout
@ -40,13 +42,13 @@
| 영역 | 기준 | 메모 |
|------|------|------|
| Roadmap | [Milestone 문서](../../../phase/knowledge-tool-optimization-extension/milestones/iop-hot-path-one-shot-execution.md) | 범위, Task와 완료 상태 원장 |
| Roadmap | [Milestone 문서](../../../../archive/phase/knowledge-tool-optimization-extension/milestones/iop-hot-path-one-shot-execution.md) | 범위, Task와 완료 상태 원장 |
| Config | `packages/go/config`, `configs/edge.yaml` | model/preset one-of, stage canonical model/resource reference와 load validation |
| Edge Runtime | `apps/edge/internal/openai`, `apps/edge/internal/service` | endpoint codec, logical request coordinator, route/stage dispatch |
| Stream Runtime | `packages/go/streamgate` | normalized event, release queue, terminal hold와 exactly-once commit |
| Current Spec | [Stream Evidence Gate 구현 스펙](../../../../agent-spec/runtime/stream-evidence-gate.md) | 이미 구현된 request-local gate와 이번 cross-call coordinator의 경계 |
| API Contract | [OpenAI-Compatible API](../../../../agent-contract/outer/openai-compatible-api.md), [Anthropic-Compatible Messages API](../../../../agent-contract/outer/anthropic-compatible-api.md) | 외부 success/error/tool/stream terminal 원문 |
| Runtime Contract | [Edge Config And Runtime Refresh](../../../../agent-contract/inner/edge-config-runtime-refresh.md), [Control Plane-Edge Wire](../../../../agent-contract/inner/control-plane-edge-wire.md), [Edge-Node Runtime Wire](../../../../agent-contract/inner/edge-node-runtime-wire.md) | config generation, managed principal projection/lease와 stage별 provider dispatch 원문 |
| Current Spec | [Stream Evidence Gate 구현 스펙](../../../../../agent-spec/runtime/stream-evidence-gate.md) | 이미 구현된 request-local gate와 이번 cross-call coordinator의 경계 |
| API Contract | [OpenAI-Compatible API](../../../../../agent-contract/outer/openai-compatible-api.md), [Anthropic-Compatible Messages API](../../../../../agent-contract/outer/anthropic-compatible-api.md) | 외부 success/error/tool/stream terminal 원문 |
| Runtime Contract | [Edge Config And Runtime Refresh](../../../../../agent-contract/inner/edge-config-runtime-refresh.md), [Control Plane-Edge Wire](../../../../../agent-contract/inner/control-plane-edge-wire.md), [Edge-Node Runtime Wire](../../../../../agent-contract/inner/edge-node-runtime-wire.md) | config generation, managed principal projection/lease와 stage별 provider dispatch 원문 |
| User Decision | D01-D10 | 본 설계 대화에서 확정, 추가 사용자 결정 없음 |
## State Machine
@ -83,7 +85,7 @@ State invariant:
## Interface Contract
- 계약 원문: [OpenAI-Compatible API](../../../../agent-contract/outer/openai-compatible-api.md), [Anthropic-Compatible Messages API](../../../../agent-contract/outer/anthropic-compatible-api.md), [Edge Config And Runtime Refresh](../../../../agent-contract/inner/edge-config-runtime-refresh.md), [Control Plane-Edge Wire](../../../../agent-contract/inner/control-plane-edge-wire.md), [Edge-Node Runtime Wire](../../../../agent-contract/inner/edge-node-runtime-wire.md)
- 계약 원문: [OpenAI-Compatible API](../../../../../agent-contract/outer/openai-compatible-api.md), [Anthropic-Compatible Messages API](../../../../../agent-contract/outer/anthropic-compatible-api.md), [Edge Config And Runtime Refresh](../../../../../agent-contract/inner/edge-config-runtime-refresh.md), [Control Plane-Edge Wire](../../../../../agent-contract/inner/control-plane-edge-wire.md), [Edge-Node Runtime Wire](../../../../../agent-contract/inner/edge-node-runtime-wire.md)
- config 입력:
- `models[].id`: 외부에 노출되는 model identity다.
- `models[].execution_preset`: provider mapping과 상호 배타적인 virtual preset reference다. 이 entry 자체에 provider credential slot을 부여하지 않는다.
@ -103,7 +105,7 @@ State invariant:
- virtual preset authorization을 위해 새 projection message나 credential slot을 만들지 않는다. stage dispatch마다 해석된 existing route의 current revision/credential binding과 lease를 재검증하고 revoke/expiry를 다른 slot·route·mode로 우회하지 않는다.
- legacy mode에서도 stage model/resource reference는 기존 model catalog/provider resolution을 거치며 preset config가 raw caller credential이나 provider target을 삽입하지 않는다.
- current `direct/light` mode candidate는 selector 자연어나 숨은 marker를 파싱하지 않고 output shape으로 판정한다. issued request path의 정확한 prepare/pair control tool call이면 `light`, reserved artifact control call이 없는 정상 content/reasoning/일반 작업 tool call이면 `direct` 후보이고, partial pair·충돌 shape·다른 reserved path는 validation error다. Edge가 preset allowlist와 capability gate를 적용해 최종 확정한다.
- plan-bearing internal stage에는 IOP canonical artifact operation schema를 제공한다. Edge는 model의 canonical call을 선택된 caller tool name/arguments와 public tool call id로 변환해 stream에 내보내고, continuation의 endpoint-native result를 original stage call로 역매핑한다. 일반 작업 tool call은 caller schema를 그대로 사용하며 IOP가 실제 tool이나 workspace operation을 실행하지 않는다.
- plan-bearing internal stage에는 IOP canonical artifact operation schema를 제공한다. route-01 Edge는 model의 canonical call을 선택된 caller tool name/arguments와 public tool call id로 변환해 stream에 내보내고, continuation의 endpoint-native result를 original stage call로 역매핑했다. 이 과도기 동작은 route-02의 IOP-owned internal tool loop로 대체 대상이다.
- plan-bearing mode admission은 declared tools 중 workspace file write/read/delete와, write가 missing parent를 만들지 못할 때 directory prepare를 수행할 role binding을 요구한다. Edge는 Claude/Pi 이름이 아니라 실제 tool name과 JSON schema로 request-local ordered alternative를 선택해 해당 binding을 logical request에 고정하고, 맞는 조합이 없거나 deterministic result success/error를 판별할 수 없거나 continuation에서 schema가 바뀌면 provider dispatch 전에 오류로 닫는다.
- structured tool binding은 workspace-relative path와 no-escape 의미를 보장해야 한다. canonical operation이 command tool에 바인딩되면 Edge가 issued relative path와 write content로 command를 결정적으로 합성하고 shell-safe content encoding, canonical cwd containment, symlink escape 거부와 exact success receipt를 적용한다. model이 임의 artifact command/path를 만들거나 opaque command result를 성공으로 확정하게 하지 않는다.
- 내부 identity:
@ -115,7 +117,7 @@ State invariant:
- `attempt_id`, provider session/run id와 tool call id는 `request_id + stage_id` 하위 correlation이다.
- stage 입력:
- selector/planner는 immutable caller request/history, caller tool schema, preset control과 issued artifact path를 받는다.
- `local`은 같은 immutable 사용자 작업과 committed selector/planner 결과, issued `plan.md`·`review.md` 경로를 받고 두 파일을 agent tool로 읽은 뒤 작업·검증하도록 지시받는다. IOP가 파일 내용을 대신 읽어 prompt에 복제하지 않는다.
- `local`은 같은 immutable 사용자 작업과 committed selector/planner 결과, issued `plan.md`·`review.md` 경로를 받고 route-01 caller tool continuation으로 두 파일을 읽은 뒤 작업·검증하도록 지시받는다.
- `review`는 immutable 사용자 작업, issued artifact path와 committed local completion/output correlation을 받고 필요한 workspace inspection, `review.md` 작성, 같은 stage의 review read와 pass 또는 defect repair를 수행한다.
- stage input builder는 이전 internal control prompt, credential/provider target과 다른 principal/request의 transcript를 포함하지 않는다. active request 중 새 user instruction이 섞인 continuation은 tool-result frontier로 수락하지 않는다.
- artifact 출력:
@ -123,7 +125,7 @@ State invariant:
- 파일: `plan.md`, `review.md`만 사용한다.
- selected write binding이 missing parent 생성을 보장하지 않으면 최초 `light` tool turn에는 issued request directory를 준비하는 정확히 하나의 tool call만 허용한다. 그 성공 result 뒤 같은 selector/planner stage의 plan-authoring subphase를 재개한다.
- Plan/Review 생성 tool turn은 issued `request_id`의 두 파일을 만드는 expected set만 허용한다. 같은 응답의 다른 작업 tool call, 다른 request id, sibling file과 path traversal은 release하지 않고 표준 validation error로 닫는다.
- Edge는 declarative binding으로 tool argument의 reserved relative suffix와 content field를 검증하고, 실제 workspace root 해석·권한·실행은 caller agent가 소유한다.
- route-01 Edge는 declarative binding으로 tool argument의 reserved relative suffix와 content field를 검증하고 caller continuation으로 실행 결과를 받았다. 이 문장은 제품 ownership 원칙이 아니며 route-02에서는 승인된 Mac IOP Node가 workspace root 해석·권한·실행을 소유한다.
- 두 create/write tool result는 바로 다음 continuation frontier에 임의 순서로 함께 있어야 한다. pinned binding의 endpoint error flag, result matcher 또는 Edge-issued exact receipt로 둘 다 성공이 확정될 때만 local stage를 dispatch한다. opaque result, 일부 생성이나 실패는 local로 넘기지 않고 가능한 범위에서 cleanup을 시도한다.
- stream 출력:
- routing, plan, local work/completion candidate, review, defect, repair와 final의 content/reasoning/tool call을 endpoint-native 순서로 release한다. terminal-only hold가 이 delta를 숨기거나 전체 stage를 buffer하지 않는다.
@ -169,7 +171,7 @@ State invariant:
| S13 | `error-cancel` | write 불가, timeout, provider error, context error, cancel과 output cap | 각 경로가 terminal | endpoint 표준 error/cancel/length 의미만 반환하고 partial-success 상태가 없다. |
| S14 | `preset-validation` | dangling/unauthorized stage route, one-of 위반, unsupported mode 또는 workspace tool schema/path/result/containment contract | load/admission을 수행 | credential/provider dispatch나 reserved namespace tool release 전에 validation/auth error로 거부된다. |
| S15 | `route-observability` | direct/light와 실패 요청 | metric/log를 수집 | raw prompt/output/credential 없이 request/preset/mode/stage/attempt와 outcome을 연결한다. |
| S16 | `hot-smoke` | 실제 Claude와 Pi agent가 writable test workspace 사용 | direct, pass, repair, failure/cancel smoke 실행 | 두 protocol에서 visible stage output, artifact lifecycle와 표준 terminal을 재현한다. |
| S16 | `hot-smoke` | route-01의 Claude/Pi caller-continuation smoke | 사용자 종료 결정 검토 | 최종 제품 구조를 검증하지 않으므로 수행하지 않고 route-02의 actual Claude single-POST smoke로 이관한다. |
## Evidence Map
@ -190,7 +192,7 @@ State invariant:
| S13 | endpoint별 error/cancel/length table test | `agent-task/m-iop-hot-path-one-shot-execution/error-cancel/` | `error-cancel` no-custom-status evidence |
| S14 | invalid config/route authorization/tool-schema/result matcher/reserved-path/containment admission table test | `agent-task/m-iop-hot-path-one-shot-execution/preset-validation/` | `preset-validation` fail-closed evidence |
| S15 | raw-free log/metric field allowlist test | `agent-task/m-iop-hot-path-one-shot-execution/route-observability/` | `route-observability` redaction evidence |
| S16 | actual Claude/Pi streaming smoke log와 workspace before/after evidence | `agent-task/m-iop-hot-path-one-shot-execution/hot-smoke/` | `hot-smoke` 양 protocol 최종 검증 |
| S16 | 사용자 종료 결정과 route-02 `claude-smoke` 연결 | route-01 완료 리뷰와 `agent-task/m-iop-owned-single-request-agent-execution/claude-smoke/` | 기존 cross-call smoke 미수행 공개와 single-POST 최종 검증 이관 |
공통 완료 검증은 최소 `go test -race -count=1 ./packages/go/streamgate ./packages/go/config ./apps/edge/internal/openai ./apps/edge/internal/service``git diff --check`를 포함한다. 실제 provider/agent smoke는 credential과 writable test workspace를 갖춘 환경에서 별도 실행 evidence로 남긴다.
`agent-task/m-iop-hot-path-one-shot-execution/<task-id>/complete.log`는 동일한 Milestone Task id와 최종 검증 결과를 기록하고, 완료 리뷰에서 S01-S16 Evidence Map과 대조한다.
@ -210,9 +212,11 @@ State invariant:
## 사용자 리뷰 이력
- 2026-08-02: execution preset, direct/light 현재 범위, heavy/추가 mode 확장, request identity, workspace artifact, visible streaming, 오류·취소와 외부 workflow 비의존 경계를 대화에서 확정했다.
- 2026-08-05: 사용자가 logical request와 caller continuation을 one-shot으로 보는 해석을 철회했다. Claude의 실제 POST 1회, IOP/Mac Node-owned workspace/tool loop, Gemini high plan → ornith-fast work → Gemini high review/repair를 최종 방향으로 확정했다.
- 2026-08-06: 기존 route-01은 구현된 기반까지 완료·아카이빙하고, 최종 구조는 별도 route-02로 분리하도록 확정했다.
## 작업 컨텍스트
- 표준선: 기존 top-level model catalog/provider pool, endpoint-native tool call/result, Stream Evidence Gate의 normalized event·terminal gate·exactly-once commit을 재사용한다.
- 구현 순서: config/preset catalog → request coordinator → direct → workspace prepare/Plan·Review pair → local/review/repair → protocol gate → cleanup/observability/smoke 순이다.
- 후속 SDD: [Heavy Plan/Review 실행과 검증 MVP](../../../phase/knowledge-tool-optimization-extension/milestones/knowledge-tool-validation-optimization.md), [Execution Preset 하이브리드 Mode 라우팅](../../../phase/knowledge-tool-optimization-extension/milestones/openai-compatible-hybrid-request-execution-routing.md), [RAG 기반 Local Routing Model 운영 전환](../../../phase/knowledge-tool-optimization-extension/milestones/rag-local-routing-model-operations.md)
- 후속 SDD: [IOP 단일 요청 Agent 실행](../../../../sdd/knowledge-tool-optimization-extension/iop-owned-single-request-agent-execution/SDD.md), [Heavy Plan/Review 실행과 검증 MVP](../../../../phase/knowledge-tool-optimization-extension/milestones/knowledge-tool-validation-optimization.md), [Execution Preset 하이브리드 Mode 라우팅](../../../../phase/knowledge-tool-optimization-extension/milestones/openai-compatible-hybrid-request-execution-routing.md), [RAG 기반 Local Routing Model 운영 전환](../../../../phase/knowledge-tool-optimization-extension/milestones/rag-local-routing-model-operations.md)

View file

@ -2,13 +2,13 @@
## 상태
[계획]
[진행중]
## 목표
Ollama serving 경로와 운영 기반이 안정화된 뒤, execution preset, 단계 호출, tool/schema 강제, output validation, retry/fallback과 누적 요청 컨텍스트 구성을 IOP의 추론 최적화 계층으로 확장한다.
첫 vertical slice는 외부 model을 fused selector/planner·허용 mode·downstream ordered stage/model/options 전체를 소유하는 execution preset에 매핑하고, 하나의 `request_id` 아래 `direct` 또는 cloud plan → local agent work → cloud review/repair인 `light` Hot Path를 Claude/Pi streaming에 구현한다.
그 다음 lightweight Plan/Review를 장기 작업에 맞는 `heavy` mode로 확장하고, Edge가 외부 model에 매핑된 preset의 허용 mode 중 요청 난이도·기능·예산에 맞는 실행 경로를 고르는 cloud-first 하이브리드 라우팅으로 연결한다.
첫 vertical slice는 Claude Code의 Anthropic Messages request를 Gemini OpenAI Chat provider로 안전하게 변환하는 protocol bridge 호환을 정리한다. 이 기반 위에서 외부 model을 fixed `light` execution preset에 매핑하고 Claude의 Anthropic Messages 요청 정확히 1회를 유지한 채 Mac IOP Node가 request-scoped workspace와 도구 실행을 소유하며 Gemini plan → ornith-fast work → Gemini review/repair를 하나의 model 실행처럼 완료한다.
그 다음 단일 요청 lightweight Plan/Review를 장기 작업에 맞는 `heavy` mode로 확장하고, Edge가 외부 model에 매핑된 preset의 허용 mode 중 요청 난이도·기능·예산에 맞는 실행 경로를 고르는 cloud-first 하이브리드 라우팅으로 연결한다.
cloud-first route evidence가 충분히 쌓이면 동일한 mode decision contract를 쓰는 RAG 기반 local routing model을 shadow/canary로 검증해 운영 기본 경로로 점진 전환한다.
caller-neutral 누적 요청 컨텍스트 최적화, repository 장기 기억 RAG, advisor와 Context Hook은 routing evidence RAG와 서로 다른 후속 기능으로 분리한다.
이 Phase는 특정 Agent Shell에 종속되지 않고 OpenAI-compatible, A2A, IOP native protocol 중 맞는 표면에서 공통 최적화 책임을 제공하는 방향을 다룬다.
@ -36,9 +36,9 @@ Phase를 가로지르는 실제 다음 작업 선택은 [전역 마일스톤 실
- 경로: [stream-evidence-gate-core](../../archive/phase/knowledge-tool-optimization-extension/milestones/stream-evidence-gate-core.md)
- 요약: codec의 response-start/event를 첫 safe release까지 stage하고 500-rune rolling, bounded terminal/fragment hold, pre-read 기본값/절대 상한 16 MiB raw-canonical ingress snapshot과 request-snapshot 기반 Filter Registry를 제공한다. Gate Coordinator가 single-flight all-complete evaluation/commit을, RecoveryPlan Coordinator와 host adapter가 strategy별 budget과 최초 실행 제외 기본값/절대 상한 3회의 request 전체 cap 아래 abort·optional one-shot plan prepare·lossless rebuild·cycle별 single re-admission을 담당한다.
- [진행중] [route-01] IOP 실행 프리셋과 Hot Path
- 경로: [[route-01] IOP 실행 프리셋과 Hot Path](milestones/iop-hot-path-one-shot-execution.md)
- 요약: 외부 model을 execution preset에 매핑하는 기반과 cross-call `request_id` coordinator를 만들고, Claude/Pi agent tool round-trip에서 Plan/Review artifact 없는 `direct`와 cloud plan → local work → cloud review/repair인 `light`를 구현한다.
- [완료] [route-01] IOP 실행 프리셋과 Hot Path
- 경로: [[route-01] IOP 실행 프리셋과 Hot Path](../../archive/phase/knowledge-tool-optimization-extension/milestones/iop-hot-path-one-shot-execution.md)
- 요약: execution preset/config generation, logical request coordinator, direct/light stage, Anthropic/Chat terminal과 관측 기반을 완료했다. caller tool continuation은 최종 제품 원칙이 아니며 exact single-request 내부 실행은 별도 `[route-02]`로 분리했다.
- [계획] [output-01] OpenAI-compatible 출력 검증 필터
- 경로: [[output-01] OpenAI-compatible 출력 검증 필터](milestones/openai-compatible-output-validation-filters.md)
@ -48,6 +48,10 @@ Phase를 가로지르는 실제 다음 작업 선택은 [전역 마일스톤 실
- 경로: [[output-02] OpenAI-compatible Incomplete Tool Call Syntax Gate](milestones/openai-compatible-incomplete-tool-call-syntax-gate.md)
- 요약: terminal provider 응답에서 완성된 tool call 수와 raw/reasoning/content tool-call marker scanner 결과가 불일치하는 케이스를 runtime에서 deterministic하게 판정해 incomplete tool-call syntax로 분류한다.
- [계획] [route-02] IOP 단일 요청 Agent 실행
- 경로: [[route-02] IOP 단일 요청 Agent 실행](milestones/iop-owned-single-request-agent-execution.md)
- 요약: Claude→IOP `/v1/messages` POST를 정확히 1회로 고정하고, Mac IOP Node의 request-scoped workspace/tool executor로 Gemini 3.6 Flash high plan → ornith-fast work → Gemini 3.6 Flash high review/repair를 내부에서 끝낸 뒤 하나의 outer stream과 terminal을 반환한다.
- [스케치] [output-03] OpenAI-compatible Runtime Output Integrity Filter
- 경로: [[output-03] OpenAI-compatible Runtime Output Integrity Filter](milestones/openai-compatible-runtime-output-integrity-filter.md)
- 요약: terminal assistant 응답이 content, valid tool call, 명시 허용 structured/error finish 중 하나를 만족해야 한다는 runtime invariant를 정의하고, empty terminal, reasoning-only, incomplete tool-call syntax 같은 deterministic violation을 공통 filter pipeline과 bounded retry 정책으로 묶는다.
@ -56,16 +60,16 @@ Phase를 가로지르는 실제 다음 작업 선택은 [전역 마일스톤 실
- 경로: [[judge-01] LLM 판별 기반 Missing Tool Call 재시도 Gate](milestones/llm-judged-missing-tool-call-retry-gate.md)
- 요약: Pi/dev-corp 같은 tool-bearing 요청에서 provider가 tool 사용 의도를 reasoning했지만 tool call 없이 종료하는 케이스를 LLM judge와 buffered retry 후보로 재검토하고, 정확한 종료/재시도 정책이 정의될 때까지 구현을 잠근다.
- [스케치] [route-02] Heavy Plan/Review 실행과 검증 MVP
- 경로: [[route-02] Heavy Plan/Review 실행과 검증 MVP](milestones/knowledge-tool-validation-optimization.md)
- 요약: Hot Path의 lightweight Plan/Review를 `heavy` mode로 확장해 `heavy-only` preset에서 장기 작업의 plan 갱신, 검증, review/repair cycle, 중단·재개와 stage binding을 먼저 검증한다. mixed mode 선택은 route-03에서 연결한다.
- [스케치] [route-03] Heavy Plan/Review 실행과 검증 MVP
- 경로: [[route-03] Heavy Plan/Review 실행과 검증 MVP](milestones/knowledge-tool-validation-optimization.md)
- 요약: 단일 요청 Hot Path의 lightweight Plan/Review를 `heavy` mode로 확장해 `heavy-only` preset에서 장기 작업의 plan 갱신, 검증, review/repair cycle, 중단·재개와 stage binding을 먼저 검증한다. mixed mode 선택은 route-04에서 연결한다.
- [스케치] [route-03] Execution Preset 하이브리드 Mode 라우팅
- 경로: [[route-03] Execution Preset 하이브리드 Mode 라우팅](milestones/openai-compatible-hybrid-request-execution-routing.md)
- [스케치] [route-04] Execution Preset 하이브리드 Mode 라우팅
- 경로: [[route-04] Execution Preset 하이브리드 Mode 라우팅](milestones/openai-compatible-hybrid-request-execution-routing.md)
- 요약: 폐기된 하이브리드 라우팅 설계에서 IOP Edge 책임만 복원해 cloud advisory와 deterministic hard gate를 결합하고, 이미 선택된 preset의 allowed mode 중 요청 수준에 맞는 실행 경로를 최종 결정한다.
- [스케치] [route-04] RAG 기반 Local Routing Model 운영 전환
- 경로: [[route-04] RAG 기반 Local Routing Model 운영 전환](milestones/rag-local-routing-model-operations.md)
- [스케치] [route-05] RAG 기반 Local Routing Model 운영 전환
- 경로: [[route-05] RAG 기반 Local Routing Model 운영 전환](milestones/rag-local-routing-model-operations.md)
- 요약: cloud-first route evidence가 충분한 품질·규모 gate를 통과하면 같은 decision contract를 쓰는 RAG local router를 shadow, canary, primary 순서로 승격하고 cloud judge를 fallback·audit으로 유지한다.
- [스케치] [judge-02] Tool Call 판정 모델 Gate 리뷰
@ -90,10 +94,10 @@ Phase를 가로지르는 실제 다음 작업 선택은 [전역 마일스톤 실
- 이 Phase는 Control Plane/Client 운영 기반과 운영 관측 MVP 없이 현재 provider 확장 Phase 안으로 당겨 구현하지 않는다.
- 기본 `/v1/models`, `/v1/chat/completions`, Edge-Node relay, Ollama option/API passthrough 안정화는 `Ollama 서빙 안정화 기반` Phase 책임이다.
- 추가 추론 서버 provider의 adapter/config/target/model 매핑 표준화는 `추론 서버 provider 확장` Phase 책임이다.
- execution preset Hot Path, heavy Plan/Review, 하이브리드 라우팅은 순서대로 공통 preset/coordinator와 `direct/light`, 장기 작업용 `heavy`, Edge 범용 mode 선택 정책을 구성한다.
- 외부 model 선택이 execution preset을 고정하고, IOP Edge는 요청 사실과 model advisory를 바탕으로 그 preset의 allowed mode와 stage별 canonical model binding을 최종 확정한다. IOP Node는 확정된 provider stage 실행·취소·상태·usage 보고 담당한다.
- plan-bearing mode는 agent의 기존 workspace-capable tool call로 사용자 workspace의 `.iop/job/<request_id>/plan.md``review.md`를 사용한다. write tool이 missing parent를 만들지 못하면 같은 cloud stage의 tool continuation으로 request directory를 먼저 준비한다. IOP는 tool call을 생성·검증하고 논리 요청 state와 terminal을 제어하지만 workspace나 agent runtime을 직접 소유하지 않는다.
- 각 stage의 routing, plan, work, review, defect와 repair 출력은 사용자 stream에 유지한다. 내부 control prompt, credential과 protocol metadata만 공개하지 않는다.
- target agent나 외부 workflow 제품의 process, state, contract 또는 runtime을 이 Phase에 연결하지 않는다. endpoint-native tool call 실행은 호출 agent가 소유한다.
- Claude Code용 Gemini Chat bridge, 단일 요청 Agent 실행, heavy Plan/Review, 하이브리드 라우팅은 순서대로 provider protocol 호환, fixed `light` preset/coordinator와 IOP-owned request-scoped workspace/tool runtime, 장기 작업용 `heavy`, Edge 범용 mode 선택 정책을 구성한다.
- 외부 model 선택이 execution preset을 고정하고, IOP Edge는 요청 사실과 model advisory를 바탕으로 그 preset의 allowed mode와 stage별 canonical model binding을 최종 확정한다. IOP Node는 provider stage 실행·취소·상태·usage 보고뿐 아니라 preset이 승인한 request-scoped workspace 도구 실행을 담당한다.
- plan-bearing one-shot mode는 IOP Node가 승인된 workspace root 아래 `.iop/job/<request_id>/plan.md``review.md`를 직접 생성·읽기·갱신·정리한다. 내부 model tool call/result는 IOP coordinator가 소비하며 Claude에 후속 tool result 요청을 요구하지 않는다.
- 각 stage의 routing, plan, work, review, defect와 repair는 outer stream에 redacted 진행 요약으로만 투영한다. 내부 provider reasoning, control prompt, tool protocol·argument/result, credential과 stage terminal은 공개하지 않고 최종 사용자 결과와 outer terminal만 완결된 응답으로 반환한다.
- target agent나 외부 workflow 제품의 process/state를 실행 의존성으로 연결하지 않는다. 범용 interactive shell과 장기 workflow는 제외하지만, execution preset의 request-scoped workspace/tool executor는 IOP가 소유한다.
- cloud model은 초기 semantic judge/teacher 역할을 하고, 충분한 정제 evidence가 쌓인 뒤 RAG local router로 운영 기본을 전환한다. 두 경우 모두 최종 권한은 deterministic hard gate를 적용하는 Edge arbiter에 남는다.
- routing evidence RAG는 route 판정 전용이고, repository 장기 기억 RAG·누적 요청 context·advisor·Context Hook과 corpus/index/평가를 공유하지 않는다.

View file

@ -0,0 +1,117 @@
# Milestone: [route-02] IOP 단일 요청 Agent 실행
## 위치
- Roadmap: [ROADMAP.md](../../../ROADMAP.md)
- Phase: [PHASE.md](../PHASE.md)
- SDD: [SDD.md](../../../sdd/knowledge-tool-optimization-extension/iop-owned-single-request-agent-execution/SDD.md)
## 목표
Claude가 IOP의 Anthropic-compatible model을 호출할 때 `/v1/messages` POST를 정확히 한 번만 보내고, IOP가 그 연결 안에서 Plan → Work → Review/repair를 모두 완료한다.
초기 실행 preset은 Gemini 3.6 Flash `high`가 작은 plan을 만들고, `ornith-fast`가 Mac IOP Node의 request-scoped workspace 도구로 작업·검증하며, 같은 Gemini 3.6 Flash `high`가 결과를 review하고 잔존 작업을 수정한 뒤 하나의 model 응답처럼 최종 terminal을 반환한다.
## 상태
[계획]
## 구현 잠금
- 상태: 해제
- SDD: 필요
- SDD 문서: [IOP 단일 요청 Agent 실행 SDD](../../../sdd/knowledge-tool-optimization-extension/iop-owned-single-request-agent-execution/SDD.md)
- SDD 사유: Anthropic streaming, Edge coordinator, Edge-Node wire, request-scoped workspace/tool 권한, 취소·cleanup과 provider stage 계약을 함께 변경한다.
- SDD 상태: 승인됨
- SDD 잠금: 해제
- SDD 사용자 리뷰: 없음
- 잠금 해제 조건: 아래 체크리스트
- [x] SDD 잠금이 해제되어 있다.
- [x] SDD 사용자 리뷰가 없거나 승인/해결되었다.
- [x] Acceptance Scenario가 Milestone 기능 Task와 연결되어 있다.
- [x] Evidence Map이 완료 시 `complete.log``milestone-task` id별 집계와 최종 검증 evidence로 검증 가능하게 연결되어 있다.
- 결정 필요: 없음
## 범위
### 1. 외부 단일 요청 불변 조건
- Claude는 사용자 요청 하나에 대해 IOP `/v1/messages`를 정확히 한 번 호출한다.
- IOP는 최초 Anthropic response envelope와 SSE 연결을 Plan, Work, Review/repair 전체 수명 동안 유지하고 최종 endpoint-native terminal을 한 번만 반환한다.
- internal provider/tool stage의 response-start, finish reason, tool call과 tool result는 coordinator가 소비한다. Claude에 `tool_use` terminal을 반환해 두 번째 Messages 요청을 요구하지 않는다.
- external request count 1은 `request_id` 하나나 사용자 prompt 하나와 동의어가 아니라 실제 Edge ingress POST 수로 검증한다.
### 2. Execution preset과 model binding
- exposed model은 기존 canonical model/provider route 대신 fixed `light` single-request execution preset에 매핑된다. 이 마일스톤은 요청별 mode selector를 실행하지 않는다.
- 초기 preset의 `plan``review` stage는 canonical `gemini-3.6-flash` model reference와 `reasoning_effort=high`를 사용한다.
- `work` stage는 canonical `ornith-fast` model reference를 사용하며 Gemini의 `high` 옵션을 복제하지 않는다.
- model/provider endpoint와 credential은 core에 하드코딩하지 않고 기존 principal projection, route authorization, provider-pool resolution과 lease를 stage마다 재사용한다.
### 3. IOP-owned request-scoped workspace/tool runtime
- preset은 operator가 승인한 Mac IOP Node의 `workspace_ref`를 가리키며 caller가 임의 absolute path나 Node를 선택하지 못한다.
- IOP Node는 해당 root 아래 request-scoped execution context를 만들고 canonical read/list/write/delete/command tool을 실행한다.
- `.iop/job/<request_id>/plan.md``review.md`는 IOP-owned workspace operation으로 생성·읽기·갱신·정리한다.
- tool argument, cwd containment, symlink escape, command process group, 환경 변수 allowlist, stdout/stderr 상한, timeout과 cancel을 fail-closed로 검증한다.
- cleanup은 request-owned `.iop/job/<request_id>` artifact와 실행 process만 대상으로 하며 사용자가 요청한 workspace 결과 파일은 삭제하거나 rollback하지 않는다.
- 범용 interactive terminal, desktop session, 독립 scheduler와 장기 agent process는 포함하지 않는다.
### 4. Plan → Work → Review/repair
- `plan`: Gemini 3.6 Flash high가 immutable 사용자 요청에서 작은 plan과 검증 기준을 만들고 `plan.md`를 내부 tool로 기록한다.
- `work`: ornith-fast가 사용자 요청과 plan을 받아 IOP Node tool loop로 workspace를 수정·검증하고 completion candidate를 만든다.
- `review`: Gemini 3.6 Flash high가 사용자 요청, plan, workspace 결과와 검증 evidence를 검사해 pass이면 finalize하고 defect이면 같은 stage 안에서 잔존 작업을 수정·재검증한다.
- provider repetition/no-progress와 malformed tool output은 stage별 tool-iteration/output/deadline과 request 전체 wall-clock budget 안에서 중단하며 외부 Claude 재호출로 복구하지 않는다. 이 fixed `light` 경로는 짧은 작업만 대상으로 한다.
## 기능
### Epic: [single-request] Single-request Coordinator
- [ ] [single-ingress] Claude `/v1/messages` POST 하나를 immutable request/preset/stage identity에 고정하고 추가 caller ingress 없이 완료하는 coordinator와 Anthropic API 계약을 구현한다.
- [ ] [preset-binding] exposed model을 Gemini plan/review와 ornith-fast work 및 Mac Node workspace resource를 포함한 immutable fixed `light` execution preset에 매핑하고 unsupported dynamic mode binding을 fail-closed하며 config/runtime-refresh 계약을 동기화한다.
- [ ] [stream-terminal] internal stage envelope과 terminal을 소비하고 private model reasoning/tool protocol은 숨긴 채 진행 요약, 연결 유지 ping과 최종 terminal 하나를 Anthropic SSE로 합성한다.
### Epic: [workspace-runtime] Mac Node Workspace Tool Runtime
- [ ] [workspace-binding] principal/preset에 승인된 Mac Node `workspace_ref`를 admission하고 request-scoped workspace identity와 containment를 고정한다.
- [ ] [tool-executor] provider `RunRequest`/closed `NodeCommand`와 분리된 typed Edge-Node workspace runtime으로 read/list/write/delete/command를 bounded output, cwd/symlink/env/process 안전 경계와 함께 실행하고 protobuf·Edge-Node wire 계약을 동기화한다.
- [ ] [tool-loop] internal model tool call/result를 IOP coordinator와 Node executor 사이에서 반복하고 Claude-facing `tool_use` continuation을 만들지 않는다.
- [ ] [cleanup-observation] 성공·오류·취소의 request-owned process/artifact cleanup과 raw-free request/stage/tool/total timing 관측을 구현하고 사용자 결과 파일은 보존한다.
### Epic: [plan-work-review] Plan, Work, Review
- [ ] [plan-stage] Gemini 3.6 Flash high가 작은 plan·검증 기준을 만들고 IOP-owned `plan.md`에 기록한다.
- [ ] [work-stage] ornith-fast가 plan을 읽고 internal tool loop로 실제 workspace 작업과 검증을 완료한다.
- [ ] [review-stage] Gemini 3.6 Flash high가 결과를 review하고 pass 또는 잔존 작업 수정·재검증·finalize까지 수행한다.
### Epic: [quality-gate] 오류와 실제 검증
- [ ] [error-cancel] provider/tool timeout, bounded stage/request budget, repetition/no-progress, malformed call, context/output limit, caller disconnect를 추가 외부 요청 없이 표준 오류·취소·length terminal로 수렴시킨다.
- [ ] [claude-smoke] 실제 Claude에서 작은 workspace 작업을 한 번 요청해 Edge의 `/v1/messages` ingress count가 정확히 1이고 Gemini → ornith-fast → Gemini stage, stage/total 순수 시간, 최종 파일·검증·terminal이 모두 확인되는 smoke를 통과한다.
## 완료 리뷰
- 상태: 없음
- 요청일: 없음
- 완료 근거: 사용자 확정 방향과 승인된 SDD로 계획 상태를 만들었으며 기능 Task evidence는 아직 없다.
- 검토 항목: 없음
- 리뷰 코멘트: 없음
## 범위 제외
- Pi/OpenAI Chat Completions를 이 마일스톤의 target agent/protocol로 추가하는 작업
- 장기 작업의 재계획, 여러 review cycle와 durable resume를 제공하는 `heavy` mode
- 범용 interactive shell, desktop/PTY session, 독립 scheduler, CI/CD와 사람 승인 workflow
- caller가 임의 Node, absolute workspace path, credential 또는 preset 밖 model/tool을 선택하는 기능
- cross-Edge state replication과 Edge restart 뒤 동일 SSE resume
## 작업 컨텍스트
- 관련 경로: `apps/edge/internal/openai`, `apps/edge/internal/service`, `apps/node/internal/node`, `apps/node/internal/transport`, `packages/go/config`, `packages/go/streamgate`, `proto/iop`, `configs/edge.yaml`
- 구현 기준선: 완료·아카이빙한 [[route-01] IOP 실행 프리셋과 Hot Path](../../../archive/phase/knowledge-tool-optimization-extension/milestones/iop-hot-path-one-shot-execution.md)의 execution preset/config generation, coordinator, endpoint codec, Stream Evidence Gate, authorization/lease, error·cleanup·observability 기반과 현재 Anthropic↔Gemini Chat bridge를 재사용한다. 과도기 caller tool-result smoke는 이 마일스톤의 선행 차단이 아니며, exact single-request E2E는 이 마일스톤이 직접 검증한다.
- 표준선: one-shot의 완료 기준은 logical `request_id`가 아니라 실제 Claude→IOP `/v1/messages` POST count 1이다.
- 표준선: request-scoped workspace/tool execution은 IOP Edge/Mac Node가 소유하며 외부 Claude tool callback에 의존하지 않는다.
- 큐 배치: 완료·아카이빙된 `[route-01]` 다음인 route lane의 `[route-02]` 2번이며 현재 active lane head다.
- 실행 순서와 차단 관계: [전역 마일스톤 실행 순서](../../../priority-queue.md)
- 후속: [Heavy Plan/Review 실행과 검증 MVP](knowledge-tool-validation-optimization.md), [Execution Preset 하이브리드 Mode 라우팅](openai-compatible-hybrid-request-execution-routing.md)
- 확인 필요: 없음

View file

@ -1,4 +1,4 @@
# Milestone: [route-02] Heavy Plan/Review 실행과 검증 MVP
# Milestone: [route-03] Heavy Plan/Review 실행과 검증 MVP
## 위치
@ -7,9 +7,9 @@
## 목표
[`IOP 실행 프리셋과 Hot Path`](iop-hot-path-one-shot-execution.md)가 구현한 preset/coordinator와 lightweight Plan/Review를 장기·고난도 작업용 `heavy` execution mode로 확장한다.
`heavy`는 별도 제품이나 고정 model 조합이 아니라 execution preset이 선택적으로 포함할 수 있는 mode handler다. preset마다 planner, worker, reviewer와 repair model/options를 다르게 배치할 수 있다. 이 마일스톤에서는 `heavy-only` preset으로 lifecycle을 먼저 검증하고, `direct/light/heavy` 혼합 선택은 후속 route-03에서 연결한다.
이 마일스톤은 Plan/Review 갱신, 검증, 여러 work/review 전이와 중단·재개가 필요한 작업을 IOP의 하나의 논리 `request_id` 수명으로 다루되 target agent나 외부 workflow 제품의 adapter, process 또는 state를 공유하지 않는다.
[`IOP 단일 요청 Agent 실행`](iop-owned-single-request-agent-execution.md)이 구현한 exact single-request coordinator와 lightweight Plan/Review를 장기·고난도 작업용 `heavy` execution mode로 확장한다.
`heavy`는 별도 제품이나 고정 model 조합이 아니라 execution preset이 선택적으로 포함할 수 있는 mode handler다. preset마다 planner, worker, reviewer와 repair model/options를 다르게 배치할 수 있다. 이 마일스톤에서는 `heavy-only` preset으로 lifecycle을 먼저 검증하고, `direct/light/heavy` 혼합 선택은 후속 route-04에서 연결한다.
이 마일스톤은 Plan/Review 갱신, 검증과 여러 work/review 전이를 IOP-owned request-scoped workspace/tool runtime에서 수행하며 외부 agent의 추가 model/tool HTTP turn에 의존하지 않는다.
## 상태
@ -17,15 +17,15 @@
## 선행 작업
- [`IOP 실행 프리셋과 Hot Path`](iop-hot-path-one-shot-execution.md)
- [`IOP 단일 요청 Agent 실행`](iop-owned-single-request-agent-execution.md)
## 승격 조건
- [ ] `light`에서 `heavy`로 구분되는 작업 규모·위험·검증 요구와 mode 선택 기준을 확정한다.
- [ ] heavy plan의 갱신 단위, review 기록, work/review/repair 전이와 완료 판정을 확정한다.
- [ ] 여러 agent tool turn, process restart와 중단 후 재개에 필요한 state/artifact 최소 범위를 확정한다.
- [ ] 여러 internal tool cycle, process restart와 중단 후 재개에 필요한 state/artifact 최소 범위를 확정한다.
- [ ] 검증 실패 시 재계획·수정·재검토의 budget, timeout, cancel과 표준 오류 경계를 확정한다.
- [ ] Claude/Pi 이후 endpoint 확장과 workspace capability admission 범위를 확정한다.
- [ ] Claude Messages 이후 endpoint 확장과 IOP-owned workspace capability admission 범위를 확정한다.
- [ ] API/config/event/artifact lifecycle 구현 전 필수 SDD를 작성·승인한다.
## 구현 잠금
@ -36,7 +36,7 @@
- SDD 사유: 현재는 `heavy` mode의 책임과 `light`와의 경계를 정리한 후속 스케치다. 장기 state, artifact 갱신, retry/review와 resume 계약을 구현하기 전에 필수 SDD가 필요하다.
- 잠금 해제 조건: 아래 체크리스트
- [ ] 승격 조건의 lifecycle·artifact·budget·resume 결정이 모두 해소되어 있다.
- [ ] 현재 Hot Path에 추가할 부분과 공통 coordinator를 변경할 부분이 분리되어 있다.
- [ ] single-request Hot Path에 추가할 부분과 공통 coordinator를 변경할 부분이 분리되어 있다.
- [ ] 구현 가능한 첫 heavy profile과 후속 확장 범위가 분리되어 있다.
- [ ] 필요한 SDD가 작성·승인되어 있다.
- 결정 필요: `승격 조건`과 동일
@ -48,14 +48,14 @@
- `heavy`는 preset `allowed_modes`와 registered handler로 추가하며 외부 model에 별도 하드코딩하지 않는다.
- preset stage binding은 기존 canonical model/provider resolution을 사용하고 planner/worker/reviewer/repair 역할의 model과 옵션을 operator가 구성한다.
- 이 마일스톤의 실행 검증은 `allowed_modes=[heavy]`인 unambiguous preset에서 fused selector/planner가 heavy plan을 작성하는 경로로 한정한다. selector가 `light/heavy` 난이도를 비교하거나 mixed mode를 고르는 계약은 도입하지 않는다.
- schema는 후속 `plan-only(light/heavy)`, balanced와 custom 조합을 막지 않지만, 둘 이상의 실행 가능한 mode 중 semantic selection을 요구하는 preset은 route-03 handler가 생기기 전 fail-closed한다.
- schema는 후속 `plan-only(light/heavy)`, balanced와 custom 조합을 막지 않지만, 둘 이상의 실행 가능한 mode 중 semantic selection을 요구하는 preset은 route-04 handler가 생기기 전 fail-closed한다.
### 2. Plan/Review lifecycle
- 기본 workspace root와 identity는 `.iop/job/<request_id>/``request_id`를 그대로 재사용한다.
- route-01`plan.md`/`review.md` pair를 최소 기반으로 삼고, 실제 필요가 확정될 때만 추가 파일·revision·checkpoint를 설계한다.
- plan 갱신, work progress, review defect와 repair 결과는 agent의 기존 tool call로 workspace에 반영한다. IOP는 stage와 terminal을 조정하지만 workspace를 직접 소유하지 않는다.
- long-running tool round-trip과 재연결에서도 동일 request identity, idempotency와 exactly-once final을 유지한다.
- route-02`plan.md`/`review.md` pair를 최소 기반으로 삼고, 실제 필요가 확정될 때만 추가 파일·revision·checkpoint를 설계한다.
- plan 갱신, work progress, review defect와 repair 결과는 IOP Node의 request-scoped tool executor가 workspace에 반영한다.
- long-running internal tool cycle에서도 동일 request identity, idempotency와 exactly-once final을 유지한다. 외부 Claude 요청을 추가하지 않는다.
### 3. 검증과 회귀
@ -85,7 +85,7 @@
## 범위 제외
- execution preset과 무관한 별도 heavyweight 제품/API
- target agent나 외부 workflow 제품의 process/state/contract, terminal/PTY 또는 agent별 adapter와의 runtime 연결
- 범용 interactive shell, desktop session, 외부 workflow process/state와의 runtime 연결
- 모든 미래 mode를 미리 수용하는 범용 DAG/plugin engine
- 하이브리드 mode selector의 production evidence 정책과 RAG local router 운영
- `direct/light/heavy` 혼합 preset의 난이도 기반 mode 선택
@ -94,9 +94,9 @@
## 작업 컨텍스트
- 관련 경로: `apps/edge/internal/openai`, `apps/edge/internal/service`, `packages/go/config`, `packages/go/streamgate`
- 선행 SDD: [IOP 실행 프리셋과 Hot Path SDD](../../../sdd/knowledge-tool-optimization-extension/iop-hot-path-one-shot-execution/SDD.md)
- 표준선(선택): `light`request coordinator, endpoint-native tool call, visible stage stream와 표준 오류 계약을 깨지 않고 `heavy` state만 확장한다.
- 표준선(선택): artifact 구조는 필요가 확정된 시점에만 확장하며 route-01에 manifest/revision/empty directory를 선반영하지 않는다.
- 선행 SDD: [IOP 단일 요청 Agent 실행 SDD](../../../sdd/knowledge-tool-optimization-extension/iop-owned-single-request-agent-execution/SDD.md)
- 표준선(선택): `light`single-request coordinator, IOP-owned tool loop, visible stage stream와 표준 오류 계약을 깨지 않고 `heavy` state만 확장한다.
- 표준선(선택): artifact 구조는 필요가 확정된 시점에만 확장하며 route-02에 manifest/revision/empty directory를 선반영하지 않는다.
- 후속 작업: [Execution Preset 하이브리드 Mode 라우팅](openai-compatible-hybrid-request-execution-routing.md), [RAG 기반 Local Routing Model 운영 전환](rag-local-routing-model-operations.md)
- 큐 배치: `[route-01]` 바로 뒤인 `[route-02]` 2번이다.
- 큐 배치: `[route-02]` 바로 뒤인 `[route-03]` 3번이다.
- 확인 필요: `구현 잠금 > 결정 필요`

View file

@ -1,12 +1,12 @@
# Milestone: [route-03] Execution Preset 하이브리드 Mode 라우팅
# Milestone: [route-04] Execution Preset 하이브리드 Mode 라우팅
## 목표
- 폐기된 [`OpenAI-compatible Hybrid Routing · Context Optimization`](../../../archive/phase/routing-policy-model-orchestration/milestones/openai-compatible-hybrid-routing-context-optimization.md)의 핵심 의도 중 **IOP 내부 요청 난이도·실행 형태 라우팅과 학습 가능한 decision evidence**만 현재 책임 경계에 맞게 복원한다.
- 외부 호출자가 선택한 model이 execution preset을 먼저 고정하고, IOP Edge가 요청 난이도, 기능 요구, 컨텍스트 규모, 지연·비용 예산과 model 가용성을 종합해 그 preset의 `allowed_modes` 안에서 최종 mode를 결정한다.
- 초기 운영에서는 cloud model이 의미·난이도 advisory를 제공하고 deterministic hard gate와 Edge arbiter가 최종 권한을 갖는다. cloud selector의 timeout/schema/provider 실패는 다른 mode로 조용히 우회하지 않고 표준 model/API 오류로 종료한다.
- [`IOP 실행 프리셋과 Hot Path`](iop-hot-path-one-shot-execution.md)의 `direct/light`와 [`Heavy Plan/Review 실행과 검증 MVP`](knowledge-tool-validation-optimization.md)의 `heavy`를 같은 preset mode contract로 연결한다.
- route-01의 fused selector/planner preset은 그대로 지원한다. 이 마일스톤은 advisory-only selector와 mode별 entry stage를 분리하는 explicit selection strategy를 추가하며 기존 fused preset의 의미를 암묵적으로 바꾸지 않는다.
- 기존 provider-direct 실행, [`IOP 단일 요청 Agent 실행`](iop-owned-single-request-agent-execution.md)의 fixed `light`와 [`Heavy Plan/Review 실행과 검증 MVP`](knowledge-tool-validation-optimization.md)의 `heavy`를 같은 preset mode contract로 연결한다.
- route-02의 fixed `light` preset과 single-request runtime을 그대로 지원한다. 이 마일스톤은 advisory-only selector와 mode별 entry stage를 분리하는 explicit selection strategy를 추가하며 기존 fixed preset의 의미를 암묵적으로 바꾸지 않는다.
- route decision/evidence를 축적해 후속 [`RAG 기반 Local Routing Model 운영 전환`](rag-local-routing-model-operations.md)이 selector 구현만 대체하고 preset/runtime은 그대로 재사용할 수 있게 한다.
## 상태
@ -17,14 +17,14 @@
- archive의 폐기 문서는 당시 스냅샷으로 유지하고 직접 수정하지 않는다.
- 폐기 설계의 artifact lane, grade와 자동화 runtime을 복원하지 않는다. 현재 기준은 exposed model → execution preset → allowed mode decision이다.
- preset은 selection strategy, selector와 mode별 model/stage 조합을 소유한다. 이 milestone의 router는 preset을 바꾸거나 preset 밖 model/target을 만들지 않는다. route-01의 fused strategy와 새 advisory-then-dispatch strategy는 config에서 명시적으로 구분한다.
- Plan/Review artifact와 agent tool round-trip은 선택된 `light/heavy` handler가 소유한다. route evidence에는 raw plan/review, prompt, output과 tool argument/result를 저장하지 않는다.
- preset은 selection strategy, selector와 mode별 model/stage 조합을 소유한다. 이 milestone의 router는 preset을 바꾸거나 preset 밖 model/target을 만들지 않는다. route-02의 fixed strategy와 새 advisory-then-dispatch strategy는 config에서 명시적으로 구분한다.
- Plan/Review artifact와 internal tool cycle은 선택된 `light/heavy` handler와 IOP Node tool executor가 소유한다. route evidence에는 raw plan/review, prompt, output과 tool argument/result를 저장하지 않는다.
- target agent나 외부 workflow 제품의 process, state, contract나 runtime은 연결하지 않는다.
- IOP Node는 Edge가 확정한 stage model을 provider에서 실행·취소하고 상태·usage를 보고할 뿐, preset이나 mode를 판정하지 않는다.
- IOP Node는 Edge가 확정한 stage model과 request-scoped workspace tool을 실행·취소하고 상태·usage를 보고하되 preset이나 mode를 판정하지 않는다.
## 선행 작업
- [`IOP 실행 프리셋과 Hot Path`](iop-hot-path-one-shot-execution.md)
- [`IOP 단일 요청 Agent 실행`](iop-owned-single-request-agent-execution.md)
- [`Heavy Plan/Review 실행과 검증 MVP`](knowledge-tool-validation-optimization.md)
## 승격 조건
@ -45,7 +45,7 @@
- SDD 사유: 현재는 복원된 cloud-first mode router와 후속 local selector의 경계를 정의하는 개념 스케치다. decision/evidence schema와 운영 policy 구현 전에 필수 SDD가 필요하다.
- 잠금 해제 조건: 아래 체크리스트
- [ ] 승격 조건의 decision·failure·evidence 항목이 모두 해소되어 있다.
- [ ] route-01/02에서 재사용할 preset/mode 계약과 이 milestone의 일반화 범위가 분리되어 있다.
- [ ] route-02/03에서 재사용할 preset/mode/runtime 계약과 이 milestone의 일반화 범위가 분리되어 있다.
- [ ] 기존 fused preset을 재해석하지 않는 selection strategy와 mode entry migration/validation이 확정되어 있다.
- [ ] cloud-first 운영과 RAG local selector 후속 범위가 분리되어 있다.
- [ ] 필요한 SDD가 작성·승인되어 있다.
@ -63,7 +63,7 @@
- cloud selector는 mode와 난이도 근거를 제안할 수 있지만 preset, stage target, tool parameter와 실행 권한을 갖지 않는다.
- Edge arbiter는 preset snapshot, capability, health, context와 budget으로 advisory를 검증하고 최종 mode를 확정한다.
- advisory-then-dispatch strategy에서 mode가 확정되면 해당 preset의 mode별 entry stage부터 ordered stage/model/options를 handler에 전달한다. `direct`는 direct executor, `light/heavy`는 각 planner entry를 가질 수 있다.
- 기존 fused strategy는 route-01/02 의미대로 selector output이 direct 결과 또는 plan 작성까지 담당하며, 운영자가 명시적으로 migration하지 않는 한 advisory-only로 바뀌지 않는다.
- 기존 fixed `light` strategy는 route-02 의미대로 plan entry로 바로 시작하며, 운영자가 명시적으로 migration하지 않는 한 advisory-only selector를 암묵 추가하지 않는다.
### 2. Preset별 mode 조합
@ -112,7 +112,7 @@
- 외부 model 선택을 무시하고 router가 다른 preset으로 전환하는 기능
- preset 밖 model/target/tool을 cloud model이 직접 선택하는 기능
- target agent나 외부 workflow 제품과의 상태·artifact·process 공유
- 범용 external workflow 제품과의 상태·artifact·process 공유
- IOP Node가 preset, 요청 난이도 또는 mode policy를 자율 판정하는 기능
- 이 milestone에서 RAG local selector를 production primary로 승격하는 작업
- repository 장기 기억 RAG와 routing evidence corpus의 통합
@ -129,7 +129,7 @@
- Phase: [`지식과 도구 최적화 확장`](../PHASE.md)
- 복원 근거: [`OpenAI-compatible Hybrid Routing · Context Optimization`](../../../archive/phase/routing-policy-model-orchestration/milestones/openai-compatible-hybrid-routing-context-optimization.md)
- 선행: [`IOP 실행 프리셋과 Hot Path`](iop-hot-path-one-shot-execution.md), [`Heavy Plan/Review 실행과 검증 MVP`](knowledge-tool-validation-optimization.md)
- 선행: [`IOP 단일 요청 Agent 실행`](iop-owned-single-request-agent-execution.md), [`Heavy Plan/Review 실행과 검증 MVP`](knowledge-tool-validation-optimization.md)
- 후속: [`RAG 기반 Local Routing Model 운영 전환`](rag-local-routing-model-operations.md)
- 큐 배치: `[route-02]` 바로 뒤인 `[route-03]` 3번이다.
- 큐 배치: `[route-03]` 바로 뒤인 `[route-04]` 4번이다.
- 확인 필요: `구현 잠금 > 결정 필요`

View file

@ -1,4 +1,4 @@
# Milestone: [route-04] RAG 기반 Local Routing Model 운영 전환
# Milestone: [route-05] RAG 기반 Local Routing Model 운영 전환
## 목표
@ -114,5 +114,5 @@
- Phase: [`Knowledge / Tool 최적화 확장`](../PHASE.md)
- 선행: [`Execution Preset 하이브리드 Mode 라우팅`](openai-compatible-hybrid-request-execution-routing.md), [`요청 실행 로그와 Usage Ledger 기반`](../../operational-observability-provider-management/milestones/request-execution-log-usage-ledger-foundation.md), [`Provider-Device-Model Qualification 리포트와 Lifecycle 관리`](../../operational-observability-provider-management/milestones/provider-device-model-qualification-report.md)
- 구분 대상: [`Long-term Memory RAG 2nd Wave`](long-term-memory-rag-second-wave.md)
- 큐 배치: [`Provider-Device-Model Qualification 리포트와 Lifecycle 관리`](../../operational-observability-provider-management/milestones/provider-device-model-qualification-report.md) 바로 뒤에 배치한다.
- 큐 배치: route lane의 `[route-05]` 5번이며 [`Execution Preset 하이브리드 Mode 라우팅`](openai-compatible-hybrid-request-execution-routing.md) 뒤에 실행한다. 별도 선행 gate는 [`Provider-Device-Model Qualification 리포트와 Lifecycle 관리`](../../operational-observability-provider-management/milestones/provider-device-model-qualification-report.md)다.
- 확인 필요: `구현 잠금 > 결정 필요`

View file

@ -97,7 +97,7 @@ request stall과 provider health를 운영자가 서로 다른 원인 축으로
- 표준선(선택): 현재 기본 hard timeout은 OpenAI/A2A/Console surface `120s`, service fallback `30s`로 기본 stall timeout `300s`보다 짧다. 이 경로에서는 hard timeout이 먼저 끝나는 것이 정상이며, stall 분류는 effective request timeout이 300초보다 길거나 provider override가 그보다 짧은 요청에서만 활성화된다.
- 표준선(선택): timeout 진입은 monotonic하다. threshold 뒤 도착한 old attempt event는 새 progress로 되살리지 않고 attempt generation으로 drop한다.
- 표준선(선택): OpenAI-compatible 자동 재실행은 [OpenAI-compatible 출력 검증 필터](../../knowledge-tool-optimization-extension/milestones/openai-compatible-output-validation-filters.md)가 채택하는 StreamGate commit boundary와 request-local recovery coordinator를 재사용하고 공통 fault budget을 소비한다. 이 Milestone은 별도 기본 재시도 횟수를 추가하지 않는다.
- 구현 계획 분할 기준: Node observer/watchdog/probe와 execution/wire 변경을 한 slice로, Edge health overlay와 ingress recovery host 결합을 다른 slice로 나눈다. 후자는 plan 생성 시 관련 Milestone인 [IOP 실행 프리셋과 Hot Path](../../knowledge-tool-optimization-extension/milestones/iop-hot-path-one-shot-execution.md)의 최신 OpenAI/StreamGate 변경을 다시 확인한다.
- 구현 계획 분할 기준: Node observer/watchdog/probe와 execution/wire 변경을 한 slice로, Edge health overlay와 ingress recovery host 결합을 다른 slice로 나눈다. 후자는 plan 생성 시 관련 완료 Milestone인 [IOP 실행 프리셋과 Hot Path](../../../archive/phase/knowledge-tool-optimization-extension/milestones/iop-hot-path-one-shot-execution.md)의 최신 OpenAI/StreamGate 변경을 다시 확인한다.
- 실행 순서: [전역 마일스톤 실행 순서](../../../priority-queue.md)의 `observe-01`을 따른다.
- 후속 작업: [요청 실행 로그와 Usage Ledger 기반](request-execution-log-usage-ledger-foundation.md), [Provider 부하 메트릭과 Live Queue Dashboard](provider-load-metrics-queue-dashboard.md)
- 확인 필요: 없음

View file

@ -6,16 +6,16 @@
### route
1. [[route-01] IOP 실행 프리셋과 Hot Path](phase/knowledge-tool-optimization-extension/milestones/iop-hot-path-one-shot-execution.md)
외부 model을 전체 execution preset에 매핑하는 기반과 `request_id` coordinator를 만들고, Claude/Pi agent tool round-trip에서 `direct` 또는 cloud plan → local work → cloud review/repair인 `light`를 실행한다.
2. [[route-02] IOP 단일 요청 Agent 실행](phase/knowledge-tool-optimization-extension/milestones/iop-owned-single-request-agent-execution.md)
Claude의 Anthropic Messages 요청 정확히 1회 안에서 Mac IOP Node가 request-scoped workspace와 도구 실행을 소유하고 Gemini 3.6 Flash high plan → ornith-fast work → Gemini 3.6 Flash high review/repair를 하나의 응답으로 완료한다.
2. [[route-02] Heavy Plan/Review 실행과 검증 MVP](phase/knowledge-tool-optimization-extension/milestones/knowledge-tool-validation-optimization.md)
3. [[route-03] Heavy Plan/Review 실행과 검증 MVP](phase/knowledge-tool-optimization-extension/milestones/knowledge-tool-validation-optimization.md)
Hot Path의 lightweight Plan/Review를 장기 작업용 `heavy` mode로 확장해 `heavy-only` preset에서 재계획·검증·review/repair·resume 경계를 먼저 검증한다.
3. [[route-03] Execution Preset 하이브리드 Mode 라우팅](phase/knowledge-tool-optimization-extension/milestones/openai-compatible-hybrid-request-execution-routing.md)
4. [[route-04] Execution Preset 하이브리드 Mode 라우팅](phase/knowledge-tool-optimization-extension/milestones/openai-compatible-hybrid-request-execution-routing.md)
cloud model advisory와 deterministic hard gate를 결합해 Edge가 외부 model에 매핑된 preset의 허용 mode 중 요청 난이도에 맞는 실행 경로를 고르고 route evidence를 축적한다.
4. [[route-04] RAG 기반 Local Routing Model 운영 전환](phase/knowledge-tool-optimization-extension/milestones/rag-local-routing-model-operations.md)
5. [[route-05] RAG 기반 Local Routing Model 운영 전환](phase/knowledge-tool-optimization-extension/milestones/rag-local-routing-model-operations.md)
cloud-first route evidence가 품질·규모 gate를 통과하면 RAG local router를 shadow/canary로 검증해 운영 기본 경로로 점진 전환한다.
- 선행 차단: `[observe-03]`, `[provider-02]`
@ -23,7 +23,7 @@
1. [[output-01] OpenAI-compatible 출력 검증 필터](phase/knowledge-tool-optimization-extension/milestones/openai-compatible-output-validation-filters.md)
실제 의미 필터 전에 deterministic diagnostic mock으로 실제 Stream Evidence Gate의 pass·observe-only·blocking recovery를 관측하는 smoke를 통과시키고, OpenAI-compatible single-stream 반복과 incoming request history에 누적된 assistant 반복, JSON contract 검증/repair 경로를 안정화한다.
- 동시 차단: `[route-01]`
- 동시 차단: `[route-02]`
2. [[output-02] OpenAI-compatible Incomplete Tool Call Syntax Gate](phase/knowledge-tool-optimization-extension/milestones/openai-compatible-incomplete-tool-call-syntax-gate.md)
terminal provider 응답의 incomplete tool-call syntax를 deterministic하게 판정한다.
@ -86,10 +86,10 @@
1. [[memory-01] 장기 기억과 RAG 업데이트 사이클 (2차)](phase/knowledge-tool-optimization-extension/milestones/long-term-memory-rag-second-wave.md)
repo 장기 기억, RAG 저장소, update cycle, MCP 기반 context 절약 후보를 스케치한다.
- 선행 차단: `[route-02]`, `[observe-03]`
- 선행 차단: `[route-03]`, `[observe-03]`
### advisor
1. [[advisor-01] Advisor와 Context Hook 확장 (2차)](phase/knowledge-tool-optimization-extension/milestones/advisor-context-hook-second-wave.md)
advisor 역할과 여러 기능을 실행 흐름에 연결하는 Context Hook 경계를 스케치한다.
- 선행 차단: `[route-02]`
- 선행 차단: `[route-03]`

View file

@ -0,0 +1,160 @@
# SDD: [route-02] IOP 단일 요청 Agent 실행
## 위치
- Milestone: [IOP 단일 요청 Agent 실행](../../../phase/knowledge-tool-optimization-extension/milestones/iop-owned-single-request-agent-execution.md)
- Phase: [PHASE.md](../../../phase/knowledge-tool-optimization-extension/PHASE.md)
## 상태
[승인됨]
## SDD 잠금
- 상태: 해제
- 사용자 리뷰: 없음
- 잠금 항목:
- [x] [D01] one-shot은 사용자 prompt나 logical `request_id`가 아니라 Claude→IOP `/v1/messages` POST 정확히 1회다.
- [x] [D02] IOP Edge가 외부 요청과 stage state machine, 하나의 outer Anthropic stream과 최종 terminal을 소유한다.
- [x] [D03] request-scoped workspace와 tool execution은 preset이 승인한 Mac IOP Node가 소유한다.
- [x] [D04] 외부 Claude는 internal tool call/result를 실행하지 않으며 IOP가 두 번째 Messages 요청을 요구하지 않는다.
- [x] [D05] 초기 stage는 Gemini 3.6 Flash high plan → ornith-fast work → Gemini 3.6 Flash high review/repair 순서다.
- [x] [D06] 범용 interactive shell·desktop·scheduler는 제외하고 bounded request-scoped tool executor만 포함한다.
- [x] [D07] Pi/OpenAI Chat Completions는 이 마일스톤에서 사용하지 않는다.
- [x] [D08] workspace tool wire/runtime은 provider `RunRequest`, provider execution package와 closed `NodeCommand`를 확장하지 않고 별도 typed request-scoped 경계로 둔다.
- [x] [D09] 초기 preset은 mode selection 없는 fixed `light` Plan/Work/Review 경로이며 direct/heavy/mixed mode 선택은 후속 마일스톤 범위다.
- [x] [D10] outer stream에는 진행 요약과 최종 사용자 결과만 공개하고 internal provider reasoning, tool protocol과 stage terminal은 공개하지 않는다.
## 문제 / 비목표
- 문제: 현재 compatibility 경로는 provider tool call을 Claude-facing `tool_use`로 종료하고 caller의 다음 `/v1/messages` tool-result 요청에 의존할 수 있다. 이는 사용자가 확정한 단일 요청 모델 동작이 아니다. IOP가 외부 요청을 열린 상태로 유지하면서 plan, workspace 작업, review/repair와 tool result를 모두 내부에서 소유해야 한다.
- 비목표:
- 범용 shell/desktop/PTY 서비스와 장기 agent process
- `heavy`의 재계획·여러 review cycle·durable resume
- Pi/OpenAI Chat Completions one-shot 지원
- cross-Edge coordinator state replication
## Source of Truth
| 영역 | 기준 | 메모 |
|------|------|------|
| Roadmap | [Milestone 문서](../../../phase/knowledge-tool-optimization-extension/milestones/iop-owned-single-request-agent-execution.md) | 목표, Task와 완료 상태 원장 |
| Edge Runtime | `apps/edge/internal/openai`, `apps/edge/internal/service` | single ingress, coordinator, stage dispatch, Anthropic outer stream |
| Node Runtime | `apps/node/internal/node`, `apps/node/internal/transport`와 전용 workspace executor | Mac Node request-scoped workspace/tool 실행; provider execution runtime과 분리 |
| Config/Wire | `packages/go/config`, `proto/iop`, `configs/edge.yaml` | 새 preset model/workspace reference와 전용 Edge-Node tool request/result 계약의 구현 원본 |
| Stream Runtime | `packages/go/streamgate` | internal terminal hold, repetition/no-progress와 final commit |
| API Contract | [Anthropic-Compatible Messages API](../../../../agent-contract/outer/anthropic-compatible-api.md) | 외부 단일 Messages request/stream/error 계약 |
| Runtime Contract | [Edge-Node Runtime Wire](../../../../agent-contract/inner/edge-node-runtime-wire.md) | 현재 provider wire 기준; 전용 workspace tool wire 구현 시 함께 갱신 |
| User Decision | D01-D10 | 2026-08-05 최종 합의와 기존 provider/runtime 계약에 따른 책임 분리, 추가 사용자 결정 없음 |
## State Machine
| 상태 | 진입 조건 | 다음 상태 | 근거 |
|------|-----------|-----------|------|
| `accepted` | Claude `/v1/messages` POST 하나를 인증·admission하고 request/preset/workspace generation을 고정 | `planning`, `failed`, `cancelled` | ingress count, request id, principal/preset/workspace binding |
| `planning` | Gemini 3.6 Flash high가 작은 plan과 검증 기준을 생성하고 internal write를 요청 | `internal_tool`, `working`, `failed`, `cancelled` | plan stage/provider attempt/tool call |
| `working` | ornith-fast가 plan을 읽고 workspace 작업·검증을 수행 | `internal_tool`, `reviewing`, `failed`, `cancelled` | work stage/provider attempt/tool call/completion candidate |
| `reviewing` | Gemini 3.6 Flash high가 결과·검증 evidence를 검사 | `internal_tool`, `repairing`, `finalizing`, `failed`, `cancelled` | review stage verdict/tool call |
| `repairing` | 같은 review stage가 잔존 작업을 수정·재검증 | `internal_tool`, `finalizing`, `failed`, `cancelled` | review/repair tool call/result |
| `internal_tool` | active stage가 canonical workspace tool call을 생성 | 저장된 active stage, `failed`, `cancelled` | Node tool request/result; 외부 Anthropic terminal 없음 |
| `finalizing` | review pass 또는 repair 완료, cleanup 대기 | `completed`, `failed`, `cancelled` | cleanup result, pending final terminal |
| `completed` | cleanup과 최종 response commit 성공 | 종료 | Anthropic terminal 1회, ingress count 1 |
| `failed` | admission/provider/tool/validation/timeout/context 실패 | 종료 | endpoint-native error terminal 1회 |
| `cancelled` | caller disconnect/abort | 종료 | provider/tool process cancel과 bounded cleanup |
State invariant:
- 한 external request에는 하나의 active stage만 있으며 planner, worker, reviewer binding과 workspace generation은 시작 시 고정한다.
- `internal_tool`은 외부 `tool_use` terminal이 아니다. IOP Node result가 active stage provider continuation으로 돌아가고 outer Anthropic stream은 열린 상태를 유지한다.
- Gemini plan/review stage에는 `reasoning_effort=high`를 적용하고 ornith-fast work stage에는 그 옵션을 전파하지 않는다.
- internal provider response-start/terminal은 stage transition evidence이며 public Anthropic envelope을 새로 열거나 닫지 않는다.
- stage별 tool iteration/output/deadline과 request 전체 wall-clock budget은 request 시작 시 고정하고, exhaustion은 다른 stage/model 또는 외부 Claude 요청으로 우회하지 않는다.
- cleanup은 request-owned process와 `.iop/job/<request_id>` artifact만 회수한다. 사용자 요청으로 생성·수정한 workspace 결과는 success/failure cleanup 대상이 아니다.
- completed/failed/cancelled는 서로 배타적이고 final terminal은 exactly-once다.
## Interface Contract
- 계약 원문: [Anthropic-Compatible Messages API](../../../../agent-contract/outer/anthropic-compatible-api.md), [Edge Config And Runtime Refresh](../../../../agent-contract/inner/edge-config-runtime-refresh.md), [Edge-Node Runtime Wire](../../../../agent-contract/inner/edge-node-runtime-wire.md)
- 외부 입력:
- `POST /v1/messages`: Claude가 보내는 유일한 작업 ingress다.
- `model`: fixed `light` single-request execution preset에 매핑되는 public model id다.
- caller body의 `tools[]`는 이 preset의 workspace 실행 권한이나 Node/path selector가 아니다.
- preset/config 입력:
- `plan`: canonical `gemini-3.6-flash` reference와 high reasoning option.
- `work`: canonical `ornith-fast` reference; planner/reviewer high option을 상속하지 않는다.
- `review`: canonical `gemini-3.6-flash` reference와 high reasoning option.
- `workspace_ref`: operator가 승인한 Mac IOP Node와 workspace root capability reference다. raw absolute path나 credential을 preset에 직접 넣지 않는다.
- `limits`: request `wall_clock_ms`와 stage별 `timeout_ms`, `max_tool_iterations`, `max_output_bytes`를 양수와 server absolute cap 안에서 고정한다. refresh는 active request limit을 바꾸지 않는다.
- 초기 preset은 dynamic selector나 `allowed_modes` advisory를 실행하지 않고 plan → work → review entry를 고정한다. unknown/direct/heavy/mixed binding은 시작 전에 거부한다.
- 내부 tool 입력/출력:
- provider `RunRequest.metadata`, provider execution package나 closed `NodeCommand`를 workspace 실행 표면으로 재사용하지 않고 전용 typed Edge-Node request/result를 사용한다.
- canonical operation은 read/list/write/delete/command이며 Edge가 bounded typed request로 만들고 Node가 structured result를 반환한다.
- command는 fixed workspace cwd, process group, timeout, output cap과 environment allowlist를 가진다.
- path는 workspace root containment와 symlink escape 방지를 통과해야 한다.
- 외부 출력:
- plan/work/review/repair의 redacted 진행 요약과 최종 사용자 결과는 하나의 Anthropic stream에서 보일 수 있고, 긴 내부 stage 동안 endpoint-native ping으로 연결 liveness를 유지할 수 있다.
- internal provider reasoning, tool protocol, provider id, credential, raw command output과 stage terminal은 공개하지 않는다.
- 최종 response model은 caller가 선택한 public model id를 유지하고 terminal은 한 번만 emit한다.
- 금지:
- `tool_use` terminal로 외부 Claude에 internal workspace 작업을 넘기거나 두 번째 `/v1/messages`를 요구한다.
- “사용자 prompt 1회” 또는 “request_id 1개”만 확인하고 one-shot PASS로 판정한다.
- caller가 arbitrary Node, absolute path, command environment, provider credential 또는 preset 밖 model을 선택하게 한다.
- review defect를 숨기고 work candidate를 성공으로 반환하거나 provider 실패를 다른 stage/model로 암묵 fallback한다.
## Acceptance Scenarios
| ID | Milestone Task | Given | When | Then |
|----|----------------|-------|------|------|
| S01 | `single-ingress` | Claude가 작은 workspace 작업을 public preset model로 요청 | 작업이 최종 종료 | Edge가 관측한 `/v1/messages` POST가 정확히 1회이고 추가 caller ingress가 없다. |
| S02 | `preset-binding` | authorized Gemini, ornith-fast와 Mac workspace route가 있는 principal | preset을 list/admit/execute | fixed light plan/work/review/workspace binding이 immutable하게 고정되고 public model id가 유지되며 dynamic mode binding은 거부된다. |
| S03 | `stream-terminal` | 여러 internal provider stage가 response-start/content/terminal을 생성하고 stage 사이 대기가 발생 | outer Anthropic SSE를 관측 | redacted progress/ping으로 연결을 유지하고 private reasoning/tool wire 없이 outer envelope 하나, 충돌 없는 block 순서와 최종 terminal 하나만 보인다. |
| S04 | `workspace-binding` | 승인/미승인 workspace, 다른 Node/path와 symlink escape 후보 | request admission과 tool 실행 | 승인된 Mac workspace만 실행되고 임의 path/Node/escape는 provider/tool 실행 전에 거부된다. |
| S05 | `tool-executor` | read/list/write/delete/command 성공·실패·timeout·large output | Node tool을 실행 | typed result, containment, process cancel과 output bound가 일관되게 적용된다. |
| S06 | `tool-loop` | internal model이 여러 workspace tool call을 생성 | IOP가 결과를 stage에 반환 | tool loop가 IOP 내부에서 계속되고 Claude-facing `tool_use` terminal이나 두 번째 HTTP request가 없다. |
| S07 | `cleanup-observation` | 성공·오류·cancel 요청이 request artifact/process와 사용자 결과 파일을 생성 | terminal 정리를 수행 | request process와 `.iop/job` artifact만 정책대로 정리되고 사용자 결과는 보존되며 raw content 없이 stage/tool/total timing과 outcome이 연결된다. |
| S08 | `plan-stage` | immutable user task와 empty request job | plan stage 실행 | Gemini 3.6 Flash high가 작은 plan·검증 기준을 만들고 internal `plan.md` write가 성공한다. |
| S09 | `work-stage` | plan과 writable workspace | work stage 실행 | ornith-fast가 high 옵션 없이 plan을 읽고 실제 변경·검증과 completion candidate를 만든다. |
| S10 | `review-stage` | pass 또는 defect work candidate | review stage 실행 | Gemini 3.6 Flash high가 pass를 확정하거나 잔존 작업을 수정·재검증하고 final 결과를 만든다. |
| S11 | `error-cancel` | stage/request budget exhaustion, repetition/no-progress, malformed tool call, provider/tool timeout, output/context limit 또는 disconnect | 요청이 종료 | 추가 Claude 요청, 암묵 stage/model fallback이나 partial-success 없이 표준 error/cancel/length terminal과 내부 cancel로 수렴한다. |
| S12 | `claude-smoke` | 실제 Claude와 writable Mac test workspace | 작은 수정·검증 작업을 한 번 요청 | Gemini → ornith-fast → Gemini 순서, stage/total 순수 시간, 최종 파일/검증, ingress POST 1회와 terminal 1회를 redacted 로그로 재현한다. |
## Evidence Map
| Scenario | Required Evidence | `agent-task` 연결 | 완료 Evidence 기대 |
|----------|-------------------|------------------|---------------------------|
| S01 | Edge ingress counter, Claude invocation integration test와 Anthropic contract sync | `agent-task/m-iop-owned-single-request-agent-execution/single-ingress/` | `single-ingress` request-count=1/API contract evidence |
| S02 | preset decode/authorization/model echo/workspace snapshot test와 config contract sync | `agent-task/m-iop-owned-single-request-agent-execution/preset-binding/` | `preset-binding` immutable fixed-light binding evidence |
| S03 | multi-stage fragmented SSE와 single terminal test | `agent-task/m-iop-owned-single-request-agent-execution/stream-terminal/` | `stream-terminal` one-envelope/one-terminal evidence |
| S04 | workspace route/path/symlink admission table test | `agent-task/m-iop-owned-single-request-agent-execution/workspace-binding/` | `workspace-binding` fail-closed evidence |
| S05 | Node tool operation/process/output bound integration test, proto와 Edge-Node contract sync | `agent-task/m-iop-owned-single-request-agent-execution/tool-executor/` | `tool-executor` typed wire/success/error/cancel evidence |
| S06 | internal multi-tool round-trip test with zero public tool terminal | `agent-task/m-iop-owned-single-request-agent-execution/tool-loop/` | `tool-loop` no-external-continuation evidence |
| S07 | cleanup race, user-result preservation과 raw-free timing/log/metric allowlist test | `agent-task/m-iop-owned-single-request-agent-execution/cleanup-observation/` | `cleanup-observation` scoped lifecycle/timing evidence |
| S08 | Gemini plan request/options/artifact fixture | `agent-task/m-iop-owned-single-request-agent-execution/plan-stage/` | `plan-stage` high option과 small-plan evidence |
| S09 | ornith-fast tool work fixture와 high-option absence test | `agent-task/m-iop-owned-single-request-agent-execution/work-stage/` | `work-stage` actual workspace/verification evidence |
| S10 | review pass/defect/repair fixture와 finalization test | `agent-task/m-iop-owned-single-request-agent-execution/review-stage/` | `review-stage` review/repair evidence |
| S11 | budget/error/cancel/length/repetition terminal matrix test | `agent-task/m-iop-owned-single-request-agent-execution/error-cancel/` | `error-cancel` bounded/no-partial/no-second-request evidence |
| S12 | actual Claude, ingress counter, Edge/Node/provider stage+total timing log와 workspace before/after | `agent-task/m-iop-owned-single-request-agent-execution/claude-smoke/` | `claude-smoke` request-count=1 end-to-end/elapsed evidence |
공통 완료 검증은 최소 `go test -race -count=1 ./packages/go/config ./packages/go/streamgate ./apps/edge/internal/openai ./apps/edge/internal/service ./apps/node/internal/node ./apps/node/internal/transport`, 전용 workspace executor package test, `make proto`, `git diff --check`를 포함한다.
실제 provider smoke는 credential과 writable test workspace를 갖춘 Mac Node에서 실행하되 secret과 raw prompt/tool output을 tracked evidence에 기록하지 않는다.
## Cross-repo Dependencies
- 없음
## Drift Check
- [x] Milestone 기능 Task와 Acceptance Scenario가 일치한다.
- [x] Evidence Map이 code-review/complete.log에서 검증 가능하다.
- [x] agent-contract를 쓰는 경우 SDD에 계약 원문을 복제하지 않았다.
- [x] 사용자 리뷰가 필요한 항목은 없고 확정된 D01-D10을 반영했다.
## 사용자 리뷰 이력
- 2026-08-05: 사용자가 Claude→IOP 요청 정확히 1회, IOP/Mac Node-owned workspace tool execution, Gemini 3.6 Flash high plan → ornith-fast work → Gemini 3.6 Flash high review/잔존 수정과 Pi 제외를 최종 방향으로 확정했다.
## 작업 컨텍스트
- 표준선: 기존 Anthropic bridge, provider-pool authorization/lease, Stream Evidence Gate와 Edge-Node transport를 재사용하되 caller tool continuation을 one-shot 내부 tool runtime으로 대체한다.
- 구현 순서: preset/workspace config → Edge-Node tool wire와 Mac executor → single-request coordinator → plan/work/review stage → stream/error/cleanup → actual Claude smoke.
- 후속 SDD: [Heavy Plan/Review 실행과 검증 MVP](../../../phase/knowledge-tool-optimization-extension/milestones/knowledge-tool-validation-optimization.md)

View file

@ -128,5 +128,5 @@
- 표준선: Node는 execution-local liveness, local attempt fence와 probe evidence를 소유한다. Edge service는 provider lease·candidate eligibility를, ingress recovery host는 response commit·bounded retry를 소유한다. Control Plane은 projection을 소비할 수 있지만 canonical 실행 상태나 watchdog을 소유하지 않는다.
- 재사용 기준: OpenAI-compatible 경로는 [OpenAI-compatible 출력 검증 필터 SDD](../../knowledge-tool-optimization-extension/openai-compatible-output-validation-filters/SDD.md)의 StreamGate commit/recovery 경계를 사용한다. liveness failure는 Node 관측 결과를 소비하는 recovery cause/intent이며 별도 output content filter나 retry coordinator가 아니다.
- 현재 구현 차이: `response_stalled` failure/wire metadata, provider runtime health overlay와 `response_stall_timeout_ms`는 아직 구현되지 않았다. raw tunnel subscriber도 Node disconnect만으로 즉시 닫히지 않고 ingress wait timeout/cancel에 의존한다. 기존 `ProviderProber`, terminal emitter, provider tunnel release-once와 StreamGate recovery coordinator를 확장하며 구현 완료로 간주하지 않는다.
- 계획 분할 기준: Node observer/watchdog/probe와 execution/wire 변경을 한 slice로, Edge health overlay와 ingress recovery host 결합을 다른 slice로 계획한다. 후자는 plan 생성 시 [IOP 실행 프리셋과 Hot Path](../../knowledge-tool-optimization-extension/iop-hot-path-one-shot-execution/SDD.md)의 최신 OpenAI/StreamGate 변경을 다시 확인한다.
- 계획 분할 기준: Node observer/watchdog/probe와 execution/wire 변경을 한 slice로, Edge health overlay와 ingress recovery host 결합을 다른 slice로 계획한다. 후자는 plan 생성 시 [IOP 실행 프리셋과 Hot Path SDD](../../../archive/sdd/knowledge-tool-optimization-extension/iop-hot-path-one-shot-execution/SDD.md)의 최신 OpenAI/StreamGate 변경을 다시 확인한다.
- 후속 SDD: [요청 실행 로그와 Usage Ledger 기반 SDD](../request-execution-log-usage-ledger-foundation/SDD.md)

View file

@ -57,6 +57,9 @@ source_evidence:
- type: code
path: apps/edge/internal/openai/anthropic_types.go
notes: Anthropic request/response types, header validation, content block decode
- type: test
path: apps/edge/internal/openai/anthropic_bridge_test.go
notes: Claude Code beta/request mapping과 Gemini thought signature 왕복 검증
- type: code
path: apps/edge/internal/openai/principal.go
notes: Shared principal token hash auth for both OpenAI and Anthropic surfaces
@ -129,6 +132,7 @@ Edge가 OpenAI-compatible HTTP 요청을 받아 내부 `adapter + target` 실행
| Anthropic ingress | `POST /v1/messages` and `POST /anthropic/v1/messages` share one handler; the corresponding count-tokens paths share another. `/anthropic/v1/models`, and `/v1/models` with `anthropic-version`, return the Anthropic model-list shape. Wrong methods return `405 invalid_request_error`. |
| Anthropic caller auth | Anthropic ingress accepts `Authorization: Bearer <token>` or `X-Api-Key: <token>`. If both are present they must match; shared principal-token and legacy bearer fallback apply after this validation. |
| Anthropic provider-pool dispatch | Messages and count-tokens require a provider-pool model route. Native Messages requires `messages` capability and operation, while the Chat bridge requires `chat` capability and `chat_completions` operation; streaming and tools add their own capability checks. |
| Claude Code Chat bridge | Supported Claude Code beta headers are consumed at the bridge, adaptive High effort maps to Chat `reasoning_effort`, JSON schema output maps to `response_format`, Anthropic metadata/cache-control annotations are stripped, Gemini tool thought signatures round-trip through opaque tool-use ids, and unsigned private thinking replay is dropped only for generic Chat profiles that cannot represent it. |
| bounded ingress와 Stream Evidence Gate | Chat/Responses body를 첫 read 전에 최대 16 MiB로 제한한다. `openai.stream_evidence_gate.enabled=true`인 지원 경로는 response-start staging, filter arbitration, bounded recovery와 단일 terminal을 `runtime/stream-evidence-gate`에 위임한다. |
| repeat-resume request shape | A selected continuation uses only request-local assistant content/reasoning plus a fixed English directive. Chat emits assistant provenance followed by the directive; Responses emits assistant output/reasoning items and places the directive in `instructions`. Caller messages, `input`, and original `instructions` are excluded. |
| repeat history boundary | Chat and Responses use separate endpoint decoders to create a bounded raw-free role/channel/action snapshot from the current request only. User occurrences exclude assistant anchors; missing reasoning does not infer lineage or TTL state. |
@ -196,6 +200,7 @@ sequenceDiagram
- normalized run과 provider tunnel의 성공 dispatch는 actual `provider_id`, served target, resolved node id, effective attribution policy를 Edge-local result에 보존한다. strict attempt binding은 `provider_id`만 actual provider로 인정하고 adapter 또는 node id로 대체하지 않는다.
- provider-pool model group은 capacity + priority + availability 기준으로 provider candidate를 먼저 선택하고, 선택된 provider가 OpenAI-compatible 호출 방식을 지원하면 raw tunnel passthrough로 dispatch한다. Ollama/native provider가 선택되면 normalized `RunRequest` path로 dispatch한다.
- Anthropic Messages and count-tokens do not use legacy direct-route or single-target fallback. Native responses preserve provider status, allowed headers, and body/SSE bytes; bridge responses are converted between Anthropic Messages and Chat Completions shapes.
- Claude Code Messages requests may use adaptive thinking, `output_config.effort`, structured output, cache-control annotations, and supported beta headers. The Chat bridge consumes those headers, maps supported fields, and requires callers to replay opaque `tool_use.id` values unchanged so Gemini thought signatures can be restored on tool-result turns.
- provider capacity와 long-context slot은 model alias별이 아니라 `node_id + provider_id`별로 공유한다. queue pending 상한과 timeout은 Edge root `provider_pool` policy이며, lease 반환·refresh·disconnect/reconnect가 모든 model group waiter를 global enqueue 순서로 재평가한다.
- provider가 full이면 queue policy에 따라 대기하지만 live candidate가 모두 사라지면 즉시 unavailable로 수렴한다. Chat Completions와 Responses provider-pool 표면은 새 public status/field 없이 HTTP 502 `node_dispatch_error`를 유지한다.
- In legacy mode, `openai.provider_auth` stores only a forwarding rule and reads raw provider material from its request-time header; inbound IOP authorization is never reused. Managed mode rejects that rule and the caller header and uses only the sealed slot lease.
@ -270,3 +275,4 @@ sequenceDiagram
- 2026-08-01: Synchronized Anthropic ingress, provider-pool admission, usage boundaries, and Responses capability admission with the current handlers.
- 2026-08-02: Synchronized active managed projection auth, exact slot-route binding, lease acquisition/fencing, managed-versus-legacy credentials, safe slot/revision attribution, and the repaired managed API-key lease header canonicalization with source and deterministic two-profile qualification evidence.
- 2026-08-02: Removed IOP-owned workspace and Agent/CLI runtime semantics while preserving bounded metadata, managed projection, and credential lease behavior.
- 2026-08-05: Added Claude Code adaptive-effort/structured-output/cache-control bridge compatibility, stateless Gemini thought-signature tool round trips, and generic Chat replay handling for unsigned private thinking blocks.

View file

@ -3,7 +3,7 @@ test_env: dev
test_profile: edge-smoke
domain: edge
verification_type: smoke
last_rule_updated_at: 2026-08-05
last_rule_updated_at: 2026-08-06
---
# edge-smoke dev 테스트
@ -45,6 +45,8 @@ last_rule_updated_at: 2026-08-05
dev-runtime provider pool과 4-node 연결 상태를 점검할 때는 `agent-test/inventory-dev.yaml`의 machine-readable 값을 우선하고, 원격 runner `ssh toki@toki-labs.com``/Users/toki/agent-work/iop-dev` checkout을 기준으로 한다.
Claude Anthropic-compatible 단일 요청 Agent 실행을 검증할 때는 Claude가 보낸 실제 Edge `/v1/messages` ingress POST 수를 계수한다. PASS 기준은 정확히 1회이며, 같은 endpoint·사용자 요청·Claude 세션 또는 logical request id 하나는 이를 대체하지 않는다. plan/work/review를 caller나 외부 test harness가 각각 호출하거나 Claude-facing `tool_use`/tool result continuation으로 이어 간 과거 다중 요청 실험은 protocol bridge와 model/provider 연결 evidence로만 보존하고 단일 요청 acceptance로 재사용하지 않는다. 이 경로의 stage와 workspace tool loop는 IOP Edge/Mac Node가 소유하며 Agent-Ops dispatcher와 Pi를 실행 경로 또는 test harness로 사용하지 않는다.
- Edge config: `build/dev-runtime/edge.yaml`
- Edge id: `edge-toki-labs-dev`
- Control Plane HTTP: `http://127.0.0.1:18001`

View file

@ -2,7 +2,7 @@ inventory_id: inventory-dev
common_inventory: agent-test/inventory.yaml
test_env: dev
profile: dev-runtime-provider-pool
last_updated_at: "2026-08-05"
last_updated_at: "2026-08-06"
source:
remote_runner:
@ -102,6 +102,8 @@ model:
model: gemini-3.6-flash
effort: high
experimental_betas_disabled: true
acceptance_scope: anthropic_bridge_and_legacy_caller_continuation_only
iop_internal_single_request_plan_work_review: not_tested
text_single_turn: passed_exact_SCENARIO_OK
partial_streaming: passed_exact_STREAM_OK
partial_stream_event_count: 6
@ -111,8 +113,13 @@ model:
initial_quota_evidence: generate_content_free_tier_requests_limit_20
billing_enabled_direct_google_retry: passed_http_200
three_stage_claude_cycle:
status: unstable_reproducible_ornith_worker_stream_failure_on_retest
connection: single_claude_iop_anthropic_endpoint
status: historical_multi_request_cycle_completed_once_with_unstable_worker_retest
endpoint_topology: claude_code_via_one_iop_anthropic_endpoint
request_topology: caller_orchestrated_multiple_messages_requests
edge_v1_messages_post_count: exact_count_not_recorded_but_not_one
stage_continuation_owner: external_test_harness_and_claude_code
iop_internal_stage_loop: not_implemented_or_verified
route_02_single_request_acceptance: not_evidence
planner:
model: gemini-3.6-flash
effort: high
@ -175,8 +182,9 @@ model:
bounded_cycle_until_worker_failure_sec: 80.771179
pi_processes_observed: 0
isolated_runtime: stopped_and_logs_preserved
file_edit_end_to_end: passed_three_stage_claude_cycle
file_edit_end_to_end: passed_legacy_multi_request_three_stage_cycle
file_edit_fixture_changed: true
route_02_single_request_acceptance: not_run
pi_processes_observed: 0
ornith_fast_used: true
ornith_fast_shared_route_preserved: true

View file

@ -2,6 +2,7 @@ package openai
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
@ -9,6 +10,14 @@ import (
"iop/packages/go/config"
)
const anthropicBridgeToolIDPrefix = "iop_gts_"
type openAIChatToolExtraContent struct {
Google *struct {
ThoughtSignature string `json:"thought_signature"`
} `json:"google,omitempty"`
}
type openAIChatBridgeResponse struct {
ID string `json:"id"`
Model string `json:"model"`
@ -19,9 +28,10 @@ type openAIChatBridgeResponse struct {
ReasoningContent string `json:"reasoning_content"`
Reasoning string `json:"reasoning"`
ToolCalls []struct {
ID string `json:"id"`
Type string `json:"type"`
Function struct {
ID string `json:"id"`
Type string `json:"type"`
ExtraContent openAIChatToolExtraContent `json:"extra_content,omitempty"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
@ -56,7 +66,7 @@ func prepareAnthropicChatBridge(body []byte, target string, profile config.Concr
if req.TopK != nil {
return nil, req, fmt.Errorf("top_k is not supported by the Chat bridge")
}
if req.Thinking != nil && !profileSupportsAnthropicThinking(profile) {
if req.Thinking != nil && req.Thinking.Type == "enabled" && !profileSupportsAnthropicThinking(profile) {
return nil, req, fmt.Errorf("selected Chat profile does not support thinking")
}
@ -108,7 +118,6 @@ func prepareAnthropicChatBridge(body []byte, target string, profile config.Concr
if err := json.Unmarshal(req.Metadata, &metadata); err != nil {
return nil, req, fmt.Errorf("metadata must be an object")
}
chat["metadata"] = metadata
}
if len(req.Tools) > 0 {
tools := make([]map[string]any, 0, len(req.Tools))
@ -132,11 +141,28 @@ func prepareAnthropicChatBridge(body []byte, target string, profile config.Concr
chat["parallel_tool_calls"] = *parallel
}
}
if req.Thinking != nil {
if req.Thinking != nil && req.Thinking.Type == "enabled" {
chat["think"] = true
chat["include_reasoning"] = true
chat["thinking_token_budget"] = req.Thinking.BudgetTokens
}
if req.OutputConfig != nil {
if req.OutputConfig.Effort != "" {
chat["reasoning_effort"] = req.OutputConfig.Effort
}
if req.OutputConfig.Format != nil {
var schema map[string]any
if err := json.Unmarshal(req.OutputConfig.Format.Schema, &schema); err != nil {
return nil, req, fmt.Errorf("decode output_config.format.schema: %w", err)
}
chat["response_format"] = map[string]any{
"type": "json_schema",
"json_schema": map[string]any{
"name": "response", "strict": true, "schema": schema,
},
}
}
}
encoded, err := json.Marshal(chat)
if err != nil {
return nil, req, fmt.Errorf("encode Chat bridge request: %w", err)
@ -176,7 +202,8 @@ func anthropicMessageToChat(role string, blocks []anthropicContentBlock, profile
if block.IsError {
text = "Error: " + text
}
out = append(out, map[string]any{"role": "tool", "tool_call_id": block.ToolUseID, "content": text})
toolUseID, _, _ := decodeAnthropicBridgeToolID(block.ToolUseID)
out = append(out, map[string]any{"role": "tool", "tool_call_id": toolUseID, "content": text})
default:
return nil, fmt.Errorf("content block %q is invalid for a user message", block.Type)
}
@ -198,18 +225,27 @@ func anthropicAssistantToChat(blocks []anthropicContentBlock, profile config.Con
case "text":
content = append(content, map[string]any{"type": "text", "text": block.Text})
case "thinking":
if !profileSupportsAnthropicThinking(profile) {
return nil, fmt.Errorf("selected Chat profile does not support thinking blocks")
}
if block.Signature != "" {
return nil, fmt.Errorf("signed thinking blocks cannot be represented by the Chat bridge")
}
if !profileSupportsAnthropicThinking(profile) {
// Claude Code replays unsigned thinking blocks returned by the
// previous turn. Generic Chat profiles cannot represent those
// blocks, and dropping private reasoning preserves the visible
// assistant/tool conversation needed for the next turn.
continue
}
reasoning = append(reasoning, block.Thinking)
case "tool_use":
toolCalls = append(toolCalls, map[string]any{
"id": block.ID, "type": "function",
toolID, thoughtSignature, encoded := decodeAnthropicBridgeToolID(block.ID)
toolCall := map[string]any{
"id": toolID, "type": "function",
"function": map[string]any{"name": block.Name, "arguments": string(block.Input)},
})
}
if encoded {
toolCall["extra_content"] = openAIChatThoughtSignature(thoughtSignature)
}
toolCalls = append(toolCalls, toolCall)
default:
return nil, fmt.Errorf("content block %q is invalid for an assistant message", block.Type)
}
@ -306,7 +342,8 @@ func convertChatResponseToAnthropic(body []byte, requestModel string) (anthropic
if err := json.Unmarshal([]byte(call.Function.Arguments), &input); err != nil {
return anthropicMessageResponse{}, fmt.Errorf("decode Chat tool arguments: %w", err)
}
content = append(content, map[string]any{"type": "tool_use", "id": call.ID, "name": call.Function.Name, "input": input})
toolID := encodeAnthropicBridgeToolID(call.ID, call.ExtraContent)
content = append(content, map[string]any{"type": "tool_use", "id": toolID, "name": call.Function.Name, "input": input})
}
stopReason, err := anthropicStopReason(choice.FinishReason)
if err != nil {
@ -326,6 +363,41 @@ func convertChatResponseToAnthropic(body []byte, requestModel string) (anthropic
}, nil
}
type anthropicBridgeToolID struct {
ID string `json:"id"`
ThoughtSignature string `json:"thought_signature"`
}
func encodeAnthropicBridgeToolID(id string, extra openAIChatToolExtraContent) string {
if extra.Google == nil || extra.Google.ThoughtSignature == "" {
return id
}
payload, err := json.Marshal(anthropicBridgeToolID{ID: id, ThoughtSignature: extra.Google.ThoughtSignature})
if err != nil {
return id
}
return anthropicBridgeToolIDPrefix + base64.RawURLEncoding.EncodeToString(payload)
}
func decodeAnthropicBridgeToolID(id string) (string, string, bool) {
if !strings.HasPrefix(id, anthropicBridgeToolIDPrefix) {
return id, "", false
}
payload, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(id, anthropicBridgeToolIDPrefix))
if err != nil {
return id, "", false
}
var decoded anthropicBridgeToolID
if err := json.Unmarshal(payload, &decoded); err != nil || decoded.ID == "" || decoded.ThoughtSignature == "" {
return id, "", false
}
return decoded.ID, decoded.ThoughtSignature, true
}
func openAIChatThoughtSignature(signature string) map[string]any {
return map[string]any{"google": map[string]any{"thought_signature": signature}}
}
func openAIChatContentText(content any) (string, error) {
switch value := content.(type) {
case nil:
@ -371,7 +443,13 @@ func anthropicStopReason(finishReason *string) (*string, error) {
func convertChatErrorToAnthropic(body []byte) anthropicErrorResponse {
var provider openAIChatBridgeError
if json.Unmarshal(body, &provider) == nil && strings.TrimSpace(provider.Error.Message) != "" {
if json.Unmarshal(body, &provider) != nil || strings.TrimSpace(provider.Error.Message) == "" {
var providers []openAIChatBridgeError
if json.Unmarshal(body, &providers) == nil && len(providers) > 0 {
provider = providers[0]
}
}
if strings.TrimSpace(provider.Error.Message) != "" {
errorType := strings.TrimSpace(provider.Error.Type)
if errorType == "" {
errorType = "api_error"

View file

@ -3,6 +3,7 @@ package openai
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
@ -138,6 +139,38 @@ func TestAnthropicChatBridgeThinkingCapabilityAndResponse(t *testing.T) {
}
}
func TestAnthropicChatBridgeDropsUnsignedThinkingReplayForGenericProfile(t *testing.T) {
candidate := anthropicTestCandidate(t, "openai")
candidate.ActualModel = "served-chat"
fake := &providerFakeRunService{
poolDispatchPath: string(edgeservice.ProviderPoolPathTunnel),
poolSelectedCandidate: candidate,
tunnelFrames: anthropicTunnelFrames(http.StatusOK, "application/json",
[]byte(`{"id":"chat_replay","choices":[{"message":{"role":"assistant","content":"done"},"finish_reason":"stop"}],"usage":{"prompt_tokens":8,"completion_tokens":1}}`)),
}
srv := NewServer(config.EdgeOpenAIConf{}, fake, nil)
srv.SetModelCatalog([]config.ModelCatalogEntry{{ID: "claude-route", Providers: map[string]string{"chat": "served-chat"}}})
body := `{"model":"claude-route","max_tokens":64,"thinking":{"type":"adaptive"},"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"private prior reasoning","signature":""},{"type":"text","text":"I will inspect the file."}]},{"role":"user","content":"continue"}]}`
w := serveAnthropicRequest(srv, "/v1/messages", body)
if w.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
var chat map[string]any
if err := json.Unmarshal(fake.tunnelBodiesSnapshot()[0], &chat); err != nil {
t.Fatal(err)
}
messages := anthropicAnySlice(t, chat["messages"])
assistant := anthropicAnyMap(t, messages[0])
if _, ok := assistant["reasoning_content"]; ok {
t.Fatalf("generic Chat replay leaked unsupported reasoning: %+v", assistant)
}
content := anthropicAnySlice(t, assistant["content"])
if got := anthropicAnyMap(t, content[0])["text"]; got != "I will inspect the file." {
t.Fatalf("visible assistant content changed: %+v", assistant)
}
}
func TestAnthropicChatBridgeRejectsUnsupportedBeforeWire(t *testing.T) {
for _, tc := range []struct {
name string
@ -148,7 +181,7 @@ func TestAnthropicChatBridgeRejectsUnsupportedBeforeWire(t *testing.T) {
{name: "unknown block", body: `{"model":"claude-route","max_tokens":16,"messages":[{"role":"user","content":[{"type":"search_result","content":"unknown"}]}]}`},
{name: "unknown field", body: `{"model":"claude-route","max_tokens":16,"vendor_extension":true,"messages":[{"role":"user","content":"hello"}]}`},
{name: "thinking capability", body: `{"model":"claude-route","max_tokens":16,"thinking":{"type":"enabled","budget_tokens":8},"messages":[{"role":"user","content":"hello"}]}`},
{name: "beta", body: `{"model":"claude-route","max_tokens":16,"messages":[{"role":"user","content":"hello"}]}`, beta: "prompt-caching-2024-07-31"},
{name: "unknown beta", body: `{"model":"claude-route","max_tokens":16,"messages":[{"role":"user","content":"hello"}]}`, beta: "unknown-beta-2099-01-01"},
} {
t.Run(tc.name, func(t *testing.T) {
candidate := anthropicTestCandidate(t, "openai")
@ -175,20 +208,158 @@ func TestAnthropicChatBridgeRejectsUnsupportedBeforeWire(t *testing.T) {
}
}
func TestAnthropicChatBridgeProviderError(t *testing.T) {
candidate := anthropicTestCandidate(t, "openai")
candidate.ActualModel = "served-chat"
providerError := []byte(`{"error":{"type":"rate_limit_error","message":"slow down","code":429}}`)
func TestAnthropicChatBridgeClaudeCodeRequest(t *testing.T) {
candidate := anthropicTestCandidate(t, "gemini")
candidate.ActualModel = "gemini-3.6-flash"
providerResponse := []byte(`{"id":"chat_claude_code","choices":[{"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":11,"completion_tokens":2}}`)
fake := &providerFakeRunService{
poolDispatchPath: string(edgeservice.ProviderPoolPathTunnel),
poolSelectedCandidate: candidate,
tunnelFrames: anthropicTunnelFrames(http.StatusTooManyRequests, "application/json", providerError[:13], providerError[13:]),
tunnelFrames: anthropicTunnelFrames(http.StatusOK, "application/json", providerResponse),
}
srv := NewServer(config.EdgeOpenAIConf{}, fake, nil)
srv.SetModelCatalog([]config.ModelCatalogEntry{{ID: "claude-route", Providers: map[string]string{"chat": "served-chat"}}})
w := serveAnthropicRequest(srv, "/v1/messages", `{"model":"claude-route","max_tokens":16,"messages":[{"role":"user","content":"hello"}]}`)
if w.Code != http.StatusTooManyRequests || !strings.Contains(w.Body.String(), `"type":"rate_limit_error"`) || !strings.Contains(w.Body.String(), `"message":"slow down"`) {
t.Fatalf("provider error mapping mismatch: status=%d body=%s", w.Code, w.Body.String())
srv.SetModelCatalog([]config.ModelCatalogEntry{{ID: "gemini-route", Providers: map[string]string{"gemini": "gemini-3.6-flash"}}})
body := `{
"model":"gemini-route",
"max_tokens":1024,
"system":[
{"type":"text","text":"base"},
{"type":"text","text":"cached","cache_control":{"type":"ephemeral"}}
],
"messages":[{"role":"user","content":[
{"type":"text","text":"hello"},
{"type":"text","text":"cached prompt","cache_control":{"type":"ephemeral"}}
]}],
"thinking":{"type":"adaptive"},
"output_config":{"effort":"high","format":{"type":"json_schema","schema":{"type":"object","properties":{"title":{"type":"string"}},"required":["title"],"additionalProperties":false}}},
"metadata":{"user_id":"claude-code"},
"tools":[{"name":"Read","description":"Read a file","input_schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"file_path":{"type":"string"}},"required":["file_path"],"additionalProperties":false}}]
}`
req := newAnthropicRequest(http.MethodPost, "/v1/messages", body)
req.Header.Set(anthropicBetaHeader, strings.Join([]string{
"claude-code-20250219",
"interleaved-thinking-2025-05-14",
"mid-conversation-system-2026-04-07",
"effort-2025-11-24",
"structured-outputs-2025-12-15",
}, ","))
w := serveAnthropicHTTPRequest(srv, req)
if w.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
var chat map[string]any
if err := json.Unmarshal(fake.tunnelBodiesSnapshot()[0], &chat); err != nil {
t.Fatal(err)
}
if chat["model"] != "gemini-3.6-flash" || chat["reasoning_effort"] != "high" {
t.Fatalf("Claude Code model or effort mapping mismatch: %+v", chat)
}
for _, key := range []string{"think", "include_reasoning", "thinking_token_budget", "output_config"} {
if _, ok := chat[key]; ok {
t.Fatalf("adaptive request leaked unsupported field %q: %+v", key, chat)
}
}
if _, ok := chat["metadata"]; ok {
t.Fatalf("Anthropic metadata must not be forwarded to Chat providers: %+v", chat)
}
responseFormat := anthropicAnyMap(t, chat["response_format"])
jsonSchema := anthropicAnyMap(t, responseFormat["json_schema"])
schema := anthropicAnyMap(t, jsonSchema["schema"])
if responseFormat["type"] != "json_schema" || jsonSchema["name"] != "response" || jsonSchema["strict"] != true || schema["type"] != "object" {
t.Fatalf("structured output mapping mismatch: %+v", responseFormat)
}
messages := anthropicAnySlice(t, chat["messages"])
if len(messages) != 2 || anthropicAnyMap(t, messages[0])["content"] != "base\ncached" {
t.Fatalf("cache-controlled system mapping mismatch: %+v", messages)
}
tools := anthropicAnySlice(t, chat["tools"])
if anthropicAnyMap(t, anthropicAnyMap(t, tools[0])["function"])["name"] != "Read" {
t.Fatalf("Claude Code tool mapping mismatch: %+v", tools)
}
}
func TestAnthropicChatBridgeGeminiThoughtSignatureRoundTrip(t *testing.T) {
providerResponse := []byte(`{
"id":"chat_signature",
"choices":[{"message":{"role":"assistant","tool_calls":[{
"id":"call_1",
"type":"function",
"function":{"name":"Bash","arguments":"{\"command\":\"printf 5 > answer.txt\"}"},
"extra_content":{"google":{"thought_signature":"signature-1"}}
}]},"finish_reason":"tool_calls"}],
"usage":{"prompt_tokens":9,"completion_tokens":4}
}`)
response, err := convertChatResponseToAnthropic(providerResponse, "gemini-route")
if err != nil {
t.Fatal(err)
}
if len(response.Content) != 1 {
t.Fatalf("content blocks=%d, want 1", len(response.Content))
}
encodedID, ok := response.Content[0]["id"].(string)
if !ok || encodedID == "call_1" {
t.Fatalf("thought signature was not encoded in tool_use id: %+v", response.Content[0])
}
toolID, signature, encoded := decodeAnthropicBridgeToolID(encodedID)
if !encoded || toolID != "call_1" || signature != "signature-1" {
t.Fatalf("encoded tool id mismatch: id=%q signature=%q encoded=%v", toolID, signature, encoded)
}
requestBody := fmt.Sprintf(`{
"model":"gemini-route",
"max_tokens":1024,
"messages":[
{"role":"assistant","content":[{"type":"tool_use","id":%q,"name":"Bash","input":{"command":"printf 5 > answer.txt"}}]},
{"role":"user","content":[{"type":"tool_result","tool_use_id":%q,"content":"done","is_error":false,"cache_control":{"type":"ephemeral"}}]}
]
}`, encodedID, encodedID)
profile, err := config.ResolveProtocolProfile("gemini", "", config.BuiltInProtocolProfileCatalog())
if err != nil {
t.Fatal(err)
}
bridged, _, err := prepareAnthropicChatBridge([]byte(requestBody), "gemini-3.6-flash", profile)
if err != nil {
t.Fatal(err)
}
var chat map[string]any
if err := json.Unmarshal(bridged, &chat); err != nil {
t.Fatal(err)
}
messages := anthropicAnySlice(t, chat["messages"])
assistant := anthropicAnyMap(t, messages[0])
toolCall := anthropicAnyMap(t, anthropicAnySlice(t, assistant["tool_calls"])[0])
extra := anthropicAnyMap(t, anthropicAnyMap(t, toolCall["extra_content"])["google"])
toolResult := anthropicAnyMap(t, messages[1])
if toolCall["id"] != "call_1" || extra["thought_signature"] != "signature-1" || toolResult["tool_call_id"] != "call_1" {
t.Fatalf("Gemini thought signature round trip mismatch: assistant=%+v tool_result=%+v", assistant, toolResult)
}
}
func TestAnthropicChatBridgeProviderError(t *testing.T) {
for _, tc := range []struct {
name string
body string
}{
{name: "object", body: `{"error":{"type":"rate_limit_error","message":"slow down","code":429}}`},
{name: "Google array", body: `[{"error":{"type":"rate_limit_error","message":"slow down","code":429}}]`},
} {
t.Run(tc.name, func(t *testing.T) {
candidate := anthropicTestCandidate(t, "openai")
candidate.ActualModel = "served-chat"
providerError := []byte(tc.body)
fake := &providerFakeRunService{
poolDispatchPath: string(edgeservice.ProviderPoolPathTunnel),
poolSelectedCandidate: candidate,
tunnelFrames: anthropicTunnelFrames(http.StatusTooManyRequests, "application/json", providerError[:13], providerError[13:]),
}
srv := NewServer(config.EdgeOpenAIConf{}, fake, nil)
srv.SetModelCatalog([]config.ModelCatalogEntry{{ID: "claude-route", Providers: map[string]string{"chat": "served-chat"}}})
w := serveAnthropicRequest(srv, "/v1/messages", `{"model":"claude-route","max_tokens":16,"messages":[{"role":"user","content":"hello"}]}`)
if w.Code != http.StatusTooManyRequests || !strings.Contains(w.Body.String(), `"type":"rate_limit_error"`) || !strings.Contains(w.Body.String(), `"message":"slow down"`) {
t.Fatalf("provider error mapping mismatch: status=%d body=%s", w.Code, w.Body.String())
}
})
}
}
@ -344,6 +515,43 @@ func TestAnthropicChatBridgeStreamStopsAtTerminalWithinFrame(t *testing.T) {
}
}
func TestAnthropicChatBridgeStreamEncodesGeminiThoughtSignature(t *testing.T) {
w := httptest.NewRecorder()
stream := newAnthropicBridgeStream(w, "gemini-route")
payload := `data: {"id":"chat_signature","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"Bash","arguments":"{\"command\":\"printf 5 > answer.txt\"}"},"extra_content":{"google":{"thought_signature":"signature-1"}}}]},"finish_reason":"tool_calls"}]}` + "\n\n" +
"data: [DONE]\n\n"
if err := stream.Feed([]byte(payload)); err != nil {
t.Fatal(err)
}
var encodedID string
for _, event := range bytes.Split(w.Body.Bytes(), []byte("\n\n")) {
var data []byte
for _, line := range bytes.Split(event, []byte("\n")) {
if bytes.HasPrefix(line, []byte("data: ")) {
data = bytes.TrimPrefix(line, []byte("data: "))
}
}
if len(data) == 0 {
continue
}
var item struct {
Type string `json:"type"`
ContentBlock struct {
Type string `json:"type"`
ID string `json:"id"`
} `json:"content_block"`
}
if json.Unmarshal(data, &item) == nil && item.Type == "content_block_start" && item.ContentBlock.Type == "tool_use" {
encodedID = item.ContentBlock.ID
}
}
toolID, signature, encoded := decodeAnthropicBridgeToolID(encodedID)
if !encoded || toolID != "call_1" || signature != "signature-1" {
t.Fatalf("stream signature encoding mismatch: encoded_id=%q id=%q signature=%q encoded=%v body=%s", encodedID, toolID, signature, encoded, w.Body.String())
}
}
func newAnthropicRequest(method, path, body string) *http.Request {
req := httptest.NewRequest(method, path, bytes.NewBufferString(body))
req.Header.Set(anthropicVersionHeader, anthropicSupportedVersion)

View file

@ -69,7 +69,7 @@ func (s *Server) handleAnthropicMessages(w http.ResponseWriter, r *http.Request)
return
}
defer r.Body.Close()
if err := validateAnthropicHeaders(r, false); err != nil {
if err := validateAnthropicHeaders(r); err != nil {
writeAnthropicError(w, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
@ -188,7 +188,7 @@ func (s *Server) handleAnthropicCountTokens(w http.ResponseWriter, r *http.Reque
return
}
defer r.Body.Close()
if err := validateAnthropicHeaders(r, false); err != nil {
if err := validateAnthropicHeaders(r); err != nil {
writeAnthropicError(w, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
@ -334,7 +334,7 @@ func (s *Server) buildAnthropicPoolRequest(
if operation != config.OperationMessages {
return tunnelReq, newAnthropicClientError("not_supported_error", fmt.Errorf("selected Chat profile has no native count-tokens operation"))
}
if err := validateAnthropicHeaders(r, true); err != nil {
if err := validateAnthropicHeaders(r); err != nil {
return tunnelReq, newAnthropicClientError("invalid_request_error", err)
}
bridged, _, err := prepareAnthropicChatBridge(body, selected.ActualModel, profile)

View file

@ -25,9 +25,10 @@ type openAIChatStreamChunk struct {
ReasoningContent string `json:"reasoning_content"`
Reasoning string `json:"reasoning"`
ToolCalls []struct {
Index int `json:"index"`
ID string `json:"id"`
Function struct {
Index int `json:"index"`
ID string `json:"id"`
ExtraContent openAIChatToolExtraContent `json:"extra_content,omitempty"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
@ -43,9 +44,10 @@ type openAIChatStreamChunk struct {
}
type anthropicBridgeToolState struct {
id string
name string
arguments strings.Builder
id string
name string
extraContent openAIChatToolExtraContent
arguments strings.Builder
}
type anthropicBridgeStream struct {
@ -157,6 +159,9 @@ func (s *anthropicBridgeStream) consumeSSEEvent(event []byte) error {
if delta.Function.Name != "" {
state.name = delta.Function.Name
}
if delta.ExtraContent.Google != nil && delta.ExtraContent.Google.ThoughtSignature != "" {
state.extraContent = delta.ExtraContent
}
state.arguments.WriteString(delta.Function.Arguments)
}
if choice.FinishReason != nil {
@ -246,7 +251,10 @@ func (s *anthropicBridgeStream) emitTools() error {
}
if err := writeAnthropicSSEEvent(s.w, "content_block_start", map[string]any{
"type": "content_block_start", "index": s.nextBlock,
"content_block": map[string]any{"type": "tool_use", "id": tool.id, "name": tool.name, "input": map[string]any{}},
"content_block": map[string]any{
"type": "tool_use", "id": encodeAnthropicBridgeToolID(tool.id, tool.extraContent),
"name": tool.name, "input": map[string]any{},
},
}); err != nil {
return err
}

View file

@ -18,9 +18,12 @@ const (
var supportedAnthropicBetas = map[string]struct{}{
"claude-code-20250219": {},
"effort-2025-11-24": {},
"fine-grained-tool-streaming-2025-05-14": {},
"interleaved-thinking-2025-05-14": {},
"mid-conversation-system-2026-04-07": {},
"prompt-caching-2024-07-31": {},
"structured-outputs-2025-12-15": {},
}
type anthropicRequestEnvelope struct {
@ -41,6 +44,7 @@ type anthropicMessageRequest struct {
Tools []anthropicTool `json:"tools,omitempty"`
ToolChoice *anthropicToolChoice `json:"tool_choice,omitempty"`
Thinking *anthropicThinkingConfig `json:"thinking,omitempty"`
OutputConfig *anthropicOutputConfig `json:"output_config,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
}
@ -50,9 +54,10 @@ type anthropicInputMessage struct {
}
type anthropicTool struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
InputSchema json.RawMessage `json:"input_schema"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
InputSchema json.RawMessage `json:"input_schema"`
CacheControl json.RawMessage `json:"cache_control,omitempty"`
}
type anthropicToolChoice struct {
@ -63,7 +68,17 @@ type anthropicToolChoice struct {
type anthropicThinkingConfig struct {
Type string `json:"type"`
BudgetTokens int `json:"budget_tokens"`
BudgetTokens int `json:"budget_tokens,omitempty"`
}
type anthropicOutputConfig struct {
Effort string `json:"effort,omitempty"`
Format *anthropicOutputFormat `json:"format,omitempty"`
}
type anthropicOutputFormat struct {
Type string `json:"type"`
Schema json.RawMessage `json:"schema"`
}
type anthropicContentBlock struct {
@ -144,7 +159,7 @@ func writeAnthropicModels(w http.ResponseWriter, models []advertisedModel) {
})
}
func validateAnthropicHeaders(r *http.Request, bridge bool) error {
func validateAnthropicHeaders(r *http.Request) error {
version := strings.TrimSpace(r.Header.Get(anthropicVersionHeader))
if version == "" {
return fmt.Errorf("%s header is required", strings.ToLower(anthropicVersionHeader))
@ -152,13 +167,9 @@ func validateAnthropicHeaders(r *http.Request, bridge bool) error {
if version != anthropicSupportedVersion {
return fmt.Errorf("unsupported anthropic-version %q", version)
}
betas, err := anthropicBetaValues(r.Header.Values(anthropicBetaHeader))
if err != nil {
if _, err := anthropicBetaValues(r.Header.Values(anthropicBetaHeader)); err != nil {
return err
}
if bridge && len(betas) > 0 {
return fmt.Errorf("anthropic-beta is not supported by the Chat bridge")
}
return nil
}
@ -247,10 +258,35 @@ func decodeAnthropicMessageRequest(body []byte, requireMaxTokens bool) (anthropi
return req, err
}
if req.Thinking != nil {
if req.Thinking.Type != "enabled" || req.Thinking.BudgetTokens <= 0 {
switch req.Thinking.Type {
case "adaptive":
if req.Thinking.BudgetTokens != 0 {
return req, fmt.Errorf("adaptive thinking does not accept budget_tokens")
}
case "enabled":
if req.Thinking.BudgetTokens <= 0 {
return req, fmt.Errorf("thinking must be enabled with a positive budget_tokens")
}
default:
return req, fmt.Errorf("thinking must be enabled with a positive budget_tokens")
}
}
if req.OutputConfig != nil {
switch req.OutputConfig.Effort {
case "", "low", "medium", "high":
default:
return req, fmt.Errorf("output_config.effort must be low, medium, or high")
}
if format := req.OutputConfig.Format; format != nil {
if format.Type != "json_schema" {
return req, fmt.Errorf("output_config.format.type must be json_schema")
}
trimmed := bytes.TrimSpace(format.Schema)
if len(trimmed) == 0 || trimmed[0] != '{' || !json.Valid(trimmed) {
return req, fmt.Errorf("output_config.format.schema must be an object")
}
}
}
return req, nil
}
@ -322,8 +358,9 @@ func decodeAnthropicContentBlock(raw json.RawMessage) (anthropicContentBlock, er
switch kind.Type {
case "text":
var block struct {
Type string `json:"type"`
Text string `json:"text"`
Type string `json:"type"`
Text string `json:"text"`
CacheControl json.RawMessage `json:"cache_control,omitempty"`
}
if err := decodeStrictJSON(raw, &block); err != nil {
return anthropicContentBlock{}, err
@ -331,8 +368,9 @@ func decodeAnthropicContentBlock(raw json.RawMessage) (anthropicContentBlock, er
return anthropicContentBlock{Type: block.Type, Text: block.Text}, nil
case "image":
var block struct {
Type string `json:"type"`
Source anthropicImageSource `json:"source"`
Type string `json:"type"`
Source anthropicImageSource `json:"source"`
CacheControl json.RawMessage `json:"cache_control,omitempty"`
}
if err := decodeStrictJSON(raw, &block); err != nil {
return anthropicContentBlock{}, err
@ -351,10 +389,11 @@ func decodeAnthropicContentBlock(raw json.RawMessage) (anthropicContentBlock, er
return anthropicContentBlock{Type: block.Type, Source: &block.Source}, nil
case "tool_use":
var block struct {
Type string `json:"type"`
ID string `json:"id"`
Name string `json:"name"`
Input json.RawMessage `json:"input"`
Type string `json:"type"`
ID string `json:"id"`
Name string `json:"name"`
Input json.RawMessage `json:"input"`
CacheControl json.RawMessage `json:"cache_control,omitempty"`
}
if err := decodeStrictJSON(raw, &block); err != nil {
return anthropicContentBlock{}, err
@ -365,10 +404,11 @@ func decodeAnthropicContentBlock(raw json.RawMessage) (anthropicContentBlock, er
return anthropicContentBlock{Type: block.Type, ID: block.ID, Name: block.Name, Input: block.Input}, nil
case "tool_result":
var block struct {
Type string `json:"type"`
ToolUseID string `json:"tool_use_id"`
Content json.RawMessage `json:"content,omitempty"`
IsError bool `json:"is_error,omitempty"`
Type string `json:"type"`
ToolUseID string `json:"tool_use_id"`
Content json.RawMessage `json:"content,omitempty"`
IsError bool `json:"is_error,omitempty"`
CacheControl json.RawMessage `json:"cache_control,omitempty"`
}
if err := decodeStrictJSON(raw, &block); err != nil {
return anthropicContentBlock{}, err
@ -379,9 +419,10 @@ func decodeAnthropicContentBlock(raw json.RawMessage) (anthropicContentBlock, er
return anthropicContentBlock{Type: block.Type, ToolUseID: block.ToolUseID, Content: block.Content, IsError: block.IsError}, nil
case "thinking":
var block struct {
Type string `json:"type"`
Thinking string `json:"thinking"`
Signature string `json:"signature,omitempty"`
Type string `json:"type"`
Thinking string `json:"thinking"`
Signature string `json:"signature,omitempty"`
CacheControl json.RawMessage `json:"cache_control,omitempty"`
}
if err := decodeStrictJSON(raw, &block); err != nil {
return anthropicContentBlock{}, err

View file

@ -86,7 +86,7 @@ func (s *Server) handleModels(w http.ResponseWriter, r *http.Request) {
return
}
if anthropic {
if err := validateAnthropicHeaders(r, false); err != nil {
if err := validateAnthropicHeaders(r); err != nil {
writeAnthropicError(w, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}