From 3d53c28ae18117d36b845adf81e4f6ecc6db2186 Mon Sep 17 00:00:00 2001 From: toki Date: Thu, 13 Aug 2026 23:28:51 +0900 Subject: [PATCH] =?UTF-8?q?fix(edge):=20Gemini=20caller=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=20=EA=B2=BD=EB=A1=9C=EB=A5=BC=20=EB=B6=84=EB=A6=AC?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 엄격한 route 식별자 규칙을 유지하면서 공식 caller의 URL-encoded 표시 모델 이름은 별도 bounded 규칙으로 수용해 Gemini ingress의 잘못된 400을 제거한다. --- agent-contract/outer/gemini-compatible-api.md | 2 +- .../benchmark-route-minimal-html-smoke.md | 1 + agent-spec/input/openai-compatible-surface.md | 1 + .../iop-benchmark-route-minimal-html-smoke.md | 32 +++++++++++-------- apps/edge/internal/openai/gemini_handler.go | 3 +- .../internal/openai/gemini_handler_test.go | 25 +++++++++++++++ 6 files changed, 49 insertions(+), 15 deletions(-) diff --git a/agent-contract/outer/gemini-compatible-api.md b/agent-contract/outer/gemini-compatible-api.md index b17a367c..d79f733a 100644 --- a/agent-contract/outer/gemini-compatible-api.md +++ b/agent-contract/outer/gemini-compatible-api.md @@ -27,7 +27,7 @@ x-goog-api-key: ``` - `{route-id}`는 Edge가 인증된 principal에 대해 해석할 direct route 또는 virtual execution-preset id다. URL path segment 하나의 canonical token이어야 한다. -- `{caller-model}`은 caller가 선택한 Gemini 모델 id이며 관측·호환성 검증 대상이다. provider/credential 또는 execution preset 선택 권한은 갖지 않는다. +- `{caller-model}`은 caller가 선택한 Gemini 모델 id 또는 공식 CLI의 bounded 표시 label이며 관측·호환성 검증 대상이다. URL-encoded space를 포함한 `Gemini 3.6 Flash` label을 허용하지만 앞뒤 공백, slash, control character는 거부한다. provider/credential 또는 execution preset 선택 권한은 갖지 않는다. - 현재 `agy` 호환 표면은 `alt=sse`인 `streamGenerateContent`만 지원한다. `generateContent`, batch, files, cached content, tuning API는 비범위다. - direct route와 marked single-request preset은 모두 기존 Edge route resolution, managed admission, provider-pool, preset coordinator를 사용한다. 별도 Gemini 전용 우회 dispatch를 만들지 않는다. diff --git a/agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/benchmark-route-minimal-html-smoke.md b/agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/benchmark-route-minimal-html-smoke.md index 0594e84d..16d7431f 100644 --- a/agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/benchmark-route-minimal-html-smoke.md +++ b/agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/benchmark-route-minimal-html-smoke.md @@ -75,4 +75,5 @@ IOP 전체 안정성을 처음부터 재검증하지 않고, 실패가 재현된 - evidence 위치: `agent-test/dev/iop-benchmark-route-minimal-html-smoke.md` - 현재 사전 확인: 2026-08-13에 Claude Code 2.1.228, agy 1.1.12, Codex 0.147.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는 같은 principal의 직접 `/v1/responses`가 200인 반면 caller가 `turn.failed`로 끝나 caller TLS/transport 호환 경계로 분류했다. Claude Code → Gemini preset은 caller terminal success에도 184초와 caller workspace 산출물 부재로 preset workspace 반환 경계 실패로 분류했다. - 후속 측정: [초경량 Agent 모델 비교](thin-agent-model-comparison-benchmark.md) diff --git a/agent-spec/input/openai-compatible-surface.md b/agent-spec/input/openai-compatible-surface.md index 540e9d7b..d7e71d8e 100644 --- a/agent-spec/input/openai-compatible-surface.md +++ b/agent-spec/input/openai-compatible-surface.md @@ -365,6 +365,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-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. - 2026-08-06: Added the marked streaming subset with fixed plan/work/review/repair progress, liveness ping, serialized monotonic text blocks, private-wire exclusion, one success/error terminal, joined ticker shutdown, and post-`message_stop` completion acknowledgement. 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 3983b747..9e155716 100644 --- a/agent-test/dev/iop-benchmark-route-minimal-html-smoke.md +++ b/agent-test/dev/iop-benchmark-route-minimal-html-smoke.md @@ -15,23 +15,29 @@ | Claude Code | 2.1.228 확인 | | agy | 1.1.12 확인 | | Codex | 0.147.0 확인 | -| managed CA | 파일 존재 확인 | -| 기존 benchmark principal token | 파일 존재, mode `0600`, 65 bytes 확인 | -| public `/v1/models` | HTTP 401 | -| active public Edge token hash 매핑 | 0건 | -| direct Edge `:18086` | 현재 host에서 연결 불가 | -| model 호출 | 시작하지 않음 | +| managed CA | live Edge certificate 확인 | +| principal token | 원격 SOPS의 기존 두 token 사용; 별도 benchmark token 미사용 | +| public `/v1/models` | 두 기존 token 모두 HTTP 200, route 9개 확인 | +| ambient CA override | 호출 전후 unset 확인 | -## 현재 분류 +## 경로 결과 -- 소유 경계: 환경/credential -- 근거: caller를 시작하기 전 공통 인증 preflight에서 HTTP 401이 발생했고, 현재 public Edge 활성 config의 `principal_tokens`에 test token hash가 없다. -- IOP 제품 결함 판정: 아직 아님 -- caller/model 결함 판정: 아직 아님 -- benchmark script 결함 판정: 해당 없음. script를 사용하지 않았다. +| 경로 | 최초 1회 결과 | 재검증 | 분류 | +|---|---|---|---| +| 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으로 보낸 결함 | +| Codex → GPT direct | 30초, `turn.failed`, 파일 없음 | 없음 | 직접 `/v1/responses`는 200; Codex custom-provider/TLS transport 경계 | +| Claude Code → Gemini execution preset | 184초, caller terminal success, caller workspace 파일 없음 | 없음 | 120초 초과와 preset workspace 산출물 반환 경계 | +| agy → Gemini execution preset | 미실행 | direct parser 수정 배포 대기 | 선행 결함 | +| Claude Code → GPT execution preset | 미실행 | normalization 배포 대기 | 선행 결함 | +| Codex → GPT execution preset | 미실행 | Codex transport 분리 대기 | 선행 결함 | + +추가 API 분리에서는 동일 principal의 최소 `/v1/responses`가 HTTP 200이었다. Gemini-native 최소 요청은 canonical caller model id에서 HTTP 200, 공식 표시 label `Gemini 3.6 Flash`에서 HTTP 400으로 갈려 path parser 결함을 재현했다. ## 재개 조건 -기존 direct·execution-preset route를 볼 수 있는 유효한 dev-corp principal token을 operator-private 경로에 준비하거나 기존 test token을 active Edge에 안전하게 매핑·재시작한다. token 원문을 출력하지 않은 `/v1/models` 확인이 HTTP 200이고 `claude-sonnet-5`, `gemini-3.6-flash`, `gpt-5.6-luna`, `gemini-hybrid`, `gpt-hybrid`가 존재하면 9개 최소 호출을 각 1회 시작한다. +원격에 남은 미완료 release head를 운영 절차로 먼저 정리한 뒤 병합된 `dev`를 새 release로 배포한다. 그 뒤 변경된 원인에 연결된 agy direct와 Claude Code GPT direct만 1회 재검증하고, 선행 결함이 해소된 미실행 행을 각 1회 수행한다. 성공한 경로는 반복하지 않는다. 실패한 경로는 원인이 변경된 경우에만 해당 경로를 1회 재검증한다. diff --git a/apps/edge/internal/openai/gemini_handler.go b/apps/edge/internal/openai/gemini_handler.go index b52fbda3..412bf9c5 100644 --- a/apps/edge/internal/openai/gemini_handler.go +++ b/apps/edge/internal/openai/gemini_handler.go @@ -13,6 +13,7 @@ import ( ) var geminiPathToken = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) +var geminiCallerModelLabel = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9 ._-]{0,127}$`) var geminiToolCallID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`) const geminiRejectionLogMessage = "edge_gemini_rejection" @@ -104,7 +105,7 @@ func parseGeminiStreamPath(r *http.Request) (string, string, error) { } routeID := parts[0] callerModel := strings.TrimSuffix(parts[3], suffix) - if !geminiPathToken.MatchString(routeID) || !geminiPathToken.MatchString(callerModel) { + if !geminiPathToken.MatchString(routeID) || !geminiCallerModelLabel.MatchString(callerModel) || strings.TrimSpace(callerModel) != callerModel { return "", "", fmt.Errorf("invalid path token") } query := r.URL.Query() diff --git a/apps/edge/internal/openai/gemini_handler_test.go b/apps/edge/internal/openai/gemini_handler_test.go index 66e0817c..155ee14d 100644 --- a/apps/edge/internal/openai/gemini_handler_test.go +++ b/apps/edge/internal/openai/gemini_handler_test.go @@ -59,6 +59,31 @@ func TestGeminiIngressAuthenticatesAndStreamsThroughChatRoute(t *testing.T) { } } +func TestGeminiStreamPathAcceptsOfficialCallerModelLabel(t *testing.T) { + req := httptest.NewRequest( + http.MethodPost, + "/gemini/gemini-direct/v1beta/models/Gemini%203.6%20Flash:streamGenerateContent?alt=sse", + nil, + ) + routeID, callerModel, err := parseGeminiStreamPath(req) + if err != nil { + t.Fatalf("parseGeminiStreamPath: %v", err) + } + if routeID != "gemini-direct" || callerModel != "Gemini 3.6 Flash" { + t.Fatalf("path = %q/%q", routeID, callerModel) + } + + for _, path := range []string{ + "/gemini/gemini-direct/v1beta/models/%20Gemini:streamGenerateContent?alt=sse", + "/gemini/gemini-direct/v1beta/models/Gemini%20:streamGenerateContent?alt=sse", + } { + bad := httptest.NewRequest(http.MethodPost, path, nil) + if _, _, err := parseGeminiStreamPath(bad); err == nil { + t.Fatalf("expected invalid caller model path: %s", path) + } + } +} + func TestGeminiIngressRejectsAuthenticationAndShapeBeforeDispatch(t *testing.T) { base := `{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}` for _, tc := range []struct {