Update roadmap and openai server implementation
This commit is contained in:
parent
dccfbff9ff
commit
9b64a26f18
4 changed files with 122 additions and 6 deletions
|
|
@ -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 경계를 검증/정규화한다.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 이동을 다시 판단한다.
|
||||
|
||||
## 범위 제외
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue