Merge branch 'release/dev-1008'

This commit is contained in:
toki 2026-08-14 02:46:51 +09:00
commit 91560f37ce
25 changed files with 984 additions and 47 deletions

View file

@ -40,7 +40,7 @@ Client protobuf wire는 hello-only를 유지하고, post-bootstrap slot write는
- Principal bootstrap is an offline, host-local CLI operation. It returns the raw IOP token once; no WebSocket or remote HTTP bootstrap route exists.
- Post-bootstrap slot and route lifecycle uses the dedicated credential HTTPS listener only. The listener presents the configured server certificate, and callers validate its CA and server name.
- Every credential request requires `Authorization: Bearer <IOP token>`. Slot create and rotate accept the provider secret only as an `application/octet-stream` body; list, disable, enable, revoke, and route operations never return provider plaintext.
- Slot mutations use an explicit expected revision. A successful mutation rebuilds the secret-free principal projection and synchronizes it to connected managed Edges before the operation is treated as complete.
- Slot mutations and `POST /v1/credentials/routes/{route_id}/update` use an explicit `IOP-Expected-Revision`. Route update accepts the complete slot/alias/profile/upstream/resource-selector binding as JSON and applies it with CAS. A successful mutation rebuilds the secret-free principal projection and synchronizes it to connected managed Edges before the operation is treated as complete.
- TLS private keys, the at-rest encryption keyring, the lease issuer private key, and raw principal/provider credentials are external mounts. They are never tracked config or Client wire fields.
## 필드 의미

View file

@ -51,7 +51,7 @@ tracked config에는 public 예시와 기본 구조만 두고, 실제 endpoint/c
- `ConcreteProtocolProfile.ResolveOperationURL(op)`는 완성된 resolved upstream URL을 반환한다. absolute operation URL은 그대로 보존하며 relative operation path는 normalized base URL에 1회 join된다. 표기된 `/v1/...` 값은 return value가 아니라 operation-path input이다 (`models` → `GET /v1/models` 또는 `GET /anthropic/v1/models`, `chat_completions``POST /v1/chat/completions`, `messages``POST /v1/messages`, `count_tokens``POST /v1/messages/count_tokens`, `responses``POST /v1/responses`).
- `validOperationsByDriver`는 driver별 허용 operation의 closed set이다. `openai_chat``models`, `chat_completions`, `responses`, `count_tokens`를 허용한다. `anthropic_messages``models`, `messages`, `count_tokens`를 허용한다. `openai_responses``models`, `responses`, `count_tokens`를 허용한다.
- `openai.provider_auth` is a legacy-mode-only request-time raw provider token forwarding rule. `enabled=false` is the default; when enabled in legacy mode, omitted fields resolve to `from_header=X-IOP-Provider-Authorization`, `target_header=Authorization`, `scheme=Bearer`, and `required=true`. Managed mode rejects this configuration and rejects a caller-supplied legacy provider credential header.
- `openai.stream_evidence_gate` configures request-local Recovery Coordinator limits, the ingress snapshot bound, and optional semantic policy. Every supported Chat Completions, normalized Responses, provider tunnel, provider-pool, and tool-validation response already uses the `packages/go/streamgate` request runtime as its sole liveness owner. `enabled` defaults to false and controls only configured semantic filter registration/capability admission; false preserves endpoint-native compatibility inside the same runtime and does not restore a legacy response or retry owner. `max_request_fault_recovery`는 요청당 전체 fault recovery 상한(`0..3`, omitted 기본값 3, explicit 0은 모든 fault recovery 비활성화)이다. `max_strategy_fault_recovery`는 fault strategy(exact_replay/continuation_repair/schema_repair)별 상한(`0..max_request_fault_recovery`, omitted 기본값은 effective request total 상속, explicit 0은 해당 strategy 비활성화)이며 request-start 시점에 immutable runtime option snapshot으로 각 fault strategy에 동일하게 적용된다. `max_ingress_snapshot_bytes`는 ingress snapshot 바이트 상한(`1..16777216` [16 MiB], omitted/0 기본값 16 MiB)이다. `environment`는 request-start selector snapshot이며 `dev|dev-corp`만 허용하고 omitted 기본값은 `dev`다. `filters[]`는 unique `filter` (`repeat_guard|schema_gate|provider_error`) policy이다. `enabled` omitted=true, `enforcement` omitted=`blocking`, `capability` omitted=`output.<filter>`, `hold_evidence_runes` omitted=500, `timeout_ms` omitted=5000으로 정규화하며 selector는 `environment|model_group|model|provider`로만 filter enablement/enforcement를 보정한다. base-disabled filter도 registry snapshot에 남아 더 구체적인 selector가 활성화할 수 있고, 실제 target에서 활성화된 `blocking` filter만 provider capability admission에 참여한다. `observe_only`는 evidence를 만들지만 admission을 막지 않는다. `repeat_guard` uses the configured rune bound for active request-local history/current-stream inspection and stores only bounded fingerprints, counts, and offsets in its semantic snapshot and observations. `schema_gate` and `provider_error` remain lifecycle foundations until their matcher Tasks; an unmatched provider error never creates exact replay. Config accepts no caller/agent selector.
- `openai.stream_evidence_gate` configures request-local Recovery Coordinator limits, the ingress snapshot bound, and optional semantic policy. Every supported Chat Completions, normalized Responses, provider tunnel, provider-pool, and tool-validation response already uses the `packages/go/streamgate` request runtime as its sole liveness owner. `enabled` defaults to false and controls only configured semantic filter registration/capability admission; false preserves endpoint-native compatibility inside the same runtime and does not restore a legacy response or retry owner. `max_request_fault_recovery`는 요청당 전체 fault recovery 상한(`0..3`, omitted 기본값 3, explicit 0은 모든 fault recovery 비활성화)이다. `max_strategy_fault_recovery`는 fault strategy(exact_replay/continuation_repair/schema_repair)별 상한(`0..max_request_fault_recovery`, omitted 기본값은 effective request total 상속, explicit 0은 해당 strategy 비활성화)이며 request-start 시점에 immutable runtime option snapshot으로 각 fault strategy에 동일하게 적용된다. base 또는 selector에서 활성화될 수 있는 blocking `repeat_guard`는 continuation recovery를 생성하므로 두 recovery 상한이 모두 최소 1이어야 하며, 모순된 설정은 load 시 거부한다. `max_ingress_snapshot_bytes`는 ingress snapshot 바이트 상한(`1..16777216` [16 MiB], omitted/0 기본값 16 MiB)이다. `environment`는 request-start selector snapshot이며 `dev|dev-corp`만 허용하고 omitted 기본값은 `dev`다. `filters[]`는 unique `filter` (`repeat_guard|schema_gate|provider_error`) policy이다. `enabled` omitted=true, `enforcement` omitted=`blocking`, `capability` omitted=`output.<filter>`, `hold_evidence_runes` omitted=500, `timeout_ms` omitted=5000으로 정규화하며 selector는 `environment|model_group|model|provider`로만 filter enablement/enforcement를 보정한다. base-disabled filter도 registry snapshot에 남아 더 구체적인 selector가 활성화할 수 있고, 실제 target에서 활성화된 `blocking` filter만 provider capability admission에 참여한다. `observe_only`는 evidence를 만들지만 admission을 막지 않는다. `repeat_guard` uses the configured rune bound for active request-local history/current-stream inspection and stores only bounded fingerprints, counts, and offsets in its semantic snapshot and observations. `schema_gate` and `provider_error` remain lifecycle foundations until their matcher Tasks; an unmatched provider error never creates exact replay. Config accepts no caller/agent selector.
- `openai.stream_evidence_gate` 설정은 request-start 시점에 snapshot으로 고정되며 in-flight request의 실행 중 refresh 영향에서 격리된다 (generation isolation). 새 generation의 설정은 이후 시작되는 새 request에만 적용된다.
- The internal `response_stalled` recovery registration is always present for a supported OpenAI runtime request. It is not a member of `filters[]`, has no configurable capability, and does not participate in provider capability admission. It consumes only an Edge-confirmed typed handoff; configurable `provider_error` keeps its generic foundation behavior.
- The request-start `models[].context_window_tokens` snapshot is the resume builder's target context bound. Each Chat/Responses runtime shares one request-local content/reasoning recorder across its initial and recovery event sources. A continuation rebuild uses only that recorder and the fixed directive; unknown or exceeded context rejects the rebuild before re-admission. An omitted caller temperature selects `0.2`, `0.4`, then `0.6` by continuation strategy attempt, while an explicit value is preserved. Recorder state and its raw values remain request-local, are consumed once per attempt, and are never added to config refresh state or observations. Repeat history and counters are pinned to the same request-start config generation and are not refreshable TTL/session state.

View file

@ -17,8 +17,11 @@
- `apps/edge/internal/service/single_request_tool_loop.go`
- `apps/edge/internal/service/single_request_types.go`
- `apps/edge/internal/openai/single_request_preset_binding.go`
- `apps/edge/internal/openai/single_request_provider_stage.go`
- `apps/edge/internal/openai/single_request_plan_stage.go`
- `apps/edge/internal/openai/single_request_work_stage.go`
- `apps/edge/internal/openai/single_request_review_stage.go`
- `apps/edge/internal/openai/provider_normalization.go`
- `packages/go/singlerequesttemplate/template.go`
- `apps/edge/internal/openai/anthropic_types.go`
- `apps/edge/internal/openai/routes.go`
@ -97,6 +100,17 @@ binding (route ID, credential slot, provider ID, endpoint, or raw workspace data
echoed to the caller. The admission is owned by the service package; the OpenAI and
Anthropic surfaces read only the public model identity and the frozen limits.
Each private Plan/Work/Review call derives caller-neutral requirements from the
frozen stage options, tool presence, and structured-output contract. The selected
protocol profile then chooses Chat Completions or Responses and maps effort to the
exact or nearest supported lower grade; the caller product or SDK name never enters
that choice. A managed route with an explicit resource selector still freezes and
verifies the exact provider ID. A `default` resource selector intentionally leaves
the provider ID open for the provider pool while continuing to freeze and verify the
model group, profile, upstream model, credential slot/revision, and tunnel execution
path. The concrete provider selected by that pool is therefore valid dispatch
evidence, not a stage-binding mismatch.
The admission also freezes the effective Plan and Review Markdown templates resolved
from the operator's `execution_presets[].single_request.templates` configuration (see
`agent-contract/inner/edge-config-runtime-refresh.md`). These templates are
@ -166,11 +180,15 @@ unbounded identifier. Success, length, and cancelled terminals do not emit this
Private Plan/Work/Review Chat Completions responses may contain the standard bounded
`usage` bookkeeping object (`prompt_tokens`, `completion_tokens`, `total_tokens`, and
their standard detail objects) and an optional string `message.reasoning_content`.
The stage decoder validates the known envelope shape and discards these private values;
their standard detail objects), top-level `service_tier`/`system_fingerprint`/provider
`timings`, choice
`logprobs`, message `annotations`, a null `refusal`, and an optional string
`message.reasoning_content`. The shared provider-normalization layer removes the
bookkeeping fields and emits the same canonical private Chat-shaped envelope used for
Responses results before the stage decoder runs. These private values are discarded;
they do not enter a stage result or artifact and do not select a route, credential,
workspace, tool, or terminal. A non-string reasoning value and unknown or duplicate
response members still fail closed. The external Claude
workspace, tool, or terminal. A non-null refusal, non-string reasoning value, and
unknown or duplicate response members still fail closed. The external Claude
qualification harness also disables SDK retry and automatic session-title generation
only in its supervised child so the single observed Messages ingress is the actual task.
@ -365,7 +383,7 @@ Wrong methods on Anthropic-selected endpoints return `405 invalid_request_error`
- `model`: Edge가 내부 `adapter + target`으로 해석할 외부 route 이름이다. IOP Edge에서는 라우팅을 위해 필수다.
- `max_tokens`: 출력 토큰 상한이다. 필수 field다. 0 이하 값은 `400 invalid_request_error`를 반환한다.
- `messages`: `user` 또는 `assistant` role만 허용한다. content는 string 또는 content block array다.
- `messages`: `user`, `assistant`, `system` role을 허용한다. content는 string 또는 content block array다. `system` role은 mid-conversation privileged instruction으로 보존하며 text content만 허용한다. 첫 message에는 올 수 없고 top-level `system`을 사용해야 한다. user message(연속 system message 포함) 직후에만 오며 assistant message 앞 또는 message array 끝에만 위치할 수 있고, 미완료 `tool_use``tool_result` 사이에는 올 수 없다. Chat bridge는 같은 위치의 `role=system`으로, Responses bridge는 같은 위치의 system input message로 normalize한다.
- `system`: string 또는 text block array만 허용한다.
- `stream`: `true`이면 ordinary provider routes relay raw provider SSE. `false` 또는 생략이면 non-streaming JSON 응답을 반환한다. An admitted virtual-preset Hot Path is the narrow exception described in routing: it emits the caller-requested endpoint-native shape after structural classification. A marked single-request request with `stream=true` uses the closed progress/ping/terminal subset above; `stream=false` retains the buffered final-only response.
- `temperature`: 0..1 범위. 범위를 벗어나면 `400 invalid_request_error`를 반환한다.

View file

@ -74,6 +74,6 @@ IOP 전체 안정성을 처음부터 재검증하지 않고, 실패가 재현된
- TLS 환경 경계: 개발 Edge용 사설 CA는 Edge leaf 인증서가 아니라 managed CA bundle을 해당 벤치 caller process에만 전달한다. Codex에는 공식 변수 `CODEX_CA_CERTIFICATE`, Node 기반 caller에는 필요한 경우 `NODE_EXTRA_CA_CERTS`를 사용하며 Codex/IDE 시작 환경이나 셸 전역에 `export`하지 않는다. 그렇지 않으면 공개 TLS 연결에도 같은 CA override가 적용될 수 있다.
- evidence 위치: `agent-test/dev/iop-benchmark-route-minimal-html-smoke.md`
- 현재 사전 확인: 2026-08-13 실제 원격 실행기에서 Claude Code 2.1.177, agy 1.1.12, Codex 0.146.0을 확인했고, 원격 SOPS에 보관된 기존 IOP principal token으로 token 원문을 출력하지 않은 `/v1/models`가 HTTP 200임을 확인했다. 새 벤치 전용 token은 발급하거나 사용하지 않는다.
- 현재 경로 결과: Claude Code → Claude direct와 Claude Code → Gemini direct는 최소 HTML 1회 호출을 통과했다. Claude Code → GPT direct는 Chat Completions의 tools+reasoning 조합 미지원으로 실패했고, IOP provider operation normalization 결함으로 귀속했다. caller-neutral operation 선택, Messages↔Responses 변환, nearest-lower effort mapping의 focused regression은 통과했으며 개발 런타임 재검증이 남아 있다.
- 추가 분리 결과: agy → Gemini direct는 누락된 caller `modelProvider=gemini`를 보정한 뒤 공식 URL-encoded model label을 IOP Gemini path parser가 거부하는 제품 결함까지 좁혔다. Codex → GPT direct의 최초 실패는 CA bundle 대신 Edge leaf 인증서를 전달한 측정 환경 결함이었고, 공식 Responses custom provider와 command-scoped managed CA bundle으로 바꾼 재검증은 10초 안에 `turn.completed``index.html` 생성을 통과했다. 같은 경계의 Codex → GPT execution preset도 16초 안에 `turn.completed`와 terminal marker 1회를 통과했다. Claude Code → Gemini preset의 caller workspace 파일 부재는 Edge-owned 비공개 workspace 정리 계약상 정상이라 측정 판정을 바로잡았고, 184초로 120초 상한을 넘은 지연과 최초 terminal marker 미수집만 별도 실패로 남겼다.
- 현재 경로 결과: Claude Code → Claude direct와 Claude Code → Gemini direct는 최소 HTML 1회 호출을 통과했다. Claude Code → GPT direct는 Chat Completions의 tools+reasoning 조합 미지원으로 실패했으나 caller-neutral operation normalization 반영 뒤 9초 안에 caller success와 `index.html` marker 1회를 확인했다. Codex → GPT direct와 Codex → GPT execution preset도 각각 10초/16초에 통과했다.
- 추가 분리 결과: agy → Gemini direct는 누락된 caller `modelProvider=gemini`를 보정한 뒤 공식 URL-encoded model label을 IOP Gemini path parser가 거부하는 제품 결함까지 좁혔다. Codex → GPT direct의 최초 실패는 CA bundle 대신 Edge leaf 인증서를 전달한 측정 환경 결함이었다. Claude Code → Gemini/GPT preset은 모두 provider 호출 뒤 Plan `validation`으로 닫혔고, 원인은 `default` selector가 비워 둔 provider ID와 pool이 선택한 실제 provider ID를 사후에 무조건 비교한 IOP 결함으로 확인했다. explicit selector와 profile/model/credential/path fence는 유지하면서 default selector의 pool 선택만 인정하도록 수정했고, private Plan/Work/Review에도 profile별 Chat/Responses 선택과 nearest-lower effort mapping을 적용한 focused regression 및 Edge 전체 테스트가 통과했다. 개발 런타임에는 아직 배포되지 않았다.
- 후속 측정: [초경량 Agent 모델 비교](thin-agent-model-comparison-benchmark.md)

View file

@ -54,7 +54,7 @@ Control Plane과 Client가 Edge 운영 상태를 어떻게 관찰하고 명령
| Control Plane server | HTTP health/readiness endpoint, Client proto-socket WebSocket endpoint, Edge proto-socket TCP endpoint를 함께 시작한다. |
| Client hello wire | `/client` WebSocket proto-socket에서 `ClientHelloRequest`/`ClientHelloResponse` baseline을 제공한다. |
| Edge outbound enrollment | Edge가 Control Plane TCP wire로 outbound 연결하고 `EdgeHelloRequest`를 보낸다. `edge_id`가 비어 있으면 거부된다. |
| credential HTTPS | Dedicated server-authenticated HTTPS exposes principal-authenticated slot and route lifecycle; provider plaintext is accepted only for create/rotate and is sealed before persistence. |
| credential HTTPS | Dedicated server-authenticated HTTPS exposes principal-authenticated slot and route lifecycle, including revision-CAS route binding updates; provider plaintext is accepted only for create/rotate and is sealed before persistence. |
| host-local principal bootstrap | Initial principal/token bootstrap remains an offline CLI operation and returns the raw token only once; no remote bootstrap route exists. |
| managed CP-Edge security | Credential mode requires CP-Edge mTLS and certificate workload identity agreement with `edge_id`. |
| principal projection sync | Accepted hello carries the current secret-free projection, and durable mutations push strictly newer generations to connected managed Edges. |
@ -111,7 +111,7 @@ sequenceDiagram
- Edge connector 설정은 `configs/edge.yaml``control_plane` 섹션이다.
- Managed Control Plane startup requires the credential HTTPS certificate/key, CP-Edge mTLS certificate/key/CA, durable database, external at-rest encryption keyring, lease issuer key id/private key, and bounded lease TTL/cache. Key and credential contents are mounted files, not tracked YAML values.
- The projection contains active token hashes and safe route/slot/profile/model/revision facts only. Lease acquisition re-reads durable generation, route, and slot state, so revoke/disable/rotation and stale revisions fail closed even if a request observed an older snapshot.
- Slot/route mutation success includes projection synchronization. Operator lifecycle is bootstrap, create slot, bind route, rotate by expected revision, disable/enable when reversible suspension is needed, and revoke when permanent invalidation is required.
- Slot/route mutation success includes projection synchronization. Operator lifecycle is bootstrap, create slot, bind or update a route by expected revision, rotate by expected revision, disable/enable when reversible suspension is needed, and revoke when permanent invalidation is required.
- Edge registry recent node events와 command audit는 bounded in-memory buffer다. durable audit store가 아니다.
- `EdgeNodeSnapshot.connected`는 current dispatch-ready ownership과 같고 accepted/pending connection은 false다. configured offline provider는 `status=unavailable`, `health=offline`, capacity/in-flight/queued/long-context 관련 수치를 0으로 보고한다.
- online provider의 in-flight는 Edge provider lease state, queued 값은 Edge queue의 candidate pressure다. current owner의 ready/disconnect 전이 뒤에만 관측 event가 relay되고 stale/rejected close는 live snapshot/event를 바꾸지 않는다.
@ -142,3 +142,4 @@ sequenceDiagram
- 2026-07-07: 기능 목록 중심으로 축소하고 주요 흐름을 Mermaid sequence diagram으로 정리.
- 2026-07-22: dispatch-ready connectivity와 configured offline provider snapshot, reconnect capacity 복구, current-owner event 의미를 현재 Edge status 구현과 계약 기준으로 동기화.
- 2026-08-02: Synchronized credential HTTPS, host-local bootstrap, CP-Edge mTLS identity, active projection refresh, authenticated lease issuance, and durable slot/route lifecycle with current source.
- 2026-08-14: Added the implemented revision-CAS credential HTTPS route binding update to the current operations surface.

View file

@ -83,7 +83,13 @@ source_evidence:
notes: Anthropic Messages ↔ Chat Completions request/response bridge
- type: code
path: apps/edge/internal/openai/provider_normalization.go
notes: Caller-neutral provider operation selection, effort fallback, Messages ↔ Responses conversion
notes: Caller-neutral provider operation selection, effort fallback, Messages/Chat ↔ Responses conversion for public bridges and private single-request stages
- type: code
path: apps/edge/internal/openai/single_request_provider_stage.go
notes: Frozen stage dispatch verification with explicit-provider fencing and default-selector provider-pool ownership
- type: test
path: apps/edge/internal/openai/single_request_provider_stage_test.go
notes: Private stage Chat/Responses selection, max-to-xhigh fallback, response conversion, and default-selector dispatch regression
- type: code
path: apps/edge/internal/openai/anthropic_stream.go
notes: Chat/Responses provider output을 Anthropic Messages JSON/SSE로 변환
@ -184,6 +190,7 @@ Edge가 OpenAI-compatible HTTP 요청을 받아 내부 `adapter + target` 실행
| managed projection auth | `credential_plane.enabled=true` uses the fresh Control Plane projection for inbound token auth and principal route discovery. Static principal/bearer fallback is disabled. |
| managed slot route | Public model id/alias resolves to one projected route, exact slot/profile/upstream model/resource selector, and immutable revisions/generation. Unknown, cross-principal, stale, revoked, or ambiguous bindings fail closed. |
| marked preset single-request admission | An authorized fixed single-request preset compiles one service-owned admission value at request start: requested public model, canonical plan/work/review bindings resolved through managed authorization, opaque workspace capability, and absolute resource caps. Later refresh cannot mutate the admitted shape. No private binding is echoed to the caller. Compiled only after every canonical reference is verified through its catalog binding for the authenticated principal; missing, duplicate, unauthorized, dynamically selected, or option-inconsistent inputs are rejected without fallback. |
| marked single-request provider normalization | Plan/Work/Review derive caller-neutral effort/tool/structured-output requirements and let the selected protocol profile choose Chat Completions or Responses. Effort exact misses fall only to the nearest declared lower grade (`max` → `xhigh` when `max` is absent). Explicit resource selectors keep exact provider-ID verification; a `default` selector leaves provider choice to the pool while model group, profile, upstream model, credential slot/revision, and tunnel path remain frozen. Both Chat and Responses results are converted into the private common Chat-shaped stage codec before Plan/Work/Review validation. Chat conversion discards only bounded standard/provider bookkeeping (`service_tier`, `system_fingerprint`, provider `timings`, choice `logprobs`, message `annotations`, null `refusal`) and rejects a non-null refusal or unknown/duplicate fields. |
| marked single-request internal templates | The admission also freezes the operator-configured effective Plan/Review Markdown templates. They are internal artifact shapes only: the Plan stage obtains a strict one-line `goal` plus bounded one-line `steps`/`verification` arrays, and Edge owns the bullet formatting and renders `plan.md`, while the Review template shapes the private `review.md` artifact. Callers cannot supply, name, or select a template, and template paths, contents, and digests never appear in a response, error, log, or metric label. The caller-visible Messages request/response schema is unchanged and the final text stays the model's `decision.output`. |
| marked single-request ingress | One validated and authorized Messages POST enters the separate service coordinator capability before legacy provider/caller continuation and increments `iop_anthropic_single_request_ingress_total` once. Non-streaming returns one buffered final-only message. Streaming keeps one envelope across the coordinator lifetime, exposes only fixed plan/work/review/repair text blocks plus `event: ping`, and commits one final text/error terminal. Internal reasoning/tool wire never becomes caller `tool_use`; success is acknowledged only after the complete terminal write succeeds. |
| marked single-request S11 terminal policy | The service freezes one closed `end_turn`, `length`, `error`, or `cancelled` disposition. `error` classes are provider, validation, timeout, budget, repetition, malformed, context, internal-tool, and workspace-cleanup. Buffered and SSE share one projection: `end_turn`; `max_tokens` with no private partial output; `400 invalid_request_error` for validation/context; `502 api_error` for other failures; and silent cancellation after caller disconnect. No terminal classification retries, falls back, opens a second request, or later writes success. |
@ -204,7 +211,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. |
| provider-normalized Messages bridge | Supported Messages compatibility headers are consumed at the bridge. Edge derives caller-neutral tool/effort/token-budget/stream requirements, selects a profile operation that preserves them, and maps effort to exact or nearest lower provider grade. Chat-compatible providers may therefore use Chat or Responses without caller-name branches. JSON schema and tool shapes are converted for the selected wire; Gemini tool thought signatures still round-trip through opaque tool-use ids. |
| provider-normalized Messages bridge | Supported Messages compatibility headers are consumed at the bridge. Edge derives caller-neutral tool/effort/token-budget/stream requirements, selects a profile operation that preserves them, and maps effort to exact or nearest lower provider grade. Chat-compatible providers may therefore use Chat or Responses without caller-name branches. Mid-conversation `system` messages retain their ordered privileged role as Chat `role=system` or Responses system input messages; they are not downgraded to user text. Placement, text-only content, and pending-tool fences fail closed. JSON schema and tool shapes are converted for the selected wire; Gemini tool thought signatures still round-trip through opaque tool-use ids. |
| Gemini-native agy tool continuation | Official agy 1.1.12의 Gemini-native 요청을 Chat 실행 경로로 변환한다. tool 실행 뒤 독립 `role:model` content로 전달되는 `functionResponse`는 앞선 function call과 매칭해 Chat `tool` message로 변환하며, assistant content와 response가 한 model content에 섞인 모호한 요청은 거부한다. |
| bounded ingress and StreamGate ownership | Chat/Responses bodies are limited to 16 MiB before the first read. Every supported path delegates response-start staging, applicable filter arbitration, bounded liveness recovery, and the single terminal to `runtime/stream-evidence-gate`; `enabled` controls configured semantic policy only. |
| typed stall terminal | Supported Chat/Responses normalized and tunnel attempts always translate only Edge-confirmed `response_stalled` terminals into a raw-free liveness recovery candidate; post-commit, cancelled, tool-bearing, missing-snapshot, exhausted, unsupported, unconfirmed, generic, and no-owner paths stay terminal. |
@ -373,6 +380,10 @@ sequenceDiagram
- 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.
- 2026-08-09: Extended `output_config.effort` to accept `low`, `medium`, `high`, `xhigh`, and `max` across Anthropic native and Chat bridge routes without substitution or normalization. Unknown effort values remain `400 invalid_request_error` before provider dispatch. Deterministic Go coverage added for exact bridge mapping, native `max` preservation, and invalid-value rejection. (`apps/edge/internal/openai/anthropic_types.go`, `apps/edge/internal/openai/anthropic_bridge_test.go`, `apps/edge/internal/openai/anthropic_native_test.go`)
- 2026-08-13: Added caller-neutral provider operation normalization for Messages/Responses routes. Tool-bearing adaptive effort can select Responses when Chat cannot preserve the combination, and unsupported effort grades fall only to the nearest declared lower grade (for example `max` to `xhigh`).
- 2026-08-14: Normalized Anthropic mid-conversation `system` messages across strict ingress, logical-request lineage, Chat, and Responses provider wires. Ordered system authority is preserved; first-position, invalid-placement, non-text, and pending-tool interruption shapes remain fail-closed.
- 2026-08-14: Applied the same provider-normalization boundary to private single-request Plan/Work/Review stages. Removed pre-selection Chat operation pinning, added Chat↔Responses stage conversion and nearest-lower effort mapping, and corrected default-selector dispatch validation so the pool-selected provider is accepted without weakening explicit provider, profile, credential, target, or execution-path fences.
- 2026-08-14: Canonicalized private Chat Completions stage responses before strict Plan/Work/Review decoding. Standard OpenAI bookkeeping fields are stripped centrally, a non-null refusal remains fail-closed, and Responses/Chat now converge on the same private envelope instead of making stage codecs provider-specific.
- 2026-08-14: Added provider `timings` to the bounded Chat response bookkeeping stripped before private stage decoding, aligning the common normalizer with the existing Ornith Work codec without admitting unknown response fields.
- 2026-08-13: Gemini-native path parsing now keeps route IDs strict while accepting the bounded URL-encoded official caller model label `Gemini 3.6 Flash`.
- 2026-08-06: Synchronized always-owned Chat/Responses typed-stall recovery, provider avoidance/fallback admission, and closed-label liveness operational evidence with the current runtime, contracts, and deterministic recovery tests.
- 2026-08-06: Added marked single-request Messages admission through the separate service coordinator capability, one unlabeled runtime ingress counter, buffered sanitized terminal acknowledgement, and deterministic real-POST compatibility evidence.

View file

@ -222,13 +222,14 @@ The shared `packages/go/execution` package contains provider lifecycle, registry
| normalized execution | `adapter + target`으로 provider 실행을 선택하고 ordered `RunEvent` stream을 반환한다. |
| single-request coordinator | Immutable admission과 closed stage envelope을 service-owned state graph (`accepted`, `planning`, `working`, `reviewing`, `repairing`, `internal_tool`, `finalizing`, `completed`, `failed`, `cancelled`)로 처리한다. An internal tool result can resume only its saved stage. After a successful workspace open, every terminal path waits for one cleanup before the finalizing candidate can reach surface acknowledgement. |
| single-request S11 terminal policy | One validated, copy-safe terminal disposition is frozen across envelope/result/progress with kinds `end_turn`, `length`, `error`, and `cancelled`. Error classes are `provider`, `validation`, `timeout`, `budget`, `repetition`, `malformed`, `context`, `internal_tool`, and `workspace_cleanup`. Cleanup can replace a pending success/length before publication; no acknowledgement race can publish a second terminal. |
| single-request observation evidence | Stage-pure timing, tool/cleanup/total counts, cardinality-bounded labels, Node logs, and raw-free correlation are documented for the single-request path. `iop_anthropic_single_request_ingress_total` is strictly unlabeled. A separate `edge_single_request_terminal_rejection` event projects only the fixed terminal kind/error class and HTTP status, so `malformed` and `validation` remain distinguishable without raw model output. The private stage decoder accepts and discards only bounded standard Chat Completions `usage` bookkeeping and optional string `message.reasoning_content`; neither enters stage results or artifacts, while non-string reasoning and unknown envelope members fail closed. Gemini Plan/Review also admit only exact non-empty `extra_content.google.thought_signature`; terminal signatures are discarded and a Review tool-call signature is replayed only in the matching request-local Gemini continuation. Work, artifacts, results, and observations never retain it. The Claude qualification child disables SDK retry and session-title generation so only the actual task can consume ingress. Internal tool names, raw arguments, and private results are absent from public output and log projections. SDD S12 qualified the external Claude path on an approved IOP Node with ingress `0→1`, Gemini → ornith-fast → Gemini stage order, one `end_turn`, the exact expected workspace result, timing, cleanup, and redacted evidence. |
| single-request observation evidence | Stage-pure timing, tool/cleanup/total counts, cardinality-bounded labels, Node logs, and raw-free correlation are documented for the single-request path. `iop_anthropic_single_request_ingress_total` is strictly unlabeled. A separate `edge_single_request_terminal_rejection` event projects only the fixed terminal kind/error class and HTTP status, so `malformed` and `validation` remain distinguishable without raw model output. The provider-normalization layer accepts and discards bounded standard/provider Chat Completions bookkeeping (`usage`, `service_tier`, `system_fingerprint`, provider `timings`, choice `logprobs`, message `annotations`, null `refusal`) plus optional string `message.reasoning_content` before emitting the canonical private stage envelope; none enters stage results or artifacts, while a non-null refusal, non-string reasoning, and unknown envelope members fail closed. Gemini Plan/Review also admit only exact non-empty `extra_content.google.thought_signature`; terminal signatures are discarded and a Review tool-call signature is replayed only in the matching request-local Gemini continuation. Work, artifacts, results, and observations never retain it. The Claude qualification child disables SDK retry and session-title generation so only the actual task can consume ingress. Internal tool names, raw arguments, and private results are absent from public output and log projections. SDD S12 qualified the external Claude path on an approved IOP Node with ingress `0→1`, Gemini → ornith-fast → Gemini stage order, one `end_turn`, the exact expected workspace result, timing, cleanup, and redacted evidence. |
| workspace admission | An opaque `workspace_ref` resolves only through the configured Node catalog. Edge freezes the exact configured owner, dispatch-ready connection generation, closed operation/command/environment-name capabilities, and effective limits before executor startup; unavailable, foreign, pending, malformed, and stale candidates fail closed without fallback or reselection. |
| workspace runtime wire | The dedicated `WorkspaceOpen`/`Tool`/`Artifact`/`Cancel`/`Cleanup` request-response families carry immutable coordinator identities and closed status/error codes. `WorkspaceArtifact` admits only enum-selected `PLAN`/`REVIEW` and `READ`/`WRITE`; it carries no relative path. Edge overwrites open capabilities with frozen request authority; Node copies only catalog-subset operations/command ids and equal-or-lower effective limits. |
| workspace tool executor | A validated `darwin|linux` Node catalog owns opened root and directory handles only when every entry platform matches the host exactly. Windows, unknown hosts, and cross-platform catalogs fail before root open; empty catalogs remain compatible. Go 1.24-compatible no-follow file primitives provide bounded read, bounded list, structured write, and non-recursive delete. Exact operator-owned command templates run through an inherited-root `fchdir`/`exec` shim with minimal allowlisted environment, shared stdout/stderr bounds, process-group timeout/cancel, and stable typed results. OS is runtime evidence rather than a caller-visible selector. |
| internal workspace tool loop | The service decodes only `workspace_read`, `workspace_list`, `workspace_write`, `workspace_delete`, and `workspace_command`, opens the admitted workspace once, dispatches one call at a time on the frozen generation, and delivers one deep-copied typed result to the emitting executor continuation. Unique request/stage/tool correlation, per-stage iteration/output/deadline limits, request wall-clock budget, and typed cancel fail closed without external continuation or reselection. |
| request-owned internal artifacts | `SingleRequestController` exposes closed plan/review read/write operations. Artifact calls and model workspace tools share one serialized lazy `WorkspaceOpen`, the exact admitted Node generation, the active stage deadline, the immutable output bound, in-flight work accounting, and one terminal cleanup. Node alone maps selectors to `plan.md` and `review.md`, and inventoried descriptor-relative reads fail closed on identity replacement. |
| Plan stage | The Plan runner validates the frozen effective template, emits the `planning` envelope, sends the immutable task through the frozen Plan binding with `reasoning_effort=high` and a stage-owned strict JSON schema for one-line `goal` plus bounded one-line `steps`/`verification` arrays, validates the fields, adds Markdown bullets, renders the template deterministically inside Edge, and writes the resulting Markdown through `SingleRequestArtifactPlan`. |
| single-request provider normalization | Private Plan/Work/Review calls pass caller-neutral effort/tool/structured-output requirements to the selected protocol profile. The profile chooses Chat Completions or Responses and maps unsupported effort only downward. An explicit managed selector freezes the exact provider ID; `default` freezes no provider ID and accepts the provider pool's concrete choice while retaining exact model-group/profile/target/credential/tunnel fences. Chat and Responses provider results are both reduced to one canonical private Chat-shaped envelope before strict stage decoding. No new Edge-Node field is added: the selected operation continues through the existing provider tunnel operation field. |
| single-request effective templates | `execution_presets[].single_request.templates` optionally loads `plan_file`/`review_file` as bounded Markdown relative to the directory containing `edge.yaml`; absolute and empty paths, non-regular files, oversize (`>8192` bytes), non-UTF-8, and invalid grammar fail closed at load, and each file falls back to its built-in default independently. Admission freezes the effective Plan/Review pair into the binding, so a later refresh reaches only newly admitted requests. Templates select internal stage input and internal artifact shape only; caller request/response schemas are unchanged. |
| Work stage | The `ornith-fast` Work runner reads the closed PLAN artifact, projects only the admitted workspace tools, and resumes the same frozen provider route after exactly correlated Node results. It rejects any Work `reasoning_effort`, malformed or multiple tool calls, and empty completion or verification evidence. |
| request-owned cleanup | Node creates and inventories only `.iop/job/<request_id>` internal state, cancels and waits for all active command groups, validates the exact tree without following entries, and removes matching artifacts deepest-first with non-recursive descriptor operations. Symlinks, special files, foreign devices, identity replacements, and unowned entries fail closed. User results and sibling request state are preserved. Concurrent cleanup callers receive one bounded cached typed result. |
@ -345,6 +346,7 @@ Heartbeat interval/wait는 protobuf field가 아닌 양쪽 transport 구현의 l
- `go test -count=1 ./apps/edge/internal/openai -run '^(TestOpenAILivenessObservationSink|TestOpenAILivenessRecoveryObservability)$'` — deterministic OpenAI recovery eligibility/results with closed label values and identifier exclusion.
- `go test -count=1 ./apps/edge/internal/openai -run 'TestAnthropicSingleRequestObservation'` — deterministic single-request observation evidence: ingress=1, request-total=1, terminal=1, stage/tool/cleanup counts, raw-free correlation, and unlabeled metric assertion.
- `go test -count=1 ./apps/edge/internal/openai -run 'TestSingleRequest(ProviderStage|PlanStage)'` — deterministic frozen provider codec and Plan stage evidence, including high reasoning, the closed structured response schema, exact JSON field admission, ordered tunnel frames, deterministic frozen-template rendering, planning envelope, and `plan.md` artifact selection.
- `go test -count=1 ./apps/edge/internal/openai -run 'TestSingleRequest(ProviderStage|ProviderNormalization|PlanStage|WorkStage|ReviewStage|Executor)'` — private stage operation selection, Chat/Responses conversion, nearest-lower effort (`max` → `xhigh`), default-selector pool-provider acceptance, explicit dispatch fences, and composite regression evidence.
- `go test -count=1 ./packages/go/singlerequesttemplate ./packages/go/config` — exact standalone heading/`PASS` grammar, closed placeholder inventory, the `8192`-byte boundary, and relative-only `plan_file`/`review_file` loading with per-file fallback and filesystem-kind rejection.
- `go test -count=1 ./apps/edge/internal/service -run 'TestSingleRequestBinding'` and `go test -count=1 ./apps/edge/internal/openai -run 'TestSingleRequest(PresetBinding|ReviewStage)'` — admitted effective-template snapshot through clone and workspace revalidation, refresh isolation for admitted versus newly admitted requests, and the custom-template REVIEW artifact with unchanged caller-visible output.
- `go test -race -count=1 ./apps/edge/internal/openai -run 'TestSingleRequestWork(Stage|ToolBridge)'` — deterministic ornith-fast Work tool loop, correlation isolation, cancellation cleanup, strict completion evidence, and Work reasoning-option absence.
@ -365,6 +367,8 @@ Heartbeat interval/wait는 protobuf field가 아닌 양쪽 transport 구현의 l
## 변경 기록
- 2026-08-14: Moved private Plan/Work/Review provider calls onto the shared provider-normalization boundary. Stage requirements now select Chat or Responses without caller identity, unsupported effort maps only downward, and default-selector provider-pool choices no longer fail the post-dispatch validation that still fences profile, target, credential revision, model group, and tunnel path.
- 2026-08-14: Added common Chat result normalization for private stages so standard OpenAI bookkeeping fields are removed before strict decoding, matching the existing Responses-to-common conversion while preserving fail-closed refusal and unknown-field handling.
- 2026-08-12: Replaced nondeterministic free-form PlanMD generation with a stage-owned strict `goal`/`steps`/`verification` JSON response. Edge rejects unknown, duplicate, missing, mistyped, or out-of-bound fields and deterministically renders the already-frozen operator Plan template, preserving template customization and every caller-visible schema (`apps/edge/internal/openai/single_request_plan_stage.go`, `packages/go/singlerequesttemplate/template.go`).
- 2026-08-12: Replaced Markdown-bearing Plan string fields with bounded one-line string arrays and moved all bullet/newline rendering into Edge, removing the remaining provider-specific formatting dependency without changing the public Messages surface (`apps/edge/internal/openai/single_request_plan_stage.go`, `packages/go/singlerequesttemplate/template.go`).
- 2026-08-09: Replaced the Plan stage's structured JSON response contract with operator-configurable bounded Markdown templates. `execution_presets[].single_request.templates` resolves `plan_file`/`review_file` relative to the directory containing `edge.yaml` only, admission freezes the effective Plan/Review pair so a refresh reaches only newly admitted requests, the Plan stage prompts with and parses direct PlanMD against the frozen template, and the Review stage renders the internal REVIEW artifact from the frozen Review template while the caller-visible final response stays the model's `decision.output` (`packages/go/singlerequesttemplate/template.go`, `packages/go/config/load.go`, `apps/edge/internal/openai/single_request_preset_binding.go`, `apps/edge/internal/openai/single_request_plan_stage.go`, `apps/edge/internal/openai/single_request_review_stage.go`).

View file

@ -110,7 +110,7 @@ sequenceDiagram
## 설정/데이터/이벤트
- `openai.stream_evidence_gate.enabled` defaults to `false` and controls only configured semantic filters and their capability admission. The Core owns the supported response/liveness lifecycle in both states, while disabled mode preserves endpoint-native compatibility through runtime adapters.
- `max_request_fault_recovery`는 0..3, `max_strategy_fault_recovery`는 0..request-total이고 생략 시 request-total을 상속한다.
- `max_request_fault_recovery`는 0..3, `max_strategy_fault_recovery`는 0..request-total이고 생략 시 request-total을 상속한다. base 또는 selector에서 활성화될 수 있는 blocking `repeat_guard`는 두 상한이 모두 최소 1이어야 한다.
- `max_ingress_snapshot_bytes`는 1..16777216이며 생략 시 16 MiB다. raw body limit은 첫 read 전에 적용되고 canonical body, typed view와 rebuild peak가 같은 request-local ledger에 포함된다.
- Stream Evidence Gate 설정 변경은 현재 restart-required다. request가 시작된 뒤 config/registry snapshot은 바뀌지 않는다.
- The production Core registry includes the common Noop filter, configured active `repeat_guard`, schema/provider-error lifecycle foundations, and applicable request-local tool validation. Repeat detection uses the configured 500-rune default, never time-based release, and returns a continuation only before a tool/side-effect boundary. Provider-error still records unmatched errors as pass until its matcher Task.

View file

@ -26,18 +26,24 @@
|---|---|---|---|
| Claude Code → Claude direct | 통과 | 없음 | 기존 성공 |
| Claude Code → Gemini direct | 통과 | 없음 | 기존 성공 |
| agy → Gemini direct | 1초, caller login 요구 | `modelProvider=gemini` 보정 뒤 8초, HTTP 400 | IOP Gemini path가 공식 URL-encoded model label을 거부 |
| Claude Code → GPT direct | provider HTTP 400 | 새 normalization 배포 대기 | IOP가 tools+effort를 Chat으로 보낸 결함 |
| agy → Gemini direct | 1초, caller login 요구 | parser 배포 뒤 19-22초, caller `SUCCESS` 2회, 파일 없음 | IOP parser 결함 해소; agy가 trusted workspace에서도 파일 도구를 호출하지 않은 caller/tool 이슈 분리 |
| Claude Code → GPT direct | provider HTTP 400 | operation normalization 반영 뒤 9초, caller success, `index.html` marker 1회 | IOP가 tools+effort를 Chat으로 보낸 결함 수정 확인 |
| Codex → GPT direct | 30초, `turn.failed`, 파일 없음 | 공식 설정대로 임시 `CODEX_HOME`, Responses 전용 provider, `CODEX_CA_CERTIFICATE`에 CA bundle을 사용해 10초 통과 | 측정 환경 결함: 첫 호출은 CA bundle 대신 Edge leaf 인증서를 사용 |
| Claude Code → Gemini execution preset | 184초, caller terminal success, caller workspace 파일 없음 | 판정 정정: caller 파일 부재는 정상, 120초 초과는 실패 | 측정 판정 결함과 preset 지연을 분리; terminal marker는 최초 결과에서 미수집 |
| Claude Code → Gemini execution preset | 184초, caller terminal success, caller workspace 파일 없음 | 새 배포에서 33초 caller 정상 종료; Plan/Work/Review, workspace write/read/list, artifact 3개 cleanup 성공 | default-selector dispatch 결함 해소; terminal 문구에 marker가 없는 것은 caller-visible 결과 판정과 분리 |
| agy → Gemini execution preset | 미실행 | direct parser 수정 배포 대기 | 선행 결함 |
| Claude Code → GPT execution preset | 미실행 | normalization 배포 대기 | 선행 결함 |
| Claude Code → GPT execution preset | 미실행 | selector/Chat 응답 정규화 배포 뒤 최신 Claude 요청이 provider 전 95ms에 `messages[1].role` 검증 거절 | 실제 caller가 `user → system`과 mid-conversation-system beta를 보냄; IOP가 beta만 선언하고 ingress/lineage/Chat/Responses normalize를 구현하지 않은 별도 제품 결함 확인·국소 수정 |
| Codex → GPT execution preset | 16초, `turn.completed`, terminal marker 1회 | 없음 | 통과 |
추가 API 분리에서는 동일 principal의 최소 `/v1/responses`가 HTTP 200이었다. Codex direct도 사용자 설정과 로그인 상태를 배제한 임시 `CODEX_HOME`, Responses 전용 custom provider, 원격 SOPS의 기존 token, command-scoped managed CA bundle으로 통과했다. Gemini-native 최소 요청은 canonical caller model id에서 HTTP 200, 공식 표시 label `Gemini 3.6 Flash`에서 HTTP 400으로 갈려 path parser 결함을 재현했다.
두 preset 최초 실패는 provider 자체 실패가 아니었다. 코드 대조에서 `default` resource selector가 의도적으로 빈 provider ID를 동결하는 반면 사후 검증은 pool이 정상 선택한 실제 provider ID와 무조건 같아야 한다고 요구한 결함을 확인했다. explicit selector의 provider ID와 profile/model/credential/path fence는 유지하고, default selector만 pool 선택을 인정했다. 재배포 뒤 Gemini preset은 전체 stage와 workspace lifecycle이 통과했다. GPT preset은 다음 경계까지 진행해 실제 OpenAI Chat 응답의 `service_tier`, `system_fingerprint`, `annotations`, null `refusal`을 private strict decoder가 거부하는 별도 normalize 누락을 드러냈다. 공통 provider-normalization 계층에서 Chat/Responses 결과를 같은 canonical stage envelope로 수렴시키는 국소 회귀가 통과했다.
그 수정 배포 뒤 최신 Claude Code는 첫 task 요청에 `messages=[user, system]``mid-conversation-system-2026-04-07` beta를 보냈다. IOP는 beta header를 지원 목록에 두었지만 strict ingress와 logical-request lineage는 여전히 user/assistant만 허용했고, Chat/Responses bridge에도 system message 변환이 없었다. system 내용을 user text로 낮추지 않고 ordered privileged message로 보존하도록 공통 Anthropic 입력 normalize 경계를 수정했으며, 첫-position system, assistant 뒤 system, system 뒤 user, non-text system, 미완료 tool 결과 사이 삽입은 fail-closed로 유지했다.
해당 입력 normalize 배포 뒤 단일 재검증은 Plan 성공 후 Work `validation`으로 종료됐다. preset 구성상 Plan/Review는 GPT지만 Work는 `ornith-fast`다. RTX Ornith에 같은 forced workspace tool shape를 직접 보낸 결과 native `tool_calls`가 정상 반환됐고, 응답의 top-level provider `timings`가 기존 Work codec에서는 허용되지만 새 공통 Chat normalize allowlist에서 빠져 있음을 확인했다. 공통 normalize가 `timings`를 검증 후 폐기하도록 보완했다.
## 재개 조건
원격에 남은 미완료 release head를 운영 절차로 먼저 정리한 뒤 병합된 `dev`를 새 release로 배포한다. 그 뒤 변경된 원인에 연결된 agy direct와 Claude Code GPT direct만 1회 재검증하고, 선행 결함이 해소된 미실행 행을 각 1회 수행한다.
Anthropic mid-conversation system normalization을 병합·배포한 뒤 Claude Code → GPT preset만 1회 재검증한다. 운영 release capacity gate는 인증 projection의 `ornith:35b` selector가 기대한 OneX가 아니라 RTX로 바뀐 별도 운영 라우트 불일치를 먼저 해소해야 한다. 이를 통과시키려고 route나 capacity를 임의 변경하지 않는다. 그 뒤 선행 결함이 해소된 agy preset을 1회 수행한다. Claude Code GPT direct와 이미 성공한 경로는 반복하지 않는다.
성공한 경로는 반복하지 않는다. 실패한 경로는 원인이 변경된 경우에만 해당 경로를 1회 재검증한다.

View file

@ -193,6 +193,29 @@ func (h credentialRouteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
var result any
var callErr error
switch parts[1] {
case "update":
if contentType := r.Header.Get("Content-Type"); contentType != "application/json" {
http.Error(w, "application/json is required", http.StatusUnsupportedMediaType)
return
}
var payload struct {
SlotID string `json:"slot_id"`
Alias string `json:"alias"`
ProfileID string `json:"profile_id"`
UpstreamModel string `json:"upstream_model"`
ResourceSelector string `json:"resource_selector"`
}
dec := json.NewDecoder(io.LimitReader(r.Body, 64<<10))
dec.DisallowUnknownFields()
if err := dec.Decode(&payload); err != nil {
http.Error(w, "invalid route request", http.StatusBadRequest)
return
}
result, callErr = h.service.UpdateRoute(r.Context(), token, credentialops.UpdateRouteInput{
RouteID: parts[0], CurrentRevision: revision, SlotID: payload.SlotID,
Alias: payload.Alias, ProfileID: payload.ProfileID, UpstreamModel: payload.UpstreamModel,
ResourceSelector: payload.ResourceSelector,
})
case "disable":
result, callErr = h.service.DisableRoute(r.Context(), token, parts[0], revision)
case "enable":

View file

@ -78,6 +78,42 @@ func TestCredentialHTTPRequiresPrincipalAndBoundsSecretBody(t *testing.T) {
t.Fatalf("decode created slot: id=%q err=%v", created.ID, err)
}
routeBody := `{"slot_id":"` + created.ID + `","alias":"ornith:35b","profile_id":"openai","upstream_model":"ornith:35b","resource_selector":"provider-slow"}`
createRoute := httptest.NewRequest(http.MethodPost, "/v1/credentials/routes", strings.NewReader(routeBody))
createRoute.Header.Set("Authorization", "Bearer "+first.RawToken)
createRoute.Header.Set("Content-Type", "application/json")
createRouteResponse := httptest.NewRecorder()
mux.ServeHTTP(createRouteResponse, createRoute)
var route struct {
ID string `json:"ID"`
ResourceSelector string `json:"ResourceSelector"`
Revision int64 `json:"Revision"`
}
if err := json.Unmarshal(createRouteResponse.Body.Bytes(), &route); err != nil || createRouteResponse.Code != http.StatusOK || route.ID == "" || route.Revision != 0 {
t.Fatalf("create route: status=%d route=%+v err=%v body=%s", createRouteResponse.Code, route, err, createRouteResponse.Body.String())
}
updateBody := `{"slot_id":"` + created.ID + `","alias":"ornith:35b","profile_id":"openai","upstream_model":"ornith:35b","resource_selector":"provider-fast"}`
updateRoute := httptest.NewRequest(http.MethodPost, "/v1/credentials/routes/"+route.ID+"/update", strings.NewReader(updateBody))
updateRoute.Header.Set("Authorization", "Bearer "+first.RawToken)
updateRoute.Header.Set("Content-Type", "application/json")
updateRoute.Header.Set("IOP-Expected-Revision", "0")
updateRouteResponse := httptest.NewRecorder()
mux.ServeHTTP(updateRouteResponse, updateRoute)
if err := json.Unmarshal(updateRouteResponse.Body.Bytes(), &route); err != nil || updateRouteResponse.Code != http.StatusOK || route.ResourceSelector != "provider-fast" || route.Revision != 1 || mutations != 3 {
t.Fatalf("update route: status=%d route=%+v mutations=%d err=%v body=%s", updateRouteResponse.Code, route, mutations, err, updateRouteResponse.Body.String())
}
staleRoute := httptest.NewRequest(http.MethodPost, "/v1/credentials/routes/"+route.ID+"/update", strings.NewReader(updateBody))
staleRoute.Header.Set("Authorization", "Bearer "+first.RawToken)
staleRoute.Header.Set("Content-Type", "application/json")
staleRoute.Header.Set("IOP-Expected-Revision", "0")
staleRouteResponse := httptest.NewRecorder()
mux.ServeHTTP(staleRouteResponse, staleRoute)
if staleRouteResponse.Code != http.StatusConflict || mutations != 3 {
t.Fatalf("stale update status=%d mutations=%d body=%s", staleRouteResponse.Code, mutations, staleRouteResponse.Body.String())
}
listOther := httptest.NewRequest(http.MethodGet, "/v1/credentials/slots", nil)
listOther.Header.Set("Authorization", "Bearer "+second.RawToken)
listOtherResponse := httptest.NewRecorder()

View file

@ -188,6 +188,19 @@ func anthropicMessageToChat(role string, blocks []anthropicContentBlock, profile
if role == "assistant" {
return anthropicAssistantToChat(blocks, profile)
}
if role == "system" {
parts := make([]string, 0, len(blocks))
for _, block := range blocks {
if block.Type != "text" {
return nil, fmt.Errorf("content block %q is invalid for a system message", block.Type)
}
parts = append(parts, block.Text)
}
if len(parts) == 0 {
return nil, fmt.Errorf("system message content is empty")
}
return []map[string]any{{"role": "system", "content": strings.Join(parts, "\n")}}, nil
}
var out []map[string]any
var content []map[string]any
flushContent := func() {

View file

@ -122,6 +122,75 @@ func TestAnthropicChatBridgeKeepsCompatibleMaxTokensField(t *testing.T) {
}
}
func TestAnthropicChatBridgeNormalizesMidConversationSystemMessage(t *testing.T) {
profile, err := config.ResolveProtocolProfile("openai", "", config.BuiltInProtocolProfileCatalog())
if err != nil {
t.Fatal(err)
}
body, _, err := prepareAnthropicChatBridge(
[]byte(`{"model":"claude-route","max_tokens":64,"messages":[{"role":"user","content":"implement index.html"},{"role":"system","content":"Return the marker when complete."}]}`),
"served-chat", profile,
)
if err != nil {
t.Fatal(err)
}
var chat map[string]any
if err := json.Unmarshal(body, &chat); err != nil {
t.Fatal(err)
}
messages := anthropicAnySlice(t, chat["messages"])
if len(messages) != 2 {
t.Fatalf("messages=%+v", messages)
}
system := anthropicAnyMap(t, messages[1])
if system["role"] != "system" || system["content"] != "Return the marker when complete." {
t.Fatalf("mid-conversation system mapping=%+v", system)
}
}
func TestAnthropicResponsesBridgeNormalizesMidConversationSystemMessage(t *testing.T) {
candidate := anthropicTestCandidate(t, "openai")
profile := candidate.ProtocolProfile.Clone()
plan := providerOperationPlan{Operation: config.OperationResponses}
body, _, err := prepareAnthropicResponsesBridge(
[]byte(`{"model":"gpt-route","max_tokens":64,"messages":[{"role":"user","content":"implement index.html"},{"role":"system","content":"Return the marker when complete."}]}`),
"served-responses", profile, plan,
)
if err != nil {
t.Fatal(err)
}
var responses map[string]any
if err := json.Unmarshal(body, &responses); err != nil {
t.Fatal(err)
}
input := anthropicAnySlice(t, responses["input"])
if len(input) != 2 {
t.Fatalf("input=%+v", input)
}
system := anthropicAnyMap(t, input[1])
if system["type"] != "message" || system["role"] != "system" {
t.Fatalf("mid-conversation system mapping=%+v", system)
}
}
func TestAnthropicMidConversationSystemPlacementIsFailClosed(t *testing.T) {
for _, tc := range []struct {
name string
body string
}{
{name: "first", body: `{"model":"route","max_tokens":64,"messages":[{"role":"system","content":"system"},{"role":"user","content":"hello"}]}`},
{name: "after assistant", body: `{"model":"route","max_tokens":64,"messages":[{"role":"user","content":"hello"},{"role":"assistant","content":"draft"},{"role":"system","content":"system"}]}`},
{name: "before user", body: `{"model":"route","max_tokens":64,"messages":[{"role":"user","content":"hello"},{"role":"system","content":"system"},{"role":"user","content":"again"}]}`},
{name: "non text", body: `{"model":"route","max_tokens":64,"messages":[{"role":"user","content":"hello"},{"role":"system","content":[{"type":"image","source":{"type":"url","url":"https://example.com/image.png"}}]}]}`},
} {
t.Run(tc.name, func(t *testing.T) {
if _, err := decodeAnthropicMessageRequest([]byte(tc.body), true); err == nil {
t.Fatal("invalid mid-conversation system message accepted")
}
})
}
}
func TestAnthropicChatBridgeThinkingCapabilityAndResponse(t *testing.T) {
candidate := anthropicTestCandidate(t, "openai")
candidate.ActualModel = "served-chat"

View file

@ -253,12 +253,23 @@ func decodeAnthropicMessageRequest(body []byte, requireMaxTokens bool) (anthropi
}
}
for index, message := range req.Messages {
if message.Role != "user" && message.Role != "assistant" {
return req, fmt.Errorf("messages[%d].role must be user or assistant", index)
if message.Role != "user" && message.Role != "assistant" && message.Role != "system" {
return req, fmt.Errorf("messages[%d].role must be user, assistant, or system", index)
}
if _, err := decodeAnthropicContent(message.Content); err != nil {
blocks, err := decodeAnthropicContent(message.Content)
if err != nil {
return req, fmt.Errorf("messages[%d].content: %w", index, err)
}
if message.Role == "system" {
for blockIndex, block := range blocks {
if block.Type != "text" {
return req, fmt.Errorf("messages[%d].content[%d] must be text for a system message", index, blockIndex)
}
}
}
}
if err := validateAnthropicMidConversationSystemPlacement(req.Messages); err != nil {
return req, err
}
if _, err := decodeAnthropicSystem(req.System); err != nil {
return req, err
@ -307,6 +318,28 @@ func decodeAnthropicMessageRequest(body []byte, requireMaxTokens bool) (anthropi
return req, nil
}
func validateAnthropicMidConversationSystemPlacement(messages []anthropicInputMessage) error {
for index, message := range messages {
if message.Role != "system" {
continue
}
if index == 0 {
return fmt.Errorf("messages[0].role system must use the top-level system field")
}
previousRole := messages[index-1].Role
if previousRole != "user" && previousRole != "system" {
return fmt.Errorf("messages[%d].role system must follow a user message", index)
}
if index+1 < len(messages) {
nextRole := messages[index+1].Role
if nextRole != "assistant" && nextRole != "system" {
return fmt.Errorf("messages[%d].role system must precede an assistant message or end the message list", index)
}
}
}
return nil
}
// validateAnthropicOutputEffort validates the caller's raw nested effort value
// without re-encoding the request. Native provider tunnels retain every field
// and byte outside the existing top-level model replacement.

View file

@ -1,7 +1,9 @@
package openai
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"strings"
@ -56,6 +58,345 @@ type openAIResponsesBridgeResponse struct {
} `json:"incomplete_details"`
}
// singleRequestChatBridgeResponse admits the portable Chat Completions response
// fields needed by private stages. Provider-selected bookkeeping such as
// service_tier, system_fingerprint, annotations, and logprobs is intentionally
// left outside the canonical stage envelope.
type singleRequestChatBridgeResponse struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []singleRequestChatBridgeChoice `json:"choices"`
Usage json.RawMessage `json:"usage"`
}
type singleRequestChatBridgeChoice struct {
Index int `json:"index"`
FinishReason string `json:"finish_reason"`
Message singleRequestChatBridgeMessage `json:"message"`
}
type singleRequestChatBridgeMessage struct {
Role string `json:"role"`
Content *string `json:"content"`
ToolCalls json.RawMessage `json:"tool_calls"`
ReasoningContent *string `json:"reasoning_content"`
ExtraContent json.RawMessage `json:"extra_content"`
Refusal json.RawMessage `json:"refusal"`
}
func (v *singleRequestChatBridgeResponse) UnmarshalJSON(data []byte) error {
if err := validateSingleRequestObjectFields(data, "id", "object", "created", "model", "choices", "usage", "service_tier", "system_fingerprint", "timings"); err != nil {
return err
}
type alias singleRequestChatBridgeResponse
var decoded alias
if err := json.Unmarshal(data, &decoded); err != nil {
return err
}
*v = singleRequestChatBridgeResponse(decoded)
return nil
}
func (v *singleRequestChatBridgeChoice) UnmarshalJSON(data []byte) error {
if err := validateSingleRequestObjectFields(data, "index", "finish_reason", "message", "logprobs"); err != nil {
return err
}
type alias singleRequestChatBridgeChoice
var decoded alias
if err := json.Unmarshal(data, &decoded); err != nil {
return err
}
*v = singleRequestChatBridgeChoice(decoded)
return nil
}
func (v *singleRequestChatBridgeMessage) UnmarshalJSON(data []byte) error {
if err := validateSingleRequestObjectFields(data, "role", "content", "tool_calls", "reasoning_content", "extra_content", "refusal", "annotations"); err != nil {
return err
}
type alias singleRequestChatBridgeMessage
var decoded alias
if err := json.Unmarshal(data, &decoded); err != nil {
return err
}
*v = singleRequestChatBridgeMessage(decoded)
return nil
}
func singleRequestProviderRequirements(options map[string]any, tools []any, responseFormat *singleRequestProviderResponseFormat) providerRequestRequirements {
requirements := providerRequestRequirements{HasTools: len(tools) > 0, StructuredOutput: responseFormat != nil}
if effort, ok := options["reasoning_effort"].(string); ok {
requirements.Effort = strings.TrimSpace(effort)
}
return requirements
}
func singleRequestProviderCandidatePredicate(frozen *edgeservice.SingleRequestStageDispatchBinding, requirements providerRequestRequirements) edgeservice.ProviderPoolCandidatePredicate {
return func(candidate edgeservice.ProviderPoolCandidate) bool {
if frozen == nil || candidate.ProtocolProfile == nil || candidate.ExecutionPath != string(edgeservice.ProviderPoolPathTunnel) {
return false
}
if frozen.CandidatePredicate != nil && !frozen.CandidatePredicate(candidate) {
return false
}
_, err := selectProviderOperation(*candidate.ProtocolProfile, config.OperationChatCompletions, requirements)
return err == nil
}
}
func singleRequestProviderTunnelPreparer(requirements providerRequestRequirements, buildChat singleRequestProviderBodyBuilder) func(edgeservice.SubmitProviderTunnelRequest, edgeservice.ProviderPoolCandidate) (edgeservice.SubmitProviderTunnelRequest, error) {
return func(tunnel edgeservice.SubmitProviderTunnelRequest, candidate edgeservice.ProviderPoolCandidate) (edgeservice.SubmitProviderTunnelRequest, error) {
if candidate.ProtocolProfile == nil || buildChat == nil {
return tunnel, errProviderStageMissingBinding
}
plan, err := selectProviderOperation(*candidate.ProtocolProfile, config.OperationChatCompletions, requirements)
if err != nil {
return tunnel, err
}
tunnel.Operation = string(plan.Operation)
switch plan.Operation {
case config.OperationChatCompletions:
tunnel.Path = "/v1/chat/completions"
case config.OperationResponses:
tunnel.Path = "/v1/responses"
default:
return tunnel, errProviderStageMissingBinding
}
tunnel.BuildBody = func(target string) ([]byte, error) {
body, err := buildChat(target)
if err != nil {
return nil, err
}
return normalizeSingleRequestProviderRequest(body, plan)
}
return tunnel, nil
}
}
func normalizeSingleRequestProviderRequest(chatBody []byte, plan providerOperationPlan) ([]byte, error) {
var body map[string]any
if err := json.Unmarshal(chatBody, &body); err != nil {
return nil, errProviderStageMalformed
}
if plan.Operation == config.OperationChatCompletions {
if plan.Effort != "" {
body["reasoning_effort"] = plan.Effort
}
return json.Marshal(body)
}
if plan.Operation != config.OperationResponses {
return nil, errProviderStageMalformed
}
return singleRequestChatToResponses(body, plan)
}
func singleRequestChatToResponses(chat map[string]any, plan providerOperationPlan) ([]byte, error) {
responses := map[string]any{"model": chat["model"], "stream": false}
for _, key := range []string{"temperature", "top_p", "service_tier"} {
if value, exists := chat[key]; exists {
responses[key] = value
}
}
for _, key := range []string{"max_completion_tokens", "max_tokens"} {
if value, exists := chat[key]; exists {
responses["max_output_tokens"] = value
break
}
}
messages, ok := chat["messages"].([]any)
if !ok {
return nil, errProviderStageMalformed
}
input := make([]any, 0, len(messages))
instructions := make([]string, 0, 1)
for _, raw := range messages {
message, ok := raw.(map[string]any)
if !ok {
return nil, errProviderStageMalformed
}
role, _ := message["role"].(string)
content, _ := message["content"].(string)
switch role {
case "system":
if content != "" {
instructions = append(instructions, content)
}
case "user":
input = append(input, map[string]any{"type": "message", "role": "user", "content": []any{map[string]any{"type": "input_text", "text": content}}})
case "assistant":
if content != "" {
input = append(input, map[string]any{"type": "message", "role": "assistant", "content": []any{map[string]any{"type": "output_text", "text": content}}})
}
for _, rawCall := range anySlice(message["tool_calls"]) {
call, ok := rawCall.(map[string]any)
function, functionOK := call["function"].(map[string]any)
if !ok || !functionOK {
return nil, errProviderStageMalformed
}
input = append(input, map[string]any{"type": "function_call", "call_id": call["id"], "name": function["name"], "arguments": function["arguments"]})
}
case "tool":
input = append(input, map[string]any{"type": "function_call_output", "call_id": message["tool_call_id"], "output": content})
default:
return nil, errProviderStageMalformed
}
}
if len(instructions) > 0 {
responses["instructions"] = strings.Join(instructions, "\n\n")
}
responses["input"] = input
if plan.Effort != "" {
responses["reasoning"] = map[string]any{"effort": plan.Effort}
}
if rawTools := anySlice(chat["tools"]); len(rawTools) > 0 {
tools := make([]any, 0, len(rawTools))
for _, rawTool := range rawTools {
tool, ok := rawTool.(map[string]any)
function, functionOK := tool["function"].(map[string]any)
if !ok || !functionOK {
return nil, errProviderStageMalformed
}
converted := map[string]any{"type": "function", "name": function["name"], "parameters": function["parameters"]}
for _, key := range []string{"description", "strict"} {
if value, exists := function[key]; exists {
converted[key] = value
}
}
tools = append(tools, converted)
}
responses["tools"] = tools
}
if choice, ok := chat["tool_choice"]; ok {
responses["tool_choice"] = singleRequestResponsesToolChoice(choice)
}
if parallel, ok := chat["parallel_tool_calls"]; ok {
responses["parallel_tool_calls"] = parallel
}
if format, ok := chat["response_format"].(map[string]any); ok {
if schema, schemaOK := format["json_schema"].(map[string]any); schemaOK {
converted := map[string]any{"type": "json_schema"}
for _, key := range []string{"name", "strict", "schema"} {
converted[key] = schema[key]
}
responses["text"] = map[string]any{"format": converted}
}
}
return json.Marshal(responses)
}
func anySlice(value any) []any {
if value == nil {
return nil
}
items, _ := value.([]any)
return items
}
func singleRequestResponsesToolChoice(choice any) any {
if object, ok := choice.(map[string]any); ok {
if function, functionOK := object["function"].(map[string]any); functionOK {
return map[string]any{"type": "function", "name": function["name"]}
}
}
return choice
}
func normalizeSingleRequestProviderResponse(body []byte, dispatch edgeservice.RunDispatch) ([]byte, error) {
switch dispatch.ProfileOperation {
case string(config.OperationResponses):
return normalizeSingleRequestResponsesResponse(body)
case string(config.OperationChatCompletions), "":
return normalizeSingleRequestChatResponse(body)
default:
return nil, errors.Join(errProviderStageGeneric, errProviderStageMalformed)
}
}
func normalizeSingleRequestChatResponse(body []byte) ([]byte, error) {
if err := validateSingleRequestJSON(body); err != nil {
return nil, errors.Join(errProviderStageGeneric, errProviderStageMalformed)
}
var response singleRequestChatBridgeResponse
if err := json.Unmarshal(body, &response); err != nil {
return nil, errors.Join(errProviderStageGeneric, errProviderStageMalformed)
}
choices := make([]any, 0, len(response.Choices))
for _, choice := range response.Choices {
if refusal := bytes.TrimSpace(choice.Message.Refusal); len(refusal) > 0 && !bytes.Equal(refusal, []byte("null")) {
return nil, errors.Join(errProviderStageGeneric, errProviderStageMalformed)
}
message := map[string]any{
"role": choice.Message.Role,
"content": choice.Message.Content,
}
if len(choice.Message.ToolCalls) > 0 {
message["tool_calls"] = choice.Message.ToolCalls
}
if choice.Message.ReasoningContent != nil {
message["reasoning_content"] = choice.Message.ReasoningContent
}
if len(choice.Message.ExtraContent) > 0 {
message["extra_content"] = choice.Message.ExtraContent
}
choices = append(choices, map[string]any{
"index": choice.Index, "finish_reason": choice.FinishReason, "message": message,
})
}
canonical := map[string]any{
"id": response.ID, "object": response.Object, "created": response.Created,
"model": response.Model, "choices": choices,
}
if len(response.Usage) > 0 && !bytes.Equal(bytes.TrimSpace(response.Usage), []byte("null")) {
canonical["usage"] = response.Usage
}
encoded, err := json.Marshal(canonical)
if err != nil {
return nil, errors.Join(errProviderStageGeneric, errProviderStageMalformed)
}
return encoded, nil
}
func normalizeSingleRequestResponsesResponse(body []byte) ([]byte, error) {
var response openAIResponsesBridgeResponse
if err := json.Unmarshal(body, &response); err != nil || strings.TrimSpace(response.ID) == "" {
return nil, errors.Join(errProviderStageGeneric, errProviderStageMalformed)
}
content := ""
toolCalls := make([]any, 0)
for _, item := range response.Output {
switch item.Type {
case "message":
for _, part := range item.Content {
if part.Type == "output_text" {
content += part.Text
}
}
case "function_call":
if item.CallID == "" || item.Name == "" || !json.Valid([]byte(item.Arguments)) {
return nil, errors.Join(errProviderStageGeneric, errProviderStageMalformed)
}
toolCalls = append(toolCalls, map[string]any{"id": item.CallID, "type": "function", "function": map[string]any{"name": item.Name, "arguments": item.Arguments}})
}
}
finishReason := "stop"
if len(toolCalls) > 0 {
finishReason = "tool_calls"
} else if response.Status == "incomplete" && response.IncompleteDetails.Reason == "max_output_tokens" {
finishReason = "length"
}
var contentValue any = content
if len(toolCalls) > 0 && content == "" {
contentValue = nil
}
chat := map[string]any{
"id": response.ID, "object": "chat.completion", "created": int64(0), "model": response.Model,
"choices": []any{map[string]any{"index": 0, "finish_reason": finishReason, "message": map[string]any{"role": "assistant", "content": contentValue, "tool_calls": toolCalls}}},
}
return json.Marshal(chat)
}
func anthropicProviderRequirements(req anthropicMessageRequest) providerRequestRequirements {
requirements := providerRequestRequirements{
HasTools: len(req.Tools) > 0,
@ -163,7 +504,8 @@ func rewriteResponsesProviderControls(body []byte, target string, plan providerO
// requirement is eligible.
func selectProviderOperation(profile config.ConcreteProtocolProfile, ingress config.ProtocolOperation, requirements providerRequestRequirements) (providerOperationPlan, error) {
operations := []config.ProtocolOperation{ingress}
if ingress == config.OperationMessages {
switch ingress {
case config.OperationMessages:
switch profile.Driver {
case config.ProtocolDriverAnthropicMessages:
operations = []config.ProtocolOperation{config.OperationMessages}
@ -174,6 +516,15 @@ func selectProviderOperation(profile config.ConcreteProtocolProfile, ingress con
default:
operations = nil
}
case config.OperationChatCompletions:
switch profile.Driver {
case config.ProtocolDriverOpenAIChat:
operations = []config.ProtocolOperation{config.OperationChatCompletions, config.OperationResponses}
case config.ProtocolDriverOpenAIResponses:
operations = []config.ProtocolOperation{config.OperationResponses}
default:
operations = nil
}
}
for _, operation := range operations {
@ -342,6 +693,19 @@ func anthropicMessageToResponses(role string, blocks []anthropicContentBlock) ([
}
return out, nil
}
if role == "system" {
content := make([]map[string]any, 0, len(blocks))
for _, block := range blocks {
if block.Type != "text" {
return nil, fmt.Errorf("content block %q is invalid for a system message", block.Type)
}
content = append(content, map[string]any{"type": "input_text", "text": block.Text})
}
if len(content) == 0 {
return nil, fmt.Errorf("system message content is empty")
}
return []map[string]any{{"type": "message", "role": "system", "content": content}}, nil
}
out := make([]map[string]any, 0, len(blocks))
content := make([]map[string]any, 0, len(blocks))

View file

@ -168,17 +168,44 @@ func validateAnthropicMessages(rawMessages json.RawMessage) ([]json.RawMessage,
if err := json.Unmarshal(rawMsg, &m); err != nil {
return nil, fmt.Errorf("decode anthropic message at index %d: %w", i, err)
}
if m.Role != "user" && m.Role != "assistant" {
if m.Role != "user" && m.Role != "assistant" && m.Role != "system" {
return nil, fmt.Errorf("invalid anthropic message role %q at index %d", m.Role, i)
}
if i == 0 && m.Role != "user" {
return nil, fmt.Errorf("anthropic messages first message must have role user, got %q", m.Role)
}
if i > 0 {
if m.Role == "system" {
if len(pendingToolUseIDs) > 0 {
return nil, fmt.Errorf("anthropic system message at index %d cannot interrupt pending tool results", i)
}
if i > 0 {
var previous struct {
Role string `json:"role"`
}
_ = json.Unmarshal(msgList[i-1], &previous)
if previous.Role != "user" && previous.Role != "system" {
return nil, fmt.Errorf("anthropic system message at index %d must follow a user message", i)
}
}
if i+1 < len(msgList) {
var next struct {
Role string `json:"role"`
}
_ = json.Unmarshal(msgList[i+1], &next)
if next.Role != "assistant" && next.Role != "system" {
return nil, fmt.Errorf("anthropic system message at index %d must precede an assistant message or end the message list", i)
}
}
} else if i > 0 {
var prev struct {
Role string `json:"role"`
}
_ = json.Unmarshal(msgList[i-1], &prev)
for previousIndex := i - 1; previousIndex >= 0; previousIndex-- {
_ = json.Unmarshal(msgList[previousIndex], &prev)
if prev.Role != "system" {
break
}
}
if m.Role == prev.Role {
return nil, fmt.Errorf("anthropic messages roles must alternate, repeated role %q at index %d", m.Role, i)
}
@ -189,7 +216,13 @@ func validateAnthropicMessages(rawMessages json.RawMessage) ([]json.RawMessage,
return nil, fmt.Errorf("anthropic message %d: %w", i, err)
}
if m.Role == "assistant" {
if m.Role == "system" {
for bIdx, block := range blocks {
if block.Type != "text" {
return nil, fmt.Errorf("anthropic system message %d block %d has invalid type %q", i, bIdx, block.Type)
}
}
} else if m.Role == "assistant" {
for bIdx, block := range blocks {
if block.Type == "tool_result" || block.Type == "image" {
return nil, fmt.Errorf("anthropic assistant message %d block %d has invalid type %q", i, bIdx, block.Type)

View file

@ -281,12 +281,25 @@ func TestSingleRequestExecutorConcurrentToolIsolation(t *testing.T) {
mockSvc := &mockService{
submit: func(_ context.Context, req edgeservice.ProviderPoolDispatchRequest) (*edgeservice.ProviderPoolDispatchResult, error) {
reqID := req.Tunnel.SessionID
profile, err := config.ResolveProtocolProfile("gemini", "", config.BuiltInProtocolProfileCatalog())
if err != nil {
return nil, err
}
profile.ID = "profile-1"
candidate := edgeservice.ProviderPoolCandidate{
ActualModel: "gemini-3.6-flash", ProviderID: "gemini", ExecutionPath: string(edgeservice.ProviderPoolPathTunnel),
ProfileID: "profile-1", ProfileDriver: string(profile.Driver), ProfileCapabilities: append([]string(nil), profile.Capabilities...), ProtocolProfile: &profile,
}
tunnelReq, err := prepareSingleRequestTestTunnel(req, candidate)
if err != nil {
return nil, err
}
reqID := tunnelReq.SessionID
if reqID == "" {
return nil, errors.New("missing session ID in tunnel request")
}
reqBody, err := req.Tunnel.BuildBody("gemini-3.6-flash")
reqBody, err := tunnelReq.BuildBody("gemini-3.6-flash")
if err != nil {
return nil, err
}
@ -324,10 +337,12 @@ func TestSingleRequestExecutorConcurrentToolIsolation(t *testing.T) {
resp = executorReviewPassBody(fmt.Sprintf("Reviewer Approved for %s", reqID), fmt.Sprintf("Review pass for %s", reqID))
}
dispatch := matchingDispatch()
dispatch.ProfileOperation = tunnelReq.Operation
return &edgeservice.ProviderPoolDispatchResult{
Path: edgeservice.ProviderPoolPathTunnel,
Tunnel: &mockTunnel{frames: framesFor(resp)},
DispatchInfo: matchingDispatch(),
DispatchInfo: dispatch,
}, nil
},
}

View file

@ -43,6 +43,8 @@ type singleRequestProviderStageResponse struct {
Dispatch edgeservice.RunDispatch
}
type singleRequestProviderBodyBuilder func(string) ([]byte, error)
var (
errProviderStageMissingBinding = errors.New("provider stage: missing stage binding")
errProviderStageMissingInput = errors.New("provider stage: missing messages")
@ -64,18 +66,22 @@ func (s *singleRequestProviderStage) submit(ctx context.Context, req singleReque
stageCtx, cancel := providerStageContext(ctx, req.Limits.StageTimeoutMS)
defer cancel()
requirements := singleRequestProviderRequirements(req.StageBinding.Options, nil, req.ResponseFormat)
poolReq := edgeservice.ProviderPoolDispatchRequest{
Run: edgeservice.SubmitRunRequest{NodeRef: req.NodeRef, ModelGroupKey: dispatch.ModelGroupKey, ProviderID: dispatch.ProviderID, UsageAttribution: req.UsageAttribution, SessionID: req.SessionID, TimeoutSec: dispatch.TimeoutSec, MaxQueue: dispatch.MaxQueue, QueueTimeoutMS: dispatch.QueueTimeoutMS, ProviderPool: true},
Tunnel: edgeservice.SubmitProviderTunnelRequest{
CredentialBinding: dispatch.CredentialBindingSnapshot(), NodeRef: req.NodeRef, ModelGroupKey: dispatch.ModelGroupKey, ProviderID: dispatch.ProviderID, UsageAttribution: req.UsageAttribution,
Adapter: "openai_compat", Target: dispatch.UpstreamModel, SessionID: req.SessionID, Method: http.MethodPost, Path: "/v1/chat/completions", Operation: string(config.OperationChatCompletions), Stream: false,
Adapter: "openai_compat", Target: dispatch.UpstreamModel, SessionID: req.SessionID, Method: http.MethodPost, Path: "/v1/chat/completions", Stream: false,
TimeoutSec: dispatch.TimeoutSec, MaxQueue: dispatch.MaxQueue, QueueTimeoutMS: dispatch.QueueTimeoutMS, ProviderPool: true,
BuildBody: func(target string) ([]byte, error) {
return buildSingleRequestChatBody(req.Messages, req.StageBinding.Options, req.ResponseFormat, target)
},
},
AcceptCandidate: dispatch.CandidatePredicate,
AcceptCandidate: singleRequestProviderCandidatePredicate(dispatch, requirements),
}
poolReq.PrepareProtocolTunnel = singleRequestProviderTunnelPreparer(requirements, func(target string) ([]byte, error) {
return buildSingleRequestChatBody(req.Messages, req.StageBinding.Options, req.ResponseFormat, target)
})
result, err := s.service.SubmitProviderPool(stageCtx, poolReq)
if err != nil || result == nil {
return nil, quality.providerFailure(stageCtx, err, errProviderStageGeneric)
@ -92,6 +98,10 @@ func (s *singleRequestProviderStage) submit(ctx context.Context, req singleReque
if err != nil {
return nil, quality.providerFailure(stageCtx, err, errProviderStageGeneric)
}
body, err = normalizeSingleRequestProviderResponse(body, result.DispatchInfo)
if err != nil {
return nil, quality.providerFailure(stageCtx, err, errProviderStageGeneric)
}
response, err := decodeSingleRequestChatResponse(body, result.DispatchInfo)
if err != nil {
return nil, quality.providerFailure(stageCtx, err, errProviderStageGeneric)
@ -109,7 +119,20 @@ func providerStageContext(parent context.Context, timeoutMS int) (context.Contex
func providerStageDispatchMatches(result *edgeservice.ProviderPoolDispatchResult, frozen *edgeservice.SingleRequestStageDispatchBinding) bool {
d := result.DispatchInfo
return result.Path == edgeservice.ProviderPoolPathTunnel && d.ProfileDriver == string(config.ProtocolDriverOpenAIChat) && d.ModelGroupKey == frozen.ModelGroupKey && d.ProviderID == frozen.ProviderID && d.Target == frozen.UpstreamModel && d.ProfileID == frozen.ProfileID && d.CredentialSlotRef == frozen.CredentialSlotRef && d.CredentialRevision == frozen.CredentialRevision && d.ExecutionPath == string(edgeservice.ProviderPoolPathTunnel)
operation := config.ProtocolOperation(d.ProfileOperation)
driver := config.ProtocolDriver(d.ProfileDriver)
// A default managed resource selector intentionally freezes no provider ID:
// the provider pool owns the concrete provider choice while profile, target,
// credential, and execution-path fences remain fixed. Explicit selectors
// continue to require the exact admitted provider.
providerMatches := frozen.ProviderID == "" || d.ProviderID == frozen.ProviderID
return result.Path == edgeservice.ProviderPoolPathTunnel &&
(operation == config.OperationChatCompletions || operation == config.OperationResponses || operation == "") &&
(driver == config.ProtocolDriverOpenAIChat || driver == config.ProtocolDriverOpenAIResponses) &&
d.ModelGroupKey == frozen.ModelGroupKey && providerMatches &&
d.Target == frozen.UpstreamModel && d.ProfileID == frozen.ProfileID &&
d.CredentialSlotRef == frozen.CredentialSlotRef && d.CredentialRevision == frozen.CredentialRevision &&
d.ExecutionPath == string(edgeservice.ProviderPoolPathTunnel)
}
func collectProviderStageFrames(ctx context.Context, frames <-chan *iop.ProviderTunnelFrame, maximum int) ([]byte, error) {

View file

@ -1,6 +1,7 @@
package openai
import (
"bytes"
"context"
"encoding/json"
"errors"
@ -125,6 +126,16 @@ func matchingDispatch() edgeservice.RunDispatch {
}
}
func prepareSingleRequestTestTunnel(req edgeservice.ProviderPoolDispatchRequest, candidate edgeservice.ProviderPoolCandidate) (edgeservice.SubmitProviderTunnelRequest, error) {
if req.PrepareProtocolTunnel != nil {
return req.PrepareProtocolTunnel(req.Tunnel, candidate)
}
if req.PrepareTunnel != nil {
return req.PrepareTunnel(req.Tunnel)
}
return req.Tunnel, nil
}
func framesFor(body []byte) chan *iop.ProviderTunnelFrame {
c := make(chan *iop.ProviderTunnelFrame, 3)
c <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_RESPONSE_START, StatusCode: 200}
@ -169,7 +180,7 @@ func TestSingleRequestProviderStageUsesFrozenOptionsAndDispatch(t *testing.T) {
t.Fatalf("unexpected Run dispatch: %+v", captured.Run)
}
if captured.Tunnel.NodeRef != "node" || captured.Tunnel.ModelGroupKey != "plan-model" || captured.Tunnel.ProviderID != "gemini" || captured.Tunnel.UsageAttribution != "principal" || captured.Tunnel.Adapter != "openai_compat" || captured.Tunnel.Target != "gemini-3.6-flash" || captured.Tunnel.SessionID != "session" || captured.Tunnel.Method != http.MethodPost || captured.Tunnel.Path != "/v1/chat/completions" || captured.Tunnel.Operation != string(config.OperationChatCompletions) || captured.Tunnel.Stream || captured.Tunnel.TimeoutSec != 60 || captured.Tunnel.MaxQueue != 10 || captured.Tunnel.QueueTimeoutMS != 5000 || !captured.Tunnel.ProviderPool || captured.Tunnel.RunID != "" || captured.Tunnel.Headers != nil || captured.Tunnel.Body != nil || captured.Tunnel.Metadata != nil || captured.Tunnel.EstimatedInputTokens != 0 || captured.Tunnel.ContextClass != "" || captured.Tunnel.ResponseStallTimeoutMS != 0 {
if captured.Tunnel.NodeRef != "node" || captured.Tunnel.ModelGroupKey != "plan-model" || captured.Tunnel.ProviderID != "gemini" || captured.Tunnel.UsageAttribution != "principal" || captured.Tunnel.Adapter != "openai_compat" || captured.Tunnel.Target != "gemini-3.6-flash" || captured.Tunnel.SessionID != "session" || captured.Tunnel.Method != http.MethodPost || captured.Tunnel.Path != "/v1/chat/completions" || captured.Tunnel.Operation != "" || captured.Tunnel.Stream || captured.Tunnel.TimeoutSec != 60 || captured.Tunnel.MaxQueue != 10 || captured.Tunnel.QueueTimeoutMS != 5000 || !captured.Tunnel.ProviderPool || captured.Tunnel.RunID != "" || captured.Tunnel.Headers != nil || captured.Tunnel.Body != nil || captured.Tunnel.Metadata != nil || captured.Tunnel.EstimatedInputTokens != 0 || captured.Tunnel.ContextClass != "" || captured.Tunnel.ResponseStallTimeoutMS != 0 {
t.Fatalf("unexpected Tunnel request: %+v", captured.Tunnel)
}
@ -190,12 +201,137 @@ func TestSingleRequestProviderStageUsesFrozenOptionsAndDispatch(t *testing.T) {
t.Fatalf("unexpected frozen body: %s", body)
}
// Check candidate predicate function matches dispatch with both accepted and rejected candidates
if captured.AcceptCandidate == nil || !captured.AcceptCandidate(edgeservice.ProviderPoolCandidate{ProviderID: "gemini"}) || captured.AcceptCandidate(edgeservice.ProviderPoolCandidate{ProviderID: "other"}) {
profile, err := config.ResolveProtocolProfile("gemini", "", config.BuiltInProtocolProfileCatalog())
if err != nil {
t.Fatal(err)
}
accepted := edgeservice.ProviderPoolCandidate{ProviderID: "gemini", ExecutionPath: string(edgeservice.ProviderPoolPathTunnel), ProtocolProfile: &profile}
rejected := accepted
rejected.ProviderID = "other"
// Check candidate predicate against the same concrete profile facts supplied
// by the real provider-pool service.
if captured.AcceptCandidate == nil || !captured.AcceptCandidate(accepted) || captured.AcceptCandidate(rejected) {
t.Fatalf("AcceptCandidate predicate missing or returned unexpected result")
}
}
func TestSingleRequestProviderNormalizationSelectsResponsesAndFallsBackEffort(t *testing.T) {
catalog := config.BuiltInProtocolProfileCatalog()
openAI := catalog["openai"]
responses := openAI.Normalization.Effort[string(config.OperationResponses)]
delete(responses.Levels, "max")
openAI.Normalization.Effort[string(config.OperationResponses)] = responses
catalog["openai-without-max"] = openAI
profile, err := config.ResolveProtocolProfile("openai-without-max", "", catalog)
if err != nil {
t.Fatal(err)
}
tools := []any{singleRequestWorkToolSchema("workspace_read", map[string]any{
"type": "object", "properties": map[string]any{}, "additionalProperties": false,
})}
requirements := singleRequestProviderRequirements(map[string]any{"reasoning_effort": "max"}, tools, singleRequestPlanResponseFormat())
plan, err := selectProviderOperation(profile, config.OperationChatCompletions, requirements)
if err != nil {
t.Fatal(err)
}
if plan.Operation != config.OperationResponses || plan.Effort != "xhigh" || plan.EffortWire != config.ProtocolEffortWireOpenAIResponses {
t.Fatalf("plan=%+v, want Responses max->xhigh", plan)
}
chatBody := func(target string) ([]byte, error) {
return json.Marshal(map[string]any{
"model": target, "stream": false, "reasoning_effort": "max", "max_completion_tokens": 256,
"messages": []any{
map[string]any{"role": "system", "content": "system"},
map[string]any{"role": "user", "content": "task"},
},
"tools": tools, "tool_choice": "auto", "response_format": singleRequestPlanResponseFormat(),
})
}
prepared, err := singleRequestProviderTunnelPreparer(requirements, chatBody)(
edgeservice.SubmitProviderTunnelRequest{Path: "/v1/chat/completions"},
edgeservice.ProviderPoolCandidate{ExecutionPath: string(edgeservice.ProviderPoolPathTunnel), ProtocolProfile: &profile},
)
if err != nil {
t.Fatal(err)
}
if prepared.Operation != string(config.OperationResponses) || prepared.Path != "/v1/responses" {
t.Fatalf("prepared operation/path=%q/%q", prepared.Operation, prepared.Path)
}
body, err := prepared.BuildBody("gpt-model")
if err != nil {
t.Fatal(err)
}
var decoded map[string]any
if err := json.Unmarshal(body, &decoded); err != nil {
t.Fatal(err)
}
reasoning, _ := decoded["reasoning"].(map[string]any)
text, _ := decoded["text"].(map[string]any)
format, _ := text["format"].(map[string]any)
if decoded["model"] != "gpt-model" || decoded["max_output_tokens"] != float64(256) || reasoning["effort"] != "xhigh" || format["type"] != "json_schema" || len(anySlice(decoded["tools"])) != 1 {
t.Fatalf("normalized Responses body=%s", body)
}
}
func TestSingleRequestProviderNormalizationConvertsResponsesResult(t *testing.T) {
raw := []byte(`{"id":"resp-1","model":"gpt-model","status":"completed","output":[{"type":"message","content":[{"type":"output_text","text":"{\"goal\":\"g\",\"steps\":[\"a\",\"b\"],\"verification\":[\"v\"]}"}]}]}`)
body, err := normalizeSingleRequestProviderResponse(raw, edgeservice.RunDispatch{ProfileOperation: string(config.OperationResponses)})
if err != nil {
t.Fatal(err)
}
response, err := decodeSingleRequestChatResponse(body, edgeservice.RunDispatch{ProfileOperation: string(config.OperationResponses)})
if err != nil {
t.Fatal(err)
}
if response.Output != `{"goal":"g","steps":["a","b"],"verification":["v"]}` {
t.Fatalf("output=%q", response.Output)
}
}
func TestSingleRequestProviderNormalizationCanonicalizesChatResult(t *testing.T) {
raw := []byte(`{"id":"chatcmpl-1","object":"chat.completion","created":1,"model":"gpt-model","service_tier":"default","system_fingerprint":"fp","timings":{"prompt_n":2,"predicted_n":1},"choices":[{"index":0,"finish_reason":"stop","logprobs":null,"message":{"role":"assistant","content":"{\"goal\":\"g\",\"steps\":[\"a\",\"b\"],\"verification\":[\"v\"]}","refusal":null,"annotations":[]}}],"usage":{"prompt_tokens":2,"completion_tokens":1,"total_tokens":3}}`)
body, err := normalizeSingleRequestProviderResponse(raw, edgeservice.RunDispatch{ProfileOperation: string(config.OperationChatCompletions)})
if err != nil {
t.Fatal(err)
}
if bytes.Contains(body, []byte("service_tier")) || bytes.Contains(body, []byte("system_fingerprint")) || bytes.Contains(body, []byte("timings")) || bytes.Contains(body, []byte("annotations")) || bytes.Contains(body, []byte("refusal")) {
t.Fatalf("provider bookkeeping leaked into canonical stage response: %s", body)
}
response, err := decodeSingleRequestChatResponse(body, edgeservice.RunDispatch{ProfileOperation: string(config.OperationChatCompletions)})
if err != nil {
t.Fatal(err)
}
if response.Output != `{"goal":"g","steps":["a","b"],"verification":["v"]}` {
t.Fatalf("output=%q", response.Output)
}
}
func TestSingleRequestProviderNormalizationRejectsChatRefusal(t *testing.T) {
raw := []byte(`{"id":"chatcmpl-1","object":"chat.completion","created":1,"model":"gpt-model","choices":[{"index":0,"finish_reason":"stop","message":{"role":"assistant","content":null,"refusal":"cannot comply"}}]}`)
if _, err := normalizeSingleRequestProviderResponse(raw, edgeservice.RunDispatch{ProfileOperation: string(config.OperationChatCompletions)}); !errors.Is(err, errProviderStageMalformed) {
t.Fatalf("err=%v, want malformed", err)
}
}
func TestSingleRequestProviderStageAcceptsDefaultSelectorProviderChoice(t *testing.T) {
request := providerRequest()
request.StageBinding.Dispatch.ProviderID = ""
tunnel := &mockTunnel{frames: framesFor(successBody("ok"))}
dispatch := matchingDispatch()
stage := newSingleRequestProviderStage(&mockService{submit: func(context.Context, edgeservice.ProviderPoolDispatchRequest) (*edgeservice.ProviderPoolDispatchResult, error) {
return &edgeservice.ProviderPoolDispatchResult{Path: edgeservice.ProviderPoolPathTunnel, Tunnel: tunnel, DispatchInfo: dispatch}, nil
}})
response, err := stage.submit(context.Background(), request)
if err != nil {
t.Fatal(err)
}
if response.Output != "ok" || response.Dispatch.ProviderID != "gemini" {
t.Fatalf("response=%+v", response)
}
}
func TestSingleRequestProviderStageRejectsResponseEnvelope(t *testing.T) {
tests := []struct {
name string

View file

@ -10,7 +10,6 @@ import (
"strings"
edgeservice "iop/apps/edge/internal/service"
"iop/packages/go/config"
"iop/packages/go/singlerequesttemplate"
)
@ -229,11 +228,15 @@ func (s *singleRequestReviewStage) submit(ctx context.Context, req singleRequest
defer cancel()
poolReq := edgeservice.ProviderPoolDispatchRequest{
Run: edgeservice.SubmitRunRequest{NodeRef: req.NodeRef, ModelGroupKey: dispatch.ModelGroupKey, ProviderID: dispatch.ProviderID, UsageAttribution: req.UsageAttribution, SessionID: req.SessionID, TimeoutSec: dispatch.TimeoutSec, MaxQueue: dispatch.MaxQueue, QueueTimeoutMS: dispatch.QueueTimeoutMS, ProviderPool: true},
Tunnel: edgeservice.SubmitProviderTunnelRequest{CredentialBinding: dispatch.CredentialBindingSnapshot(), NodeRef: req.NodeRef, ModelGroupKey: dispatch.ModelGroupKey, ProviderID: dispatch.ProviderID, UsageAttribution: req.UsageAttribution, Adapter: "openai_compat", Target: dispatch.UpstreamModel, SessionID: req.SessionID, Method: http.MethodPost, Path: "/v1/chat/completions", Operation: string(config.OperationChatCompletions), Stream: false, TimeoutSec: dispatch.TimeoutSec, MaxQueue: dispatch.MaxQueue, QueueTimeoutMS: dispatch.QueueTimeoutMS, ProviderPool: true, BuildBody: func(target string) ([]byte, error) {
Tunnel: edgeservice.SubmitProviderTunnelRequest{CredentialBinding: dispatch.CredentialBindingSnapshot(), NodeRef: req.NodeRef, ModelGroupKey: dispatch.ModelGroupKey, ProviderID: dispatch.ProviderID, UsageAttribution: req.UsageAttribution, Adapter: "openai_compat", Target: dispatch.UpstreamModel, SessionID: req.SessionID, Method: http.MethodPost, Path: "/v1/chat/completions", Stream: false, TimeoutSec: dispatch.TimeoutSec, MaxQueue: dispatch.MaxQueue, QueueTimeoutMS: dispatch.QueueTimeoutMS, ProviderPool: true, BuildBody: func(target string) ([]byte, error) {
return buildSingleRequestReviewBody(messages, req.StageBinding.Options, tools, target, repairRequired)
}},
AcceptCandidate: dispatch.CandidatePredicate,
}
requirements := singleRequestProviderRequirements(req.StageBinding.Options, tools, singleRequestReviewResponseFormat())
poolReq.AcceptCandidate = singleRequestProviderCandidatePredicate(dispatch, requirements)
poolReq.PrepareProtocolTunnel = singleRequestProviderTunnelPreparer(requirements, func(target string) ([]byte, error) {
return buildSingleRequestReviewBody(messages, req.StageBinding.Options, tools, target, repairRequired)
})
result, err := s.provider.service.SubmitProviderPool(stageCtx, poolReq)
if err != nil || result == nil || result.Tunnel == nil {
return nil, quality.providerFailure(stageCtx, err, errSingleRequestReviewStage)
@ -246,6 +249,10 @@ func (s *singleRequestReviewStage) submit(ctx context.Context, req singleRequest
if err != nil {
return nil, quality.providerFailure(stageCtx, err, errSingleRequestReviewStage)
}
body, err = normalizeSingleRequestProviderResponse(body, result.DispatchInfo)
if err != nil {
return nil, quality.providerFailure(stageCtx, err, errSingleRequestReviewStage)
}
response, err := decodeSingleRequestReviewProviderResponse(body, req.Limits.MaxOutputBytes)
if err != nil {
if !errors.Is(err, errProviderStageOutputLimit) && !errors.Is(err, errProviderStageContextLimit) {

View file

@ -1037,7 +1037,7 @@ func assertSingleRequestReviewDispatchAuthority(t *testing.T, got edgeservice.Pr
SessionID: "review-session",
Method: "POST",
Path: "/v1/chat/completions",
Operation: string(config.OperationChatCompletions),
Operation: "",
Stream: false,
TimeoutSec: dispatch.TimeoutSec,
MaxQueue: dispatch.MaxQueue,
@ -1051,10 +1051,16 @@ func assertSingleRequestReviewDispatchAuthority(t *testing.T, got edgeservice.Pr
if got.AcceptCandidate == nil {
t.Fatal("missing AcceptCandidate predicate")
}
if !got.AcceptCandidate(edgeservice.ProviderPoolCandidate{ProviderID: dispatch.ProviderID}) {
profile, err := config.ResolveProtocolProfile("gemini", "", config.BuiltInProtocolProfileCatalog())
if err != nil {
t.Fatal(err)
}
accepted := edgeservice.ProviderPoolCandidate{ProviderID: dispatch.ProviderID, ExecutionPath: string(edgeservice.ProviderPoolPathTunnel), ProtocolProfile: &profile}
if !got.AcceptCandidate(accepted) {
t.Fatalf("AcceptCandidate rejected matching provider %q", dispatch.ProviderID)
}
if got.AcceptCandidate(edgeservice.ProviderPoolCandidate{ProviderID: "rejected-provider"}) {
accepted.ProviderID = "rejected-provider"
if got.AcceptCandidate(accepted) {
t.Fatalf("AcceptCandidate accepted non-matching provider")
}
}

View file

@ -12,7 +12,6 @@ import (
"sync"
edgeservice "iop/apps/edge/internal/service"
"iop/packages/go/config"
)
const (
@ -436,11 +435,18 @@ func (s *singleRequestWorkStage) submit(ctx context.Context, req singleRequestWo
defer cancel()
poolReq := edgeservice.ProviderPoolDispatchRequest{
Run: edgeservice.SubmitRunRequest{NodeRef: req.NodeRef, ModelGroupKey: dispatch.ModelGroupKey, ProviderID: dispatch.ProviderID, UsageAttribution: req.UsageAttribution, SessionID: req.SessionID, TimeoutSec: dispatch.TimeoutSec, MaxQueue: dispatch.MaxQueue, QueueTimeoutMS: dispatch.QueueTimeoutMS, ProviderPool: true},
Tunnel: edgeservice.SubmitProviderTunnelRequest{CredentialBinding: dispatch.CredentialBindingSnapshot(), NodeRef: req.NodeRef, ModelGroupKey: dispatch.ModelGroupKey, ProviderID: dispatch.ProviderID, UsageAttribution: req.UsageAttribution, Adapter: "openai_compat", Target: dispatch.UpstreamModel, SessionID: req.SessionID, Method: http.MethodPost, Path: "/v1/chat/completions", Operation: string(config.OperationChatCompletions), Stream: false, TimeoutSec: dispatch.TimeoutSec, MaxQueue: dispatch.MaxQueue, QueueTimeoutMS: dispatch.QueueTimeoutMS, ProviderPool: true, BuildBody: func(target string) ([]byte, error) {
Tunnel: edgeservice.SubmitProviderTunnelRequest{CredentialBinding: dispatch.CredentialBindingSnapshot(), NodeRef: req.NodeRef, ModelGroupKey: dispatch.ModelGroupKey, ProviderID: dispatch.ProviderID, UsageAttribution: req.UsageAttribution, Adapter: "openai_compat", Target: dispatch.UpstreamModel, SessionID: req.SessionID, Method: http.MethodPost, Path: "/v1/chat/completions", Stream: false, TimeoutSec: dispatch.TimeoutSec, MaxQueue: dispatch.MaxQueue, QueueTimeoutMS: dispatch.QueueTimeoutMS, ProviderPool: true, BuildBody: func(target string) ([]byte, error) {
return buildSingleRequestWorkBody(messages, req.StageBinding.Options, tools, target, completionEligible)
}},
AcceptCandidate: dispatch.CandidatePredicate,
}
requirements := singleRequestProviderRequirements(req.StageBinding.Options, tools, nil)
if completionEligible {
requirements.StructuredOutput = true
}
poolReq.AcceptCandidate = singleRequestProviderCandidatePredicate(dispatch, requirements)
poolReq.PrepareProtocolTunnel = singleRequestProviderTunnelPreparer(requirements, func(target string) ([]byte, error) {
return buildSingleRequestWorkBody(messages, req.StageBinding.Options, tools, target, completionEligible)
})
result, err := s.provider.service.SubmitProviderPool(stageCtx, poolReq)
if err != nil || result == nil || result.Tunnel == nil {
return nil, quality.providerFailure(stageCtx, err, errSingleRequestWorkStage)
@ -453,6 +459,10 @@ func (s *singleRequestWorkStage) submit(ctx context.Context, req singleRequestWo
if err != nil {
return nil, quality.providerFailure(stageCtx, err, errSingleRequestWorkStage)
}
body, err = normalizeSingleRequestProviderResponse(body, result.DispatchInfo)
if err != nil {
return nil, quality.providerFailure(stageCtx, err, errSingleRequestWorkStage)
}
response, err := decodeSingleRequestWorkProviderResponse(body, req.Limits.MaxOutputBytes)
if err != nil {
if !errors.Is(err, errProviderStageOutputLimit) && !errors.Is(err, errProviderStageContextLimit) {

View file

@ -83,7 +83,7 @@ Agent dispatcher silence safety = 70000
Edge request hard timeout > 70000
```
dev-runtime은 `max_request_fault_recovery: 0``max_strategy_fault_recovery: 0`을 명시하고 Pi agent retry도 비활성화한다. 따라서 provider 무진행은 Node가 60초에 한 번만 종료하며 Pi·Edge·Dispatcher가 같은 요청을 다시 중첩 실행하지 않는다.
dev-runtime은 blocking `repeat_guard`와 함께 `max_request_fault_recovery: 1`, `max_strategy_fault_recovery: 1`을 사용하고 Pi agent retry는 비활성화한다. 따라서 반복 출력에는 Stream Evidence Gate가 요청 내부 continuation recovery를 최대 한 번 수행하고, 외부 Pi 재시도나 중첩 요청은 만들지 않는다.
`response_stall_timeout_ms` 변경은 restart-required다. `config check``config refresh --mode dry-run`에서 이를 확인한 뒤 Edge와 Node를 같은 source ref로 rebuild/restart하고, 각 binary의 build identity와 실행 중인 process identity를 다시 대조한다. tracked 검증 근거에는 source/build/config 식별자, 단조 시간, terminal 개수와 결과 분류만 남기며 prompt, output, token, credential 원문은 기록하지 않는다.

View file

@ -540,6 +540,11 @@ func (s StreamEvidenceGateConf) Validate() error {
if effStrategy > effTotal {
return fmt.Errorf("max_strategy_fault_recovery (%d) must not exceed max_request_fault_recovery (%d)", effStrategy, effTotal)
}
for i, filter := range s.Filters {
if s.Enabled && filter.Filter == StreamGateFilterRepeatGuard && filterCanResolveBlocking(filter) && (effTotal == 0 || effStrategy == 0) {
return fmt.Errorf("stream_evidence_gate filters[%d]: blocking repeat_guard requires max_request_fault_recovery and max_strategy_fault_recovery to be at least 1", i)
}
}
effIngress := s.EffectiveMaxIngressSnapshotBytes()
maxAllowedIngress := 16 * 1024 * 1024
@ -561,6 +566,29 @@ func (s StreamEvidenceGateConf) Validate() error {
return nil
}
func filterCanResolveBlocking(filter StreamGateFilterPolicyConf) bool {
if filter.EffectiveEnabled() && filter.EffectiveEnforcement() == StreamGateFilterEnforcementBlocking {
return true
}
for _, selector := range filter.Selectors {
enabled := filter.EffectiveEnabled()
if selector.Enabled != nil {
enabled = *selector.Enabled
}
if !enabled {
continue
}
enforcement := filter.EffectiveEnforcement()
if selector.Enforcement != "" {
enforcement = selector.Enforcement
}
if enforcement == StreamGateFilterEnforcementBlocking {
return true
}
}
return false
}
// EdgeOpenAIProviderAuthConf configures request-time provider token forwarding.
// It only describes which caller header to forward and never stores raw token
// values in config.

View file

@ -406,6 +406,107 @@ openai:
}
}
func TestStreamEvidenceGate_BlockingRepeatGuardRequiresRecoveryBudget(t *testing.T) {
boolPtr := func(value bool) *bool { return &value }
zero, one := 0, 1
tests := []struct {
name string
gate config.StreamEvidenceGateConf
expectErr bool
}{
{
name: "blocking base with zero budget",
gate: config.StreamEvidenceGateConf{
Enabled: true,
MaxRequestFaultRecovery: &zero, MaxStrategyFaultRecovery: &zero,
Filters: []config.StreamGateFilterPolicyConf{{Filter: config.StreamGateFilterRepeatGuard}},
},
expectErr: true,
},
{
name: "blocking selector with zero budget",
gate: config.StreamEvidenceGateConf{
Enabled: true,
MaxRequestFaultRecovery: &zero, MaxStrategyFaultRecovery: &zero,
Filters: []config.StreamGateFilterPolicyConf{{
Filter: config.StreamGateFilterRepeatGuard, Enabled: boolPtr(false),
Selectors: []config.StreamGateFilterSelectorConf{{
Type: config.StreamGateFilterSelectorModelGroup, Key: "ornith:35b", Enabled: boolPtr(true),
}},
}},
},
expectErr: true,
},
{
name: "blocking selector with one recovery",
gate: config.StreamEvidenceGateConf{
Enabled: true,
MaxRequestFaultRecovery: &one, MaxStrategyFaultRecovery: &one,
Filters: []config.StreamGateFilterPolicyConf{{
Filter: config.StreamGateFilterRepeatGuard, Enabled: boolPtr(false),
Selectors: []config.StreamGateFilterSelectorConf{{
Type: config.StreamGateFilterSelectorModelGroup, Key: "ornith:35b", Enabled: boolPtr(true),
}},
}},
},
},
{
name: "observe only with zero budget",
gate: config.StreamEvidenceGateConf{
Enabled: true,
MaxRequestFaultRecovery: &zero, MaxStrategyFaultRecovery: &zero,
Filters: []config.StreamGateFilterPolicyConf{{
Filter: config.StreamGateFilterRepeatGuard, Enforcement: config.StreamGateFilterEnforcementObserveOnly,
}},
},
},
{
name: "blocking selector enforcement with zero budget",
gate: config.StreamEvidenceGateConf{
Enabled: true,
MaxRequestFaultRecovery: &zero, MaxStrategyFaultRecovery: &zero,
Filters: []config.StreamGateFilterPolicyConf{{
Filter: config.StreamGateFilterRepeatGuard, Enforcement: config.StreamGateFilterEnforcementObserveOnly,
Selectors: []config.StreamGateFilterSelectorConf{{
Type: config.StreamGateFilterSelectorModelGroup, Key: "ornith:35b", Enforcement: config.StreamGateFilterEnforcementBlocking,
}},
}},
},
expectErr: true,
},
{
name: "disabled with zero budget",
gate: config.StreamEvidenceGateConf{
Enabled: true,
MaxRequestFaultRecovery: &zero, MaxStrategyFaultRecovery: &zero,
Filters: []config.StreamGateFilterPolicyConf{{
Filter: config.StreamGateFilterRepeatGuard, Enabled: boolPtr(false),
}},
},
},
{
name: "disabled gate with blocking filter and zero budget",
gate: config.StreamEvidenceGateConf{
Enabled: false,
MaxRequestFaultRecovery: &zero, MaxStrategyFaultRecovery: &zero,
Filters: []config.StreamGateFilterPolicyConf{{Filter: config.StreamGateFilterRepeatGuard}},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.gate.Validate()
if tt.expectErr && err == nil {
t.Fatal("Validate() succeeded, want recovery-budget error")
}
if !tt.expectErr && err != nil {
t.Fatalf("Validate() failed: %v", err)
}
})
}
}
func TestStreamEvidenceGate_IngressSnapshotBytes_TableFixture(t *testing.T) {
maxAllowed := 16 * 1024 * 1024 // 16777216