fix(edge): Gemini caller 모델 경로를 분리한다

엄격한 route 식별자 규칙을 유지하면서 공식 caller의 URL-encoded 표시 모델 이름은 별도 bounded 규칙으로 수용해 Gemini ingress의 잘못된 400을 제거한다.
This commit is contained in:
toki 2026-08-13 23:28:51 +09:00
parent c4375870ad
commit 3d53c28ae1
6 changed files with 49 additions and 15 deletions

View file

@ -27,7 +27,7 @@ x-goog-api-key: <IOP principal token>
```
- `{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를 만들지 않는다.

View file

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

View file

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

View file

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

View file

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

View file

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