Update roadmap and openai server implementation

This commit is contained in:
toki 2026-07-05 05:19:02 +09:00
parent dccfbff9ff
commit 9b64a26f18
4 changed files with 122 additions and 6 deletions

View file

@ -23,7 +23,7 @@ Ollama serving 경로와 운영 기반이 안정화된 뒤, 단계 호출, tool/
- 경로: `agent-roadmap/archive/phase/knowledge-tool-optimization-extension/milestones/openai-compatible-think-control.md`
- 요약: OpenAI-compatible Chat Completions 요청에서 thinking/reasoning 생성과 응답 노출을 요청별로 제어하고 provider별 option 매핑과 unsupported 정책을 구현한다.
- [검토중] OpenAI-compatible Tool Call Boundary Hardening
- [진행중] OpenAI-compatible Tool Call Boundary Hardening
- 경로: `agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/openai-compatible-tool-call-boundary-hardening.md`
- 요약: provider-pool/OpenAI-compatible 응답에서 raw text tool-call block, unknown tool name, chat-template sentinel token이 클라이언트 화면으로 새지 않도록 Edge tool-call 경계를 검증/정규화한다.

View file

@ -12,7 +12,7 @@ OpenAI-compatible `tools[]`가 있는 요청에서 provider-pool 또는 provider
## 상태
[검토중]
[진행중]
## 승격 조건
@ -51,15 +51,15 @@ OpenAI-compatible provider 응답의 tool-call 후보를 요청 schema 기준으
## 완료 리뷰
- 상태: 검토중
- 상태: 보완 필요
- 요청일: 2026-07-04
- 완료 근거: `01_provider_text_boundary/complete.log``02+01_contract_dev_smoke/complete.log`가 모든 기능 Task id의 PASS, Go 검증, dev-runtime smoke evidence를 기록한다. 현 파일/git sanity 확인에서도 `apps/edge/internal/openai`, `agent-contract/outer/openai-compatible-api.md`, `docs/edge-local-dev-guide.md`, `agent-test/dev/edge-smoke.md`가 해당 정책과 테스트를 유지한다.
- 완료 근거: `01_provider_text_boundary/complete.log``02+01_contract_dev_smoke/complete.log`가 모든 기능 Task id의 PASS, Go 검증, dev-runtime smoke evidence를 기록한다. 다만 종료 전 코드레벨 재검토에서 native stream `tool_calls`와 raw text `<tool_call>`이 같이 올 때 보류 raw candidate가 flush될 수 있는 작은 누수 가능성을 local guard/test로 보강했으며, 이 guard가 반영된 source 기준 dev-runtime credentialed smoke는 아직 재수집되지 않았다.
- 검토 항목:
- [x] valid raw text tool call은 구조화되고 raw block이 노출되지 않는다
- [x] unknown/malformed tool call은 성공 content로 노출되지 않는다
- [x] Pi/Cline형 OpenAI-compatible tools smoke 근거가 남아 있다
- agent-ui 상태 반영: 해당 없음
- 리뷰 코멘트: 기능 Task와 구현 잠금은 완료 후보 조건을 충족했으며, `[완료]` 전환과 archive 이동은 별도 완료 승인 후 처리한다.
- 리뷰 코멘트: `agent-task/m-openai-compatible-tool-call-boundary-hardening/03+02_closure_verification/PLAN-cloud-G07.md`에서 guarded source sync, dev-runtime rebuild/refresh, Pi/Cline형 non-stream/stream smoke evidence 재수집을 완료한 뒤 `[완료]` 전환과 archive 이동을 다시 판단한다.
## 범위 제외

View file

@ -1700,6 +1700,107 @@ func TestChatCompletionsStreamPassesThroughNativeToolCallsFromProviderRoute(t *t
}
}
func TestChatCompletionsStreamDropsRawTextWhenNativeToolCallsArrive(t *testing.T) {
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
fake.events <- &iop.RunEvent{Type: "delta", Delta: `<tool_call><function=run_commands><parameter=commands>["git status"]</parameter></function></tool_call>`}
fake.events <- &iop.RunEvent{
Type: "complete",
Metadata: map[string]string{
"finish_reason": "tool_calls",
runtimeMetadataOpenAIToolCalls: `[{"id":"call_1","type":"function","function":{"name":"run_commands","arguments":"{\"commands\":[\"git status\"]}"}}]`,
},
}
srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil)
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
"model":"stream-model",
"stream":true,
"messages":[{"role":"user","content":"status"}],
"tools":[{"type":"function","function":{"name":"run_commands"}}],
"tool_choice":"auto"
}`))
w := httptest.NewRecorder()
srv.handleChatCompletions(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
body := w.Body.String()
if !strings.Contains(body, `"tool_calls"`) || !strings.Contains(body, `"call_1"`) {
t.Fatalf("stream did not pass through native tool_calls:\n%s", body)
}
if strings.Contains(body, "<tool_call") || strings.Contains(body, `\u003ctool_call`) {
t.Fatalf("stream leaked raw text tool call:\n%s", body)
}
}
func TestChatCompletionsStreamDropsPartialTextToolCallSuffixWhenNativeToolCallsArrive(t *testing.T) {
cases := []struct {
name string
delta string
wantContent string
leakMarkers []string
}{
{
name: "xml partial candidate suffix",
delta: "checking status <tool_ca",
wantContent: `"content":"checking status"`,
leakMarkers: []string{`\u003ctool_ca`},
},
{
name: "mustache partial candidate suffix",
delta: "checking status {",
wantContent: `"content":"checking status"`,
leakMarkers: []string{`"content":"{"`},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
fake.events <- &iop.RunEvent{Type: "delta", Delta: tc.delta}
fake.events <- &iop.RunEvent{
Type: "complete",
Metadata: map[string]string{
"finish_reason": "tool_calls",
runtimeMetadataOpenAIToolCalls: `[{"id":"call_1","type":"function","function":{"name":"run_commands","arguments":"{\"commands\":[\"git status\"]}"}}]`,
},
}
srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil)
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
"model":"stream-model",
"stream":true,
"messages":[{"role":"user","content":"status"}],
"tools":[{"type":"function","function":{"name":"run_commands"}}],
"tool_choice":"auto"
}`))
w := httptest.NewRecorder()
srv.handleChatCompletions(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
body := w.Body.String()
if !strings.Contains(body, `"tool_calls"`) || !strings.Contains(body, `"call_1"`) {
t.Fatalf("stream did not pass through native tool_calls:\n%s", body)
}
if !strings.Contains(body, `"finish_reason":"tool_calls"`) {
t.Fatalf("stream finish_reason:\n%s", body)
}
if !strings.Contains(body, tc.wantContent) {
t.Fatalf("stream dropped safe prefix content %s:\n%s", tc.wantContent, body)
}
for _, marker := range tc.leakMarkers {
if strings.Contains(body, marker) {
t.Fatalf("stream leaked partial text tool call marker %q:\n%s", marker, body)
}
}
})
}
}
func TestChatCompletionsStreamingReportsClosedRunStream(t *testing.T) {
fake := &fakeRunService{events: make(chan *iop.RunEvent)}
close(fake.events)

View file

@ -201,7 +201,7 @@ func (s *Server) streamChatCompletion(w http.ResponseWriter, r *http.Request, re
nativeToolCalls := toolCallsFromRunMetadata(metadata)
if len(nativeToolCalls) > 0 {
if toolTextFilter != nil {
pending := toolTextFilter.Flush()
pending := toolTextFilter.FlushNonCandidate()
if pending != "" {
emittedContent.WriteString(pending)
writeTracedContentDelta(pending, pending, false)
@ -330,6 +330,21 @@ func (f *streamToolTextFilter) Flush() string {
return out
}
func (f *streamToolTextFilter) FlushNonCandidate() string {
if firstStreamToolTextCandidateIndex(f.pending) >= 0 {
f.pending = ""
return ""
}
// Even without a full candidate, chunk-boundary protection can leave a
// partial candidate suffix (e.g. "<tool_ca", trailing "{") in pending.
// Return only the safe non-candidate prefix and drop the partial suffix so
// a native tool_calls completion never flushes a raw candidate fragment.
flushLen := streamToolTextSafeFlushLen(f.pending)
out := strings.TrimRightFunc(f.pending[:flushLen], unicode.IsSpace)
f.pending = ""
return out
}
func firstStreamToolTextCandidateIndex(s string) int {
xml := strings.Index(strings.ToLower(s), "<tool_call")
mustache := strings.Index(s, textMustacheToolCallOpenHint)