fix(edge): 대화 중 system 메시지를 정규화한다

This commit is contained in:
toki 2026-08-14 01:31:55 +09:00
parent f1b7c14610
commit 84b76fcafe
8 changed files with 175 additions and 11 deletions

View file

@ -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`를 반환한다.

View file

@ -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 <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. |
@ -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`.

View file

@ -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회 재검증한다.

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

@ -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))

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)