From 84b76fcafe97c909583f9e0b2262785e4030b822 Mon Sep 17 00:00:00 2001 From: toki Date: Fri, 14 Aug 2026 01:31:55 +0900 Subject: [PATCH] =?UTF-8?q?fix(edge):=20=EB=8C=80=ED=99=94=20=EC=A4=91=20s?= =?UTF-8?q?ystem=20=EB=A9=94=EC=8B=9C=EC=A7=80=EB=A5=BC=20=EC=A0=95?= =?UTF-8?q?=EA=B7=9C=ED=99=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../outer/anthropic-compatible-api.md | 2 +- agent-spec/input/openai-compatible-surface.md | 3 +- .../iop-benchmark-route-minimal-html-smoke.md | 6 +- apps/edge/internal/openai/anthropic_bridge.go | 13 ++++ .../internal/openai/anthropic_bridge_test.go | 69 +++++++++++++++++++ apps/edge/internal/openai/anthropic_types.go | 39 ++++++++++- .../internal/openai/provider_normalization.go | 13 ++++ apps/edge/internal/openai/request_lineage.go | 41 +++++++++-- 8 files changed, 175 insertions(+), 11 deletions(-) diff --git a/agent-contract/outer/anthropic-compatible-api.md b/agent-contract/outer/anthropic-compatible-api.md index d2f70f6d..2fcc48e8 100644 --- a/agent-contract/outer/anthropic-compatible-api.md +++ b/agent-contract/outer/anthropic-compatible-api.md @@ -382,7 +382,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`를 반환한다. diff --git a/agent-spec/input/openai-compatible-surface.md b/agent-spec/input/openai-compatible-surface.md index 11b0318e..8adfa98b 100644 --- a/agent-spec/input/openai-compatible-surface.md +++ b/agent-spec/input/openai-compatible-surface.md @@ -211,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 ` or `X-Api-Key: `. 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. | @@ -380,6 +380,7 @@ 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-13: Gemini-native path parsing now keeps route IDs strict while accepting the bounded URL-encoded official caller model label `Gemini 3.6 Flash`. diff --git a/agent-test/dev/iop-benchmark-route-minimal-html-smoke.md b/agent-test/dev/iop-benchmark-route-minimal-html-smoke.md index 52592e55..670a478d 100644 --- a/agent-test/dev/iop-benchmark-route-minimal-html-smoke.md +++ b/agent-test/dev/iop-benchmark-route-minimal-html-smoke.md @@ -31,15 +31,17 @@ | 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 파일 없음 | 새 배포에서 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 | 미실행 | selector 수정 배포 뒤 9초, provider HTTP 200 이후 `malformed`; 실제 Chat 응답의 표준 bookkeeping field를 private strict codec이 거부 | 공통 Chat 응답 정규화 누락 확인·국소 수정 및 전체 Edge 회귀 통과; 재배포 대기 | +| 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로 유지했다. + ## 재개 조건 -원격에 남은 미완료 release head를 운영 절차로 먼저 정리한 뒤 병합된 `dev`를 새 release로 배포한다. 그 뒤 parser 수정에 연결된 agy direct와 dispatch 검증 수정에 연결된 두 Claude Code preset만 1회 재검증하고, 선행 결함이 해소된 agy preset을 1회 수행한다. Claude Code GPT direct는 이미 수정 배포 뒤 통과했으므로 반복하지 않는다. +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회 재검증한다. diff --git a/apps/edge/internal/openai/anthropic_bridge.go b/apps/edge/internal/openai/anthropic_bridge.go index cbdf5498..6b46d2d5 100644 --- a/apps/edge/internal/openai/anthropic_bridge.go +++ b/apps/edge/internal/openai/anthropic_bridge.go @@ -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() { diff --git a/apps/edge/internal/openai/anthropic_bridge_test.go b/apps/edge/internal/openai/anthropic_bridge_test.go index da417588..6fb58b41 100644 --- a/apps/edge/internal/openai/anthropic_bridge_test.go +++ b/apps/edge/internal/openai/anthropic_bridge_test.go @@ -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" diff --git a/apps/edge/internal/openai/anthropic_types.go b/apps/edge/internal/openai/anthropic_types.go index b2439270..e5b4d15d 100644 --- a/apps/edge/internal/openai/anthropic_types.go +++ b/apps/edge/internal/openai/anthropic_types.go @@ -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. diff --git a/apps/edge/internal/openai/provider_normalization.go b/apps/edge/internal/openai/provider_normalization.go index a86284a4..129e048f 100644 --- a/apps/edge/internal/openai/provider_normalization.go +++ b/apps/edge/internal/openai/provider_normalization.go @@ -693,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)) diff --git a/apps/edge/internal/openai/request_lineage.go b/apps/edge/internal/openai/request_lineage.go index faf9cf4e..2b41eae3 100644 --- a/apps/edge/internal/openai/request_lineage.go +++ b/apps/edge/internal/openai/request_lineage.go @@ -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)