fix(openai): Claude hybrid selector를 정규화한다
This commit is contained in:
parent
c257488d18
commit
545ecb3727
4 changed files with 145 additions and 8 deletions
|
|
@ -188,14 +188,17 @@ Worker 바인딩을 `ornith-fast`로 복원하고 중복 Plan/Review 의미 검
|
|||
|
||||
기존 4개 대분류의 동점이 너무 자주 발생해, 요구·계약, 시맨틱·구현, 반응형·레이아웃, 사용성·접근성, 시각 완성도의 5개 영역을 각각 4개 5점 항목으로 나눈다. 각 항목은 0~5 정수를 사용하며 품질 점수에 시간·usage·route는 섞지 않는다.
|
||||
|
||||
| 영역 | 5점 항목 4개 | OpenCode→Gemini hybrid | OpenCode→GPT hybrid | Claude Code→Claude Sonnet 5 direct | Claude Code→Gemini 3.6 Flash high direct | Claude Code→GPT 5.6 Luna xhigh direct |
|
||||
|---|---|---:|---:|---:|---:|---:|
|
||||
| 요구·계약 /20 | 요구 완전성, 금지 요소, terminal 계약, 콘텐츠 정확성 | 19 | 19 | 19 | 19 | 19 |
|
||||
| 시맨틱·구현 /20 | landmark, heading, component 의미, 유지보수성 | 17 | 18 | 19 | 17 | 19 |
|
||||
| 반응형·레이아웃 /20 | desktop, mobile, overflow, 적응형 기법 | 19 | 18 | 20 | 19 | 20 |
|
||||
| 사용성·접근성 /20 | CTA, navigation, non-color status, keyboard focus | 17 | 17 | 18 | 17 | 20 |
|
||||
| 시각 완성도 /20 | 정보 위계, palette, component 결속, 독자성 | 19 | 18 | 19 | 20 | 20 |
|
||||
| **총점 /100** | | **91** | **90** | **95** | **92** | **98** |
|
||||
| 경로 | 요구·계약 /20 | 시맨틱·구현 /20 | 반응형·레이아웃 /20 | 사용성·접근성 /20 | 시각 완성도 /20 | 총점 /100 |
|
||||
|---|---:|---:|---:|---:|---:|---:|
|
||||
| OpenCode→Gemini hybrid | 19 | 17 | 19 | 17 | 19 | **91** |
|
||||
| OpenCode→GPT hybrid | 19 | 18 | 18 | 17 | 18 | **90** |
|
||||
| Claude Code→Claude Sonnet 5 direct | 19 | 19 | 20 | 18 | 19 | **95** |
|
||||
| Claude Code→Gemini 3.6 Flash high direct | 19 | 17 | 19 | 17 | 20 | **92** |
|
||||
| Claude Code→GPT 5.6 Luna xhigh direct | 19 | 19 | 20 | 20 | 20 | **98** |
|
||||
| Claude Code→Gemini hybrid | — | — | — | — | — | **채점 불가** |
|
||||
| Claude Code→GPT hybrid | — | — | — | — | — | **채점 불가** |
|
||||
|
||||
세부 항목은 영역별로 요구 완전성·금지 요소·terminal 계약·콘텐츠 정확성, landmark·heading·component 의미·유지보수성, desktop·mobile·overflow·적응형 기법, CTA·navigation·non-color status·keyboard focus, 정보 위계·palette·component 결속·독자성의 각 4개다.
|
||||
|
||||
### 시간 비교
|
||||
|
||||
|
|
@ -206,9 +209,24 @@ Worker 바인딩을 `ornith-fast`로 복원하고 중복 Plan/Review 의미 검
|
|||
| Claude Code→Claude Sonnet 5 direct | 04:00:26.922993Z | terminal artifact 04:01:47.445726Z | 정확 후처리 미수집 | **79.498 / 79.363초** | artifact clock 차이 80.52초는 보조 근거 |
|
||||
| Claude Code→Gemini 3.6 Flash high direct | 04:06:43.386744Z | 04:08:43.271996Z | **119.784초** | 118.488 / 118.308초 | exact 외부 monotonic clock |
|
||||
| Claude Code→GPT 5.6 Luna xhigh direct | 04:14:33.265205Z | 04:15:27.630717Z | **54.266초** | 53.271 / 53.141초 | exact 외부 monotonic clock |
|
||||
| Claude Code→Gemini hybrid | 04:21:43.260956Z | 04:21:44.651522Z | **1.278초** | 0.070 / 0초 | Plan 전 admission 실패 시간; 성능 비교 제외 |
|
||||
| Claude Code→GPT hybrid | 04:22:36.177682Z | 04:22:37.323175Z | **1.111초** | 0.078 / 0초 | Plan 전 admission 실패 시간; 성능 비교 제외 |
|
||||
|
||||
시간은 모든 경로에 대해 보존하되, exact 외부 elapsed가 없는 표본을 대체 지표로 추정해 순위화하지 않는다. caller/API duration과 event/artifact clock은 외부 elapsed와 구분한다.
|
||||
|
||||
이 표는 경로별 단일 사이클의 생존·산출물·실행 시간을 확인하는 얇은 벤치다. 반복 표본과 분포가 없으므로 모델 latency의 신뢰구간이나 일반적 성능 순위로 확대해석하지 않는다. exact 시간이 빠진 표본은 산출물 품질 비교에는 남아도 속도 비교에서는 제외한다.
|
||||
|
||||
### Claude Code hybrid 사이클 결과
|
||||
|
||||
| 경로 | 결과 | 진입 stage | 직접 오류 | 산출물 |
|
||||
|---|---|---|---|---|
|
||||
| Claude Code→Gemini hybrid | 실패 | Plan 전 admission | `alternative "opencode-bash" operation "prepare": tool "bash" is not present in the request tools` | 없음 |
|
||||
| Claude Code→GPT hybrid | 실패 | Plan 전 admission | 동일 | 없음 |
|
||||
|
||||
두 경로는 provider/model 호출 전에 같은 preset workspace admission으로 거절됐다. 현재 caller-workspace hybrid preset에 `opencode-bash` alternative만 있고 Claude Code 요청 tool set에는 해당 `bash` tool이 없다. 따라서 이 두 결과는 Gemini/GPT 모델 품질 문제가 아니라 Claude Code edge 지원이 빠진 공통 preset 구성 결함이다.
|
||||
|
||||
이후 두 preset에 Claude Code의 exact `Bash` schema를 사용하는 `claude-code-bash` alternative를 추가하고 config check, refresh dry-run/apply를 수행했다. 변경은 restart 없이 네 Node에 적용됐으며 기존 `opencode-bash` alternative는 유지됐다. 적용 후 두 경로 모두 기존 workspace admission 오류는 재현되지 않았다. 다음 단일 진입에서 Gemini hybrid는 selector가 PLAN/REVIEW pair call을 내지 않아 light-only preset의 `mode_disabled`로 종료됐고, GPT hybrid는 selector dispatch 이전 502 `provider dispatch failed`로 종료됐다. 따라서 Claude caller binding 누락은 해소됐지만 전체 hybrid cycle은 아직 통과하지 않았다.
|
||||
|
||||
Claude Code→Claude Sonnet 5 direct는 단독·무경합으로 정확히 한 번 실행했다. caller가 보고한 duration은 79.498초, API duration은 79.363초, 3 turns였고 usage는 input 5, cache create 14,780, cache read 10,388, output 11,281이었다. 외부 래퍼의 receive 시각 후처리가 남지 않아 exact 외부 elapsed는 확정하지 않았다. terminal artifact mtime은 `04:01:47.445726Z`이고 요청 전송 기록 `04:00:26.922993Z`와의 차이는 80.52초지만 보조 근거로만 둔다.
|
||||
|
||||
산출물은 필수 HTML, meta, landmark, CTA, feature 3개, 문자 상태 label 3개와 720px breakpoint를 모두 충족했다. workspace SHA-256은 `521aa519…`이며 terminal fence는 마지막 LF 1바이트만 짧었다. `aria-label`, decorative icon의 `aria-hidden`, nav/footer list, `clamp()` 타이포그래피와 desktop/mobile component reflow는 다섯 산출물 중에서도 높은 수준이었다. 다만 명시적 `focus-visible`이 없고 시각 언어는 비교적 전형적인 dark SaaS dashboard여서 만점은 아니다. desktop/mobile SHA-256은 `819bf442…`/`b4e7bc6a…`다.
|
||||
|
|
|
|||
|
|
@ -664,6 +664,20 @@ func (s *Server) buildAnthropicPoolRequest(
|
|||
return tunnelReq, newAnthropicClientError("not_supported_error", fmt.Errorf("selected provider has no concrete protocol profile"))
|
||||
}
|
||||
profile := selected.ProtocolProfile.Clone()
|
||||
if strings.TrimSpace(selectorInstruction) != "" && profile.Driver == config.ProtocolDriverOpenAIChat {
|
||||
headers, err := s.anthropicUpstreamHeaders(r, profile, false)
|
||||
if err != nil {
|
||||
return tunnelReq, newAnthropicClientError("invalid_request_error", err)
|
||||
}
|
||||
tunnelReq.Headers = headers
|
||||
requirements := providerRequestRequirements{HasTools: true, Stream: envelope.Stream}
|
||||
if effort, ok := dispatch.Preset.Selector.Options["reasoning_effort"].(string); ok {
|
||||
requirements.Effort = strings.TrimSpace(effort)
|
||||
}
|
||||
return prepareAnthropicCallerWorkspaceSelectorTunnel(
|
||||
tunnelReq, selected, body, profile, selectorInstruction, dispatch.Preset, requirements,
|
||||
)
|
||||
}
|
||||
plan, err := selectProviderOperation(profile, operation, requirements)
|
||||
if err != nil {
|
||||
return tunnelReq, newAnthropicClientError("not_supported_error", err)
|
||||
|
|
@ -716,6 +730,38 @@ func (s *Server) buildAnthropicPoolRequest(
|
|||
return poolReq, presetIngress, nil
|
||||
}
|
||||
|
||||
func prepareAnthropicCallerWorkspaceSelectorTunnel(
|
||||
tunnel edgeservice.SubmitProviderTunnelRequest,
|
||||
selected edgeservice.ProviderPoolCandidate,
|
||||
callerBody []byte,
|
||||
profile config.ConcreteProtocolProfile,
|
||||
instruction string,
|
||||
preset config.ExecutionPreset,
|
||||
requirements providerRequestRequirements,
|
||||
) (edgeservice.SubmitProviderTunnelRequest, error) {
|
||||
tunnel.Path = "/v1/chat/completions"
|
||||
tunnel.Operation = string(config.OperationChatCompletions)
|
||||
tunnel.BuildBody = func(target string) ([]byte, error) {
|
||||
bridged, _, err := prepareAnthropicChatBridge(callerBody, target, profile)
|
||||
return bridged, err
|
||||
}
|
||||
tunnel.Body = nil
|
||||
|
||||
prepared, err := prepareHotPathSelectorProviderInstruction(tunnel, instruction)
|
||||
if err != nil {
|
||||
return tunnel, err
|
||||
}
|
||||
prepared, err = prepareHotPathSelectorOutputLimit(prepared)
|
||||
if err != nil {
|
||||
return tunnel, err
|
||||
}
|
||||
prepared, err = prepareHotPathSelectorCanonicalTools(prepared, instruction, preset)
|
||||
if err != nil {
|
||||
return tunnel, err
|
||||
}
|
||||
return prepareHotPathChatProviderOperation(prepared, selected, requirements)
|
||||
}
|
||||
|
||||
func anthropicCandidatePredicate(operation config.ProtocolOperation, requirements providerRequestRequirements) edgeservice.ProviderPoolCandidatePredicate {
|
||||
return func(candidate edgeservice.ProviderPoolCandidate) bool {
|
||||
profile := candidate.ProtocolProfile
|
||||
|
|
|
|||
|
|
@ -399,6 +399,77 @@ func TestHotPathSelectorCanonicalWriteToolReplacesCallerCommandSchema(t *testing
|
|||
}
|
||||
}
|
||||
|
||||
func TestAnthropicCallerWorkspaceSelectorUsesCanonicalProviderOperation(t *testing.T) {
|
||||
preset := config.ExecutionPreset{
|
||||
Selector: config.ExecutionModelBinding{Options: map[string]any{"reasoning_effort": "high"}},
|
||||
WorkspaceTools: []config.ExecutionWorkspaceToolAlternative{{
|
||||
Name: "claude-code-bash",
|
||||
Operations: map[string]config.ExecutionWorkspaceOperation{
|
||||
"write": {ToolName: "Bash"},
|
||||
},
|
||||
}},
|
||||
}
|
||||
instruction, err := buildHotPathSelectorProviderInstruction("req_anthropic_selector", selectorInstructionPairWrite)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
callerBody := []byte(`{"model":"gpt-hybrid","max_tokens":32000,"messages":[{"role":"user","content":"build it"}],"tools":[{"name":"Bash","description":"shell","input_schema":{"type":"object","properties":{"command":{"type":"string"}},"required":["command"]}}],"stream":true}`)
|
||||
|
||||
for _, profileID := range []string{"gemini", "openai"} {
|
||||
t.Run(profileID, func(t *testing.T) {
|
||||
profile, err := config.ResolveProtocolProfile(profileID, "", config.BuiltInProtocolProfileCatalog())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
selected := edgeservice.ProviderPoolCandidate{ProtocolProfile: &profile}
|
||||
tunnel := edgeservice.SubmitProviderTunnelRequest{Stream: true}
|
||||
prepared, err := prepareAnthropicCallerWorkspaceSelectorTunnel(
|
||||
tunnel, selected, callerBody, profile, instruction, preset,
|
||||
providerRequestRequirements{HasTools: true, Stream: true, Effort: "high"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := prepared.BuildBody("served-selector")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var request map[string]any
|
||||
if err := json.Unmarshal(body, &request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tools := anySlice(request["tools"])
|
||||
if len(tools) != 1 {
|
||||
t.Fatalf("canonical tools=%+v", tools)
|
||||
}
|
||||
if profileID == "openai" {
|
||||
if prepared.Operation != string(config.OperationResponses) || prepared.Path != "/v1/responses" || prepared.Stream {
|
||||
t.Fatalf("OpenAI selector tunnel=%+v", prepared)
|
||||
}
|
||||
tool := tools[0].(map[string]any)
|
||||
if tool["name"] != hotPathArtifactPairToolName || request["tool_choice"] != "required" || request["max_output_tokens"] != float64(maxHotPathSelectorOutputTokens) {
|
||||
t.Fatalf("OpenAI selector request=%+v", request)
|
||||
}
|
||||
if !strings.Contains(request["instructions"].(string), "Operation: pair-write") {
|
||||
t.Fatalf("OpenAI selector instruction=%v", request["instructions"])
|
||||
}
|
||||
return
|
||||
}
|
||||
if prepared.Operation != string(config.OperationChatCompletions) || prepared.Path != "/v1/chat/completions" {
|
||||
t.Fatalf("Gemini selector tunnel=%+v", prepared)
|
||||
}
|
||||
function := tools[0].(map[string]any)["function"].(map[string]any)
|
||||
if function["name"] != hotPathArtifactPairToolName || request["tool_choice"] != "required" || request["max_tokens"] != float64(maxHotPathSelectorOutputTokens) {
|
||||
t.Fatalf("Gemini selector request=%+v", request)
|
||||
}
|
||||
messages := anySlice(request["messages"])
|
||||
if len(messages) < 2 || !strings.Contains(messages[0].(map[string]any)["content"].(string), "Operation: pair-write") {
|
||||
t.Fatalf("Gemini selector messages=%+v", messages)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiChatProviderHTTPToolContinuationRoundTrip(t *testing.T) {
|
||||
var providerRequests []map[string]any
|
||||
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
|
|||
|
|
@ -578,6 +578,8 @@ nodes:
|
|||
# options:
|
||||
# reasoning_effort: "high"
|
||||
# workspace_tools:
|
||||
# # Declare one complete alternative per caller tool schema. For example,
|
||||
# # OpenCode exposes `bash`, while Claude Code exposes `Bash`.
|
||||
# - name: "opencode-bash"
|
||||
# operations:
|
||||
# prepare:
|
||||
|
|
|
|||
Loading…
Reference in a new issue