feat(edge): tool validation contract and chat completion retry
This commit is contained in:
parent
c48f531cba
commit
c3b40cbe1c
11 changed files with 1675 additions and 147 deletions
|
|
@ -15,10 +15,18 @@ Ollama serving 경로와 운영 기반이 안정화된 뒤, 단계 호출, tool/
|
|||
완료된 Milestone은 archive 경로를 가리키고, 검토중, 진행중, 계획, 스케치 또는 보류 Milestone은 이 Phase 하위 `milestones/` 경로를 가리킨다.
|
||||
완료, 검토중, 진행중, 계획, 스케치 순서로 두어 아래로 갈수록 미래 작업에 가까워지게 정렬한다.
|
||||
|
||||
- [계획] Tool Call Runtime 검증 재시도 MVP
|
||||
- 경로: `agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/tool-call-runtime-validation-retry.md`
|
||||
- 요약: 명시적 `tools[]`/`tool_choice` 계약이 있는 OpenAI-compatible 요청에서 IOP 내부 provider 응답의 마지막 tool call을 검증하고, schema 불일치 시 같은 요청을 bounded exact replay로 재시도하는 runtime-only MVP를 구현한다.
|
||||
|
||||
- [스케치] 단계 호출과 검증 최적화 MVP
|
||||
- 경로: `agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/knowledge-tool-validation-optimization.md`
|
||||
- 요약: 요청 의도 분석, 실제 작업, 검증/schema 강제, 오류 시 회귀를 단계 호출 실행 모드의 MVP 후보로 스케치한다.
|
||||
|
||||
- [스케치] Tool Call 판정 모델 Gate 리뷰
|
||||
- 경로: `agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/tool-call-validator-model-gate-review.md`
|
||||
- 요약: 명시적 tool schema만으로 판정할 수 없는 자연어/텍스트/agent-specific tool call 후보를 별도 validator 모델로 분류할지, 어떤 조건에서 허용할지 사용자 리뷰가 필요한 결정 항목으로 스케치한다.
|
||||
|
||||
- [스케치] 장기 기억과 RAG 업데이트 사이클 (2차)
|
||||
- 경로: `agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/long-term-memory-rag-second-wave.md`
|
||||
- 요약: 특정 repo 장기 기억, RAG 저장소, update cycle, MCP 기반 context 절약은 MVP 이후 2차 후보로 스케치한다.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
# Milestone: Tool Call Runtime 검증 재시도 MVP
|
||||
|
||||
## 위치
|
||||
|
||||
- Roadmap: `agent-roadmap/ROADMAP.md`
|
||||
- Phase: `agent-roadmap/phase/knowledge-tool-optimization-extension/PHASE.md`
|
||||
|
||||
## 목표
|
||||
|
||||
명시적 `tools[]`와 `tool_choice` 계약이 있는 OpenAI-compatible 요청에서 IOP가 provider 응답의 마지막 tool call을 반환 직전에 검증한다.
|
||||
tool name 또는 `function.arguments`가 요청 schema와 맞지 않으면 caller agent에 실패를 노출하기 전에 IOP 내부에서 같은 요청을 bounded exact replay로 재시도한다.
|
||||
이 Milestone은 validator 모델 없이 runtime이 결정적으로 판단할 수 있는 케이스만 다룬다.
|
||||
|
||||
## 상태
|
||||
|
||||
[계획]
|
||||
|
||||
## 승격 조건
|
||||
|
||||
- 없음
|
||||
|
||||
## 구현 잠금
|
||||
|
||||
- 상태: 해제
|
||||
- SDD: 불필요
|
||||
- SDD 문서: 없음
|
||||
- SDD 사유: 기존 OpenAI-compatible 요청/응답 계약 안에서 명시적 `tools[]` schema를 검증하고 같은 요청을 내부 재제출하는 bounded MVP로 제한하며, 새 proto/config/API field나 외부 계약 확장은 만들지 않는다.
|
||||
- 잠금 해제 조건: 없음
|
||||
- 결정 필요: 없음
|
||||
|
||||
## 범위
|
||||
|
||||
- OpenAI-compatible Chat Completions 요청의 `tools[]`, `tool_choice` 기반 runtime validation contract 판정
|
||||
- native OpenAI `tool_calls`와 IOP text tool-call fallback 결과를 공통 `ToolCallCandidate` 형태로 해석
|
||||
- 요청 `tools[].function.parameters`를 기준으로 tool name, arguments JSON, required/type/object/array/enum/additionalProperties 중심의 JSON Schema subset 검증
|
||||
- 검증 실패 시 caller에게 응답을 내보내기 전 같은 `SubmitRunRequest`를 bounded exact replay로 재제출
|
||||
- non-stream 또는 buffered stream처럼 아직 caller에게 malformed 응답을 흘리지 않은 경로 우선 적용
|
||||
- retry attempt와 validation failure reason을 run metadata 또는 로그로 구분 가능하게 남기는 최소 추적
|
||||
|
||||
## 기능
|
||||
|
||||
### Epic: [runtime-validate] Runtime Tool Call Validation
|
||||
|
||||
명시적 schema가 있는 요청에서 모델 응답의 tool call shape를 runtime이 결정적으로 검증하고 재시도하는 capability를 묶는다.
|
||||
|
||||
- [ ] [contract-detect] `tools[]`가 있는 OpenAI-compatible 요청을 runtime validation 대상으로 판정하고 `tool_choice: none` 등 검증 제외 조건을 정리한다.
|
||||
- [ ] [candidate-normalize] native `tool_calls`와 IOP text fallback 결과를 tool name, arguments, origin, raw payload를 가진 공통 후보로 정규화한다.
|
||||
- [ ] [schema-validate] 요청 tool schema를 기준으로 tool name, arguments JSON parse, required/type/object/array/enum/additionalProperties subset을 검증한다.
|
||||
- [ ] [exact-retry] validation 실패가 응답 방출 전 발견되면 같은 요청을 bounded exact replay로 재제출하고, 새 attempt run과 원 요청의 상관관계를 남긴다.
|
||||
- [ ] [failure-surface] retry 한도 초과 또는 재시도 불가 경로에서는 malformed tool call을 그대로 성공 응답으로 내보내지 않고 OpenAI-compatible 실패로 구분한다.
|
||||
- [ ] [validation-tests] runtime validation, retry 성공, retry 한도 초과, stream 제외 또는 buffer 경로를 테스트한다. 검증: `go test ./apps/edge/internal/openai`
|
||||
|
||||
## 완료 리뷰
|
||||
|
||||
- 상태: 없음
|
||||
- 요청일: 없음
|
||||
- 완료 근거: 계획 Milestone이며 기능 Task가 아직 충족되지 않았다.
|
||||
- 검토 항목: 기능 Task와 검증 통과, 구현 잠금 해제 유지, malformed tool call 비노출 확인
|
||||
- agent-ui 상태 반영: 해당 없음
|
||||
- 리뷰 코멘트: 없음
|
||||
|
||||
## 범위 제외
|
||||
|
||||
- `tools[]`가 없는 요청에서 tool 사용 필요 여부를 추론하는 validator 모델
|
||||
- agent-specific 자연어 또는 임의 텍스트를 tool call로 추정하는 휴리스틱 확장
|
||||
- caller agent까지 실패를 되돌려 repair instruction을 넣는 외부 loop
|
||||
- live SSE로 이미 content를 방출한 뒤의 투명 재시도
|
||||
- 새 public API/config/proto field와 장기 ledger schema 확정
|
||||
|
||||
## 작업 컨텍스트
|
||||
|
||||
- 관련 경로: `apps/edge/internal/openai`, `apps/edge/internal/service`, `apps/node/internal/adapters/openai_compat`, `apps/node/internal/adapters/vllm`, `agent-contract/outer/openai-compatible-api.md`
|
||||
- 표준선(선택): 명시적 `tools[]`가 있는 요청은 별도 validator 모델 없이 요청 schema를 source of truth로 삼는다.
|
||||
- 표준선(선택): 재시도는 작업 전체 재실행이 아니라 IOP 내부 provider 응답 sampling의 bounded exact replay로 제한한다.
|
||||
- 선행 작업: OpenAI-compatible tool call passthrough와 text fallback
|
||||
- 후속 작업: Tool Call 판정 모델 Gate 리뷰, 요청 실행 로그와 Usage Ledger 기반
|
||||
- 확인 필요: 없음
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
# Milestone: Tool Call 판정 모델 Gate 리뷰
|
||||
|
||||
## 위치
|
||||
|
||||
- Roadmap: `agent-roadmap/ROADMAP.md`
|
||||
- Phase: `agent-roadmap/phase/knowledge-tool-optimization-extension/PHASE.md`
|
||||
|
||||
## 목표
|
||||
|
||||
명시적 `tools[]` schema만으로 판정할 수 없는 tool-call 후보를 별도 validator 모델 또는 classifier 단계로 판정할지 검토한다.
|
||||
자연어 설명, agent-specific 텍스트, schema 부재, tool 사용 필요성 추론처럼 runtime-only 검증이 확정적으로 처리할 수 없는 케이스를 사용자 리뷰 대상 결정으로 분리한다.
|
||||
사용자가 gate 조건과 실패 처리 정책을 승인하기 전까지 구현 계획과 코드 구현을 시작하지 않는다.
|
||||
|
||||
## 상태
|
||||
|
||||
[스케치]
|
||||
|
||||
## 승격 조건
|
||||
|
||||
- [ ] validator 모델이 필요한 케이스와 runtime-only 검증으로 충분한 케이스의 경계를 확정한다.
|
||||
- [ ] 모델 판정 결과를 advisory로 둘지 blocking gate로 둘지 결정한다.
|
||||
- [ ] false positive/false negative가 발생했을 때 retry, 실패 응답, 원문 통과 중 어떤 정책을 적용할지 결정한다.
|
||||
- [ ] validator 모델 사용 비용, latency, default enable 여부, 호출 표면을 결정한다.
|
||||
- [ ] 사용자 리뷰를 통해 MVP 포함 여부와 후속 구현 Milestone 분리 방식을 확정한다.
|
||||
|
||||
## 구현 잠금
|
||||
|
||||
- 상태: 잠금
|
||||
- SDD: 불필요
|
||||
- SDD 문서: 없음
|
||||
- SDD 사유: 현재 Milestone은 모델 판정 gate의 제품/정책 스케치이며, API/config/schema 구현으로 승격할 때 SDD 필요 여부를 재판정한다.
|
||||
- 잠금 해제 조건: 아래 체크리스트
|
||||
- [ ] 아래 결정 필요 항목이 사용자 리뷰로 해소되어 있다.
|
||||
- [ ] runtime-only Milestone과 겹치지 않는 구현 범위가 정리되어 있다.
|
||||
- 결정 필요: 아래 체크리스트
|
||||
- [ ] 명시적 `tools[]`가 없는 요청에서 validator 모델을 호출할지 결정한다.
|
||||
- [ ] 모델이 tool call 필요성, tool call 후보 여부, schema 위반 여부 중 무엇을 판정할지 결정한다.
|
||||
- [ ] validator 모델 판정을 실패 처리의 source of truth로 삼을지 advisory signal로만 둘지 결정한다.
|
||||
- [ ] 오판 시 caller agent에 미칠 영향과 원문 통과/차단/retry 기준을 결정한다.
|
||||
- [ ] validator 모델 사용을 기본 활성화할지 opt-in metadata/config로 둘지 결정한다.
|
||||
|
||||
## 범위
|
||||
|
||||
- `tools[]` 또는 명시 schema가 없는 요청의 tool-call 판정 후보
|
||||
- 자연어/텍스트 기반 tool call처럼 runtime이 deterministic하게 schema validation을 시작하기 어려운 케이스 분류
|
||||
- validator 모델의 입력, 출력, 신뢰도, 실패 처리 정책 후보
|
||||
- runtime-only exact replay retry와 모델-assisted gate의 책임 경계
|
||||
- 사용자 리뷰가 필요한 제품/비용/latency/오판 위험 결정
|
||||
|
||||
## 기능
|
||||
|
||||
### Epic: [model-gate] Model-Assisted Tool Call Gate
|
||||
|
||||
runtime-only validation으로 해결되지 않는 애매한 tool-call 후보를 모델 판정 gate로 다룰지 검토하는 산출물을 묶는다.
|
||||
|
||||
- [ ] [case-map] runtime-only 검증이 처리하지 않는 케이스와 validator 모델 후보 케이스가 분리되어 있다.
|
||||
- [ ] [classifier-role] validator 모델이 판정할 역할과 출력 형태 후보가 정리되어 있다.
|
||||
- [ ] [risk-policy] false positive/false negative, retry 반복, 원문 통과/차단 정책 후보가 정리되어 있다.
|
||||
- [ ] [surface-policy] opt-in metadata, config, route policy, default off 여부 후보가 정리되어 있다.
|
||||
- [ ] [user-review] 사용자가 모델 gate 범위, 비용/latency, 오판 정책을 검토했다.
|
||||
|
||||
## 완료 리뷰
|
||||
|
||||
- 상태: 없음
|
||||
- 요청일: 없음
|
||||
- 완료 근거: 스케치 Milestone이며 기능 Task가 아직 충족되지 않았다.
|
||||
- 검토 항목: 사용자 리뷰 통과, 잠금 해제 여부, 후속 구현 Milestone 분리 여부
|
||||
- agent-ui 상태 반영: 해당 없음
|
||||
- 리뷰 코멘트: 없음
|
||||
|
||||
## 범위 제외
|
||||
|
||||
- 명시적 `tools[]` schema가 있는 runtime-only validation/retry 구현
|
||||
- validator 모델 호출 코드 구현
|
||||
- OpenAI-compatible public API/config/proto 변경
|
||||
- 임의 자연어 tool inference를 기본 동작으로 켜는 결정
|
||||
- 비용/latency 측정 없이 default-on 정책을 확정하는 일
|
||||
|
||||
## 작업 컨텍스트
|
||||
|
||||
- 관련 경로: `apps/edge/internal/openai`, `apps/edge/internal/service`, `packages/go/policy`, `agent-contract/outer/openai-compatible-api.md`
|
||||
- 표준선(선택): 명시 schema가 있는 요청은 runtime-only validator가 우선이며, 모델 gate는 schema가 없거나 출력 의도가 애매한 케이스에 한정해 검토한다.
|
||||
- 표준선(선택): 모델 판정은 오판 가능성이 있으므로 사용자 승인 전에는 blocking source of truth로 삼지 않는다.
|
||||
- 선행 작업: Tool Call Runtime 검증 재시도 MVP
|
||||
- 후속 작업: 단계 호출과 검증 최적화 MVP 또는 별도 model-assisted validation 구현 Milestone
|
||||
- 확인 필요: `구현 잠금 > 결정 필요` 항목
|
||||
|
|
@ -0,0 +1,206 @@
|
|||
<!-- task=m-tool-call-runtime-validation-retry plan=0 tag=API -->
|
||||
|
||||
# Code Review Reference - API
|
||||
|
||||
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
|
||||
> The task is NOT complete until every implementation-owned section below is filled in.
|
||||
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
|
||||
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
|
||||
> If implementation is blocked by a selected Milestone `구현 잠금 > 결정 필요` item, fill `사용자 리뷰 요청` with linked evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Environment/secret/service blockers, generic scope changes, repeated failures, and evidence gaps that a follow-up agent can close are normal follow-up issues, not user-review blockers by themselves.
|
||||
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record only Milestone lock decisions in `사용자 리뷰 요청` and stop for code-review.
|
||||
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
|
||||
> Follow the ownership table at the bottom of this file for which sections you own.
|
||||
|
||||
## 개요
|
||||
|
||||
date=2026-07-03
|
||||
task=m-tool-call-runtime-validation-retry, plan=0, tag=API
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/tool-call-runtime-validation-retry.md`
|
||||
- Task ids:
|
||||
- `contract-detect`: `tools[]`가 있는 OpenAI-compatible 요청을 runtime validation 대상으로 판정하고 `tool_choice: none` 등 검증 제외 조건을 정리한다.
|
||||
- `candidate-normalize`: native `tool_calls`와 IOP text fallback 결과를 tool name, arguments, origin, raw payload를 가진 공통 후보로 정규화한다.
|
||||
- `schema-validate`: 요청 tool schema를 기준으로 tool name, arguments JSON parse, required/type/object/array/enum/additionalProperties subset을 검증한다.
|
||||
- `exact-retry`: validation 실패가 응답 방출 전 발견되면 같은 요청을 bounded exact replay로 재제출하고, 새 attempt run과 원 요청의 상관관계를 남긴다.
|
||||
- `failure-surface`: retry 한도 초과 또는 재시도 불가 경로에서는 malformed tool call을 그대로 성공 응답으로 내보내지 않고 OpenAI-compatible 실패로 구분한다.
|
||||
- `validation-tests`: runtime validation, retry 성공, retry 한도 초과, stream 제외 또는 buffer 경로를 테스트한다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 이 파일을 읽는 리뷰 에이전트에게
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
|
||||
|
||||
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
|
||||
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
|
||||
|
||||
1. 판정을 append한다.
|
||||
2. `CODE_REVIEW-local-G05.md` → `code_review_local_G05_N.log`, `PLAN-local-G05.md` → `plan_local_G05_M.log`로 아카이브한다.
|
||||
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-tool-call-runtime-validation-retry/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 연결된 Milestone 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
|
||||
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
|
||||
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
|
||||
|
||||
---
|
||||
|
||||
## 구현 항목별 완료 여부
|
||||
|
||||
| 항목 | 완료 여부 |
|
||||
|------|---------|
|
||||
| [API-1] Stream Validation Routing | [x] |
|
||||
| [API-2] Buffered Stream Retry And Failure | [x] |
|
||||
| [API-3] Final Validation Evidence | [x] |
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [x] 기존 non-stream validation 기반(`toolValidationContractFromRequest`, `validateToolCallResponse`, `completeChatCompletion` retry loop)을 보존하고 stream mode와 충돌하지 않게 정리한다.
|
||||
- [x] live SSE는 `tools[]`가 있어도 이미 응답을 방출할 수 있는 경로로 명시적으로 validation/retry 대상에서 제외하고, validation attempt metadata를 남기지 않는다.
|
||||
- [x] strict buffered stream은 tool call chunk를 쓰기 전에 candidate normalize/schema validation을 수행하고, 실패 시 같은 `SubmitRunRequest`를 최대 1회 재제출한다.
|
||||
- [x] strict buffered stream retry 한도 초과나 재시도 불가 상태에서는 malformed `tool_calls` chunk를 성공으로 내보내지 않고 SSE error로 구분한다.
|
||||
- [x] `apps/edge/internal/openai/server_test.go`에 buffered retry 성공, buffered retry 한도 초과 실패, live stream validation 제외 테스트를 추가/갱신하고 `go test -count=1 ./apps/edge/internal/openai`를 통과시킨다.
|
||||
- [x] `go test -count=1 ./apps/edge/...`를 통과시킨다.
|
||||
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
## 코드리뷰 전용 체크리스트
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
|
||||
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
|
||||
|
||||
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
|
||||
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
|
||||
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_{review_lane}_GNN_N.log`로 아카이브한다.
|
||||
- [x] active `PLAN-*-G??.md`를 `plan_{build_lane}_GNN_M.log`로 아카이브한다.
|
||||
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
|
||||
- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
|
||||
- [x] PASS이면 active task 디렉터리 `agent-task/{task_name}/`를 `agent-task/archive/YYYY/MM/{task_name}/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
|
||||
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
|
||||
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/{task_group}/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
|
||||
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-{build_lane}-GNN.md`와 `CODE_REVIEW-{review_lane}-GNN.md`를 작성하고 `complete.log`를 작성하지 않는다.
|
||||
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
|
||||
- [ ] USER_REVIEW가 연결된 Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
- 검증 명령은 계획대로 실행했다. 대체 없음.
|
||||
- API-2에서 `streamBufferedChatCompletion`이 `collectRunResult`를 직접 호출해 native/text tool call을 다시 조립하던 방식을 제거하고, non-stream 경로와 동일한 `collectChatCompletionOutput` 공유 helper를 재사용하도록 바꿨다. 계획이 제시한 "shared completion output helper 사용" 옵션을 택한 것이며, tool call normalize/finish reason 로직 중복을 없애고 non-stream과 stream buffered가 같은 후보 정규화·검증 입력을 쓰게 한다.
|
||||
- role chunk 방출 위치를 옮겼다. 기존에는 `streamChatCompletion` 최상단에서 모든 stream mode에 role-only chunk를 먼저 썼다. 이제 live SSE는 종전대로 즉시 role chunk를 쓰지만, strict buffered stream은 collect/validation/retry가 성공한 뒤 `writeBufferedStreamOutput` 안에서 role chunk를 쓴다. 계획의 "role chunk는 validation-enabled buffered stream에서 collect/retry 이후에 쓴다" 허용 범위 안이며, retry로 run id가 바뀔 때 첫 chunk의 id가 최종 run과 어긋나는 문제를 막는다.
|
||||
- `handleChatCompletions`의 stream 분기에 있던 `defer handle.Close()`를 제거하고 handle 종료 책임을 `streamChatCompletion`로 옮겼다. live 경로는 `defer handle.Close()`로, buffered 경로는 retry loop의 각 종료 지점에서 직접 닫는다. non-stream `completeChatCompletion`과 동일하게 retry 시 이전 handle을 명시적으로 닫기 위한 정리다.
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
- stream mode별 validation 계약은 `toolValidationContractForChatRequest(req, outputPolicy)` 하나로 판정한다. non-stream과 strict buffered stream은 base 계약을 유지(enabled)하고, live SSE(`stream:true`이며 strict buffered가 아님)는 disabled로 접어 attempt metadata를 붙이지 않는다. 기존 `toolValidationContractFromRequest`는 base 판정으로 보존한다.
|
||||
- strict buffered stream retry는 non-stream과 같은 `maxToolValidationAttempts`(=2) 예산, 같은 `toolValidationAttemptMetadata`(attempt/retry_of/failure) 키, 같은 `SubmitRunRequest` exact replay를 공유한다. 첫 user-visible chunk를 쓰기 전에만 재제출하므로 exact replay 의미가 non-stream과 동일하다.
|
||||
- 실패 표면은 SSE 안에서 성공 tool_calls chunk와 구분한다. `writeSSEError`를 `writeSSEErrorWithType`로 일반화해 retry 한도 초과는 `tool_validation_error`, 재제출 dispatch 실패는 `tool_validation_retry_error` error type으로 방출하고, malformed `tool_calls` chunk는 절대 쓰지 않는다. SSE는 항상 HTTP 200 + body 안 error 계약을 유지한다.
|
||||
- edge domain 경계 유지: 모든 변경은 `apps/edge/internal/openai` 입력 표면 안에 있고 `service.SubmitRun`의 `adapter + target` dispatch 계약을 그대로 쓴다. node adapter, proto, config, 외부 계약은 건드리지 않는다.
|
||||
|
||||
## 사용자 리뷰 요청
|
||||
|
||||
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
|
||||
|
||||
- 상태: 없음
|
||||
- 사유 유형: 없음
|
||||
- 연결 대상: 없음
|
||||
- 결정 필요: 없음
|
||||
- 차단 근거: 없음
|
||||
- 실행한 검증/명령: 없음
|
||||
- 자동 후속 불가 이유: 없음
|
||||
- 재개 조건: 없음
|
||||
|
||||
## 리뷰어를 위한 체크포인트
|
||||
|
||||
- live SSE가 validation attempt metadata나 retry를 암시하지 않는지 확인한다.
|
||||
- strict buffered stream에서 invalid tool call chunk가 성공 SSE로 노출되지 않는지 확인한다.
|
||||
- retry attempt metadata가 원 run id와 failure reason을 보존하는지 확인한다.
|
||||
- `Roadmap Targets`의 여섯 task id가 테스트 evidence와 맞는지 확인한다.
|
||||
|
||||
## 검증 결과
|
||||
|
||||
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
|
||||
|
||||
필수 규칙:
|
||||
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
|
||||
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
|
||||
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
|
||||
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
|
||||
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
|
||||
|
||||
### API-1 중간 검증
|
||||
```
|
||||
$ go test -count=1 ./apps/edge/internal/openai -run 'TestChatCompletionsLiveStreamSkipsRuntimeToolValidation|TestChatCompletionsRetriesMalformedToolCallBeforeResponse'
|
||||
ok iop/apps/edge/internal/openai 0.006s
|
||||
```
|
||||
(별도 `-v` 실행: `TestChatCompletionsRetriesMalformedToolCallBeforeResponse` PASS, `TestChatCompletionsLiveStreamSkipsRuntimeToolValidation` PASS)
|
||||
|
||||
### API-2 중간 검증
|
||||
```
|
||||
$ go test -count=1 ./apps/edge/internal/openai -run 'TestChatCompletionsStrictBufferedStream'
|
||||
ok iop/apps/edge/internal/openai 0.005s
|
||||
```
|
||||
(별도 `-v` 실행: `TestChatCompletionsStrictBufferedStreamRetriesMalformedToolCallBeforeChunk` PASS, `TestChatCompletionsStrictBufferedStreamFailsMalformedToolCallAfterRetryLimit` PASS)
|
||||
|
||||
### API-3 중간 검증
|
||||
```
|
||||
$ go test -count=1 ./apps/edge/internal/openai
|
||||
ok iop/apps/edge/internal/openai 1.567s
|
||||
```
|
||||
|
||||
### 최종 검증
|
||||
```
|
||||
$ go test -count=1 ./apps/edge/internal/openai
|
||||
ok iop/apps/edge/internal/openai 1.567s
|
||||
|
||||
$ go test -count=1 ./apps/edge/...
|
||||
ok iop/apps/edge/cmd/edge 0.039s
|
||||
ok iop/apps/edge/internal/bootstrap 0.286s
|
||||
ok iop/apps/edge/internal/configrefresh 0.020s
|
||||
ok iop/apps/edge/internal/controlplane 4.448s
|
||||
ok iop/apps/edge/internal/edgecmd 0.018s
|
||||
ok iop/apps/edge/internal/edgevalidate 0.006s
|
||||
ok iop/apps/edge/internal/events 0.004s
|
||||
ok iop/apps/edge/internal/input 0.006s
|
||||
ok iop/apps/edge/internal/input/a2a 0.007s
|
||||
ok iop/apps/edge/internal/node 0.011s
|
||||
ok iop/apps/edge/internal/openai 1.569s
|
||||
ok iop/apps/edge/internal/opsconsole 0.006s
|
||||
ok iop/apps/edge/internal/service 0.441s
|
||||
ok iop/apps/edge/internal/transport 2.037s
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
|
||||
> If anything is blank, go back and fill it in before saving this file.
|
||||
> Leave review-agent-only sections unchanged.
|
||||
|
||||
Sections and their ownership:
|
||||
|
||||
| Section | Owner | Note |
|
||||
|---------|-------|------|
|
||||
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these (archive, complete.log, and task-directory archive move are review-agent only) |
|
||||
| Roadmap Targets | Fixed at stub creation from plan when present | Implementing agent must not modify; code-review copies it into `complete.log` as `Roadmap Completion` only on PASS |
|
||||
| 구현 항목별 완료 여부 (item names) | Fixed at stub creation | Implementing agent checks `[ ]` -> `[x]` only |
|
||||
| 구현 체크리스트 (item text/order) | Fixed at stub creation from plan | Implementing agent checks `[ ]` -> `[x]` only; final checkbox is mandatory before saving |
|
||||
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section |
|
||||
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content |
|
||||
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless a selected Milestone `구현 잠금 > 결정 필요` item blocks implementation; do not ask the user directly during implementation |
|
||||
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Pre-filled from plan |
|
||||
| 검증 결과 (section headings + commands) | Fixed at stub creation | Implementing agent fills in command output only; command changes require a `계획 대비 변경 사항` entry |
|
||||
| 코드리뷰 결과 | Review agent appends | Not included in stub |
|
||||
|
||||
## 코드리뷰 결과
|
||||
|
||||
- 종합 판정: PASS
|
||||
- 차원별 평가:
|
||||
- correctness: Pass
|
||||
- completeness: Pass
|
||||
- test coverage: Pass
|
||||
- API contract: Pass
|
||||
- code quality: Pass
|
||||
- implementation deviation: Pass
|
||||
- verification trust: Pass
|
||||
- spec conformance: Pass
|
||||
- 발견된 문제: 없음
|
||||
- 리뷰 검증:
|
||||
- `go test -count=1 ./apps/edge/internal/openai`: PASS (`ok iop/apps/edge/internal/openai 1.586s`)
|
||||
- `go test -count=1 ./apps/edge/...`: PASS (`apps/edge/internal/openai` 포함 전체 edge package 통과)
|
||||
- 다음 단계: PASS 종결 절차를 수행한다. `complete.log`를 작성하고 task directory를 `agent-task/archive/2026/07/m-tool-call-runtime-validation-retry/`로 이동한다.
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
# Complete - m-tool-call-runtime-validation-retry
|
||||
|
||||
## 완료 일시
|
||||
|
||||
2026-07-03
|
||||
|
||||
## 요약
|
||||
|
||||
Runtime tool call validation/retry MVP stream completion review completed in 1 loop with final verdict PASS.
|
||||
|
||||
## 루프 이력
|
||||
|
||||
| Plan | Review | Verdict | 메모 |
|
||||
|------|--------|---------|------|
|
||||
| `plan_local_G05_0.log` | `code_review_local_G05_0.log` | PASS | non-stream validation preservation, live SSE exclusion, strict buffered stream retry/failure, and edge package verification passed |
|
||||
|
||||
## 구현/정리 내용
|
||||
|
||||
- Added stream-aware runtime tool validation routing so non-stream and strict buffered stream validate tool calls, while live SSE remains excluded from exact retry metadata.
|
||||
- Shared chat completion output collection between non-stream and strict buffered stream paths, then validated normalized native/text tool call candidates before emitting caller-visible responses.
|
||||
- Added bounded exact replay metadata for validation retries and OpenAI-compatible/SSE failure surfaces for retry exhaustion or retry dispatch failure.
|
||||
- Added package tests for non-stream retry/failure, `tool_choice: none` exclusion, live SSE exclusion, strict buffered stream retry/failure, and JSON Schema subset validation.
|
||||
|
||||
## 최종 검증
|
||||
|
||||
- `go test -count=1 ./apps/edge/internal/openai` - PASS; `ok iop/apps/edge/internal/openai 1.586s`
|
||||
- `go test -count=1 ./apps/edge/...` - PASS; edge package suite passed, including `iop/apps/edge/internal/openai 1.566s`
|
||||
|
||||
## Roadmap Completion
|
||||
|
||||
- Milestone: `agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/tool-call-runtime-validation-retry.md`
|
||||
- Completed task ids:
|
||||
- `contract-detect`: PASS; evidence=`agent-task/archive/2026/07/m-tool-call-runtime-validation-retry/plan_local_G05_0.log`, `agent-task/archive/2026/07/m-tool-call-runtime-validation-retry/code_review_local_G05_0.log`; verification=`go test -count=1 ./apps/edge/internal/openai`, `go test -count=1 ./apps/edge/...`
|
||||
- `candidate-normalize`: PASS; evidence=`agent-task/archive/2026/07/m-tool-call-runtime-validation-retry/plan_local_G05_0.log`, `agent-task/archive/2026/07/m-tool-call-runtime-validation-retry/code_review_local_G05_0.log`; verification=`go test -count=1 ./apps/edge/internal/openai`, `go test -count=1 ./apps/edge/...`
|
||||
- `schema-validate`: PASS; evidence=`agent-task/archive/2026/07/m-tool-call-runtime-validation-retry/plan_local_G05_0.log`, `agent-task/archive/2026/07/m-tool-call-runtime-validation-retry/code_review_local_G05_0.log`; verification=`go test -count=1 ./apps/edge/internal/openai`, `go test -count=1 ./apps/edge/...`
|
||||
- `exact-retry`: PASS; evidence=`agent-task/archive/2026/07/m-tool-call-runtime-validation-retry/plan_local_G05_0.log`, `agent-task/archive/2026/07/m-tool-call-runtime-validation-retry/code_review_local_G05_0.log`; verification=`go test -count=1 ./apps/edge/internal/openai`, `go test -count=1 ./apps/edge/...`
|
||||
- `failure-surface`: PASS; evidence=`agent-task/archive/2026/07/m-tool-call-runtime-validation-retry/plan_local_G05_0.log`, `agent-task/archive/2026/07/m-tool-call-runtime-validation-retry/code_review_local_G05_0.log`; verification=`go test -count=1 ./apps/edge/internal/openai`, `go test -count=1 ./apps/edge/...`
|
||||
- `validation-tests`: PASS; evidence=`agent-task/archive/2026/07/m-tool-call-runtime-validation-retry/plan_local_G05_0.log`, `agent-task/archive/2026/07/m-tool-call-runtime-validation-retry/code_review_local_G05_0.log`; verification=`go test -count=1 ./apps/edge/internal/openai`, `go test -count=1 ./apps/edge/...`
|
||||
- Not completed task ids: 없음
|
||||
|
||||
## 잔여 Nit
|
||||
|
||||
- 없음
|
||||
|
||||
## 후속 작업
|
||||
|
||||
- 없음
|
||||
|
|
@ -0,0 +1,239 @@
|
|||
<!-- task=m-tool-call-runtime-validation-retry plan=0 tag=API -->
|
||||
|
||||
# Runtime Tool Validation Stream Completion - API
|
||||
|
||||
## 이 파일을 읽는 구현 에이전트에게
|
||||
|
||||
`CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우는 것이 필수 마지막 단계다. 검증을 실행하고 실제 stdout/stderr를 남긴 뒤 active 파일을 유지한 채 review ready로 보고한다. finalization, `complete.log`, archive 이동은 code-review-skill 전용이다. 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 막을 때만 review stub의 `사용자 리뷰 요청` 섹션에 근거를 기록하고 멈춘다. 직접 사용자 질문, 채팅 선택지, `request_user_input`, `USER_REVIEW.md` 생성, log/archive/complete 작성은 금지다. 환경/secret/service blocker, 일반 범위 조정, 후속 agent가 닫을 수 있는 evidence gap은 사용자 리뷰 요청이 아니다.
|
||||
|
||||
## 배경
|
||||
|
||||
`runtime-validate` 에픽의 작은 non-stream 기반 변경은 현재 working tree에 먼저 들어가 있다. 남은 큰 작업은 stream 경로다. live SSE는 이미 content를 내보낼 수 있어 exact retry 의미가 다르고, strict buffered stream은 아직 `streamBufferedChatCompletion` 내부에서 validation/retry를 공유하지 않는다. 이 계획은 기존 non-stream 기반을 보존하면서 stream mode별 validation contract를 명확히 완성한다.
|
||||
|
||||
## 사용자 리뷰 요청 흐름
|
||||
|
||||
사용자 리뷰 요청은 선택된 Milestone lock decision만 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 섹션 형식은 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md`에서 복사된 기본값을 유지한다. 구현 중 직접 사용자에게 묻지 말고, code-review가 사용자 리뷰 필요성을 검증하고 실제 `USER_REVIEW.md` 작성을 소유한다.
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/tool-call-runtime-validation-retry.md`
|
||||
- Task ids:
|
||||
- `contract-detect`: `tools[]`가 있는 OpenAI-compatible 요청을 runtime validation 대상으로 판정하고 `tool_choice: none` 등 검증 제외 조건을 정리한다.
|
||||
- `candidate-normalize`: native `tool_calls`와 IOP text fallback 결과를 tool name, arguments, origin, raw payload를 가진 공통 후보로 정규화한다.
|
||||
- `schema-validate`: 요청 tool schema를 기준으로 tool name, arguments JSON parse, required/type/object/array/enum/additionalProperties subset을 검증한다.
|
||||
- `exact-retry`: validation 실패가 응답 방출 전 발견되면 같은 요청을 bounded exact replay로 재제출하고, 새 attempt run과 원 요청의 상관관계를 남긴다.
|
||||
- `failure-surface`: retry 한도 초과 또는 재시도 불가 경로에서는 malformed tool call을 그대로 성공 응답으로 내보내지 않고 OpenAI-compatible 실패로 구분한다.
|
||||
- `validation-tests`: runtime validation, retry 성공, retry 한도 초과, stream 제외 또는 buffer 경로를 테스트한다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 분석 결과
|
||||
|
||||
### 읽은 파일
|
||||
|
||||
- `agent-roadmap/current.md`
|
||||
- `agent-roadmap/phase/knowledge-tool-optimization-extension/PHASE.md`
|
||||
- `agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/tool-call-runtime-validation-retry.md`
|
||||
- `agent-ops/rules/project/rules.md`
|
||||
- `agent-ops/rules/common/rules-roadmap.md`
|
||||
- `agent-ops/rules/project/domain/edge/rules.md`
|
||||
- `agent-ops/rules/project/domain/testing/rules.md`
|
||||
- `agent-ops/skills/common/router.md`
|
||||
- `agent-ops/skills/common/plan/SKILL.md`
|
||||
- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md`
|
||||
- `agent-test/local/rules.md`
|
||||
- `agent-test/local/edge-smoke.md`
|
||||
- `agent-contract/index.md`
|
||||
- `agent-contract/outer/openai-compatible-api.md`
|
||||
- `agent-contract/inner/edge-node-runtime-wire.md`
|
||||
- `apps/edge/internal/openai/chat_handler.go`
|
||||
- `apps/edge/internal/openai/run_result.go`
|
||||
- `apps/edge/internal/openai/types.go`
|
||||
- `apps/edge/internal/openai/stream.go`
|
||||
- `apps/edge/internal/openai/strict_output.go`
|
||||
- `apps/edge/internal/openai/server.go`
|
||||
- `apps/edge/internal/openai/server_test.go`
|
||||
- `apps/edge/internal/service/run_dispatch.go`
|
||||
- `apps/edge/internal/service/service.go`
|
||||
- `go.mod`
|
||||
|
||||
### SDD 기준
|
||||
|
||||
선택 Milestone은 `SDD: 불필요`이다. 기록된 사유: 기존 OpenAI-compatible 요청/응답 계약 안에서 명시적 `tools[]` schema를 검증하고 같은 요청을 내부 재제출하는 bounded MVP로 제한하며, 새 proto/config/API field나 외부 계약 확장은 만들지 않는다.
|
||||
|
||||
### 테스트 환경 규칙
|
||||
|
||||
- `test_env`: local.
|
||||
- `agent-test/local/rules.md`: present/read. Go quick check는 local checkout에서 실행한다.
|
||||
- matched profile: `agent-test/local/edge-smoke.md`. `apps/edge/internal/openai/**` 변경이므로 edge smoke profile을 적용한다.
|
||||
- 적용 명령: 변경 패키지에는 `go test -count=1 ./apps/edge/internal/openai`, edge 범위 회귀에는 `go test -count=1 ./apps/edge/...`.
|
||||
- OpenAI-compatible 경계 변경이므로 최종 보고에서 full-cycle 또는 smoke 생략 여부를 명시한다. 이 계획의 필수 자동 검증은 local Go tests이며, 외부 runtime/field runner preflight는 요구하지 않는다.
|
||||
|
||||
### 테스트 커버리지 공백
|
||||
|
||||
- non-stream validation/retry/failure: 현재 working tree의 `server_test.go`에 성공 retry, 한도 초과 실패, `tool_choice: none` 제외, schema subset 단위 테스트가 있다.
|
||||
- strict buffered stream: 아직 malformed native/text tool call을 validation/retry하는 테스트가 없다.
|
||||
- live SSE stream: 이미 content를 방출할 수 있는 경로의 validation 제외 또는 실패 표면 정책 테스트가 없다.
|
||||
- malformed `openai_tool_calls` metadata JSON parse error는 기존 `toolCallsFromRunMetadata`가 nil로 접는 구조다. 이 계획은 runtime candidate가 존재하는 malformed shape/schema를 다루고, metadata parse error 정책 확장은 범위 제외한다.
|
||||
|
||||
### 심볼 참조
|
||||
|
||||
renamed/removed symbol 없음. 새 helper는 `openai` package 내부 호출로 한정한다.
|
||||
|
||||
### 분할 판단
|
||||
|
||||
split decision policy를 평가했다. 단일 plan을 사용한다. 변경은 `apps/edge/internal/openai` 한 ownership boundary에 있고, 새 public API/proto/config foundation과 broad call-site rollout을 만들지 않는다. stream contract routing과 buffered retry는 같은 handler/stream lifecycle의 한 구현 단위이며, tests도 같은 package command로 검증된다. 별도 split은 active working tree의 non-stream 기반과 stream 마무리 사이 coordination 비용만 늘린다.
|
||||
|
||||
### 범위 결정 근거
|
||||
|
||||
- 포함: OpenAI Chat Completions non-stream 기반 보존, stream mode별 validation 적용/제외, strict buffered stream retry/failure, package tests.
|
||||
- 제외: Node adapter native tool retry 정책 변경, provider-specific prompt repair, live SSE에서 이미 방출한 content를 되감는 투명 재시도, 새 public API/config/proto field, docs/contract 원문 변경.
|
||||
- `apps/node/**`, `proto/**`, `packages/go/config/**`는 이번 구현에서 수정하지 않는다.
|
||||
|
||||
### 빌드 등급
|
||||
|
||||
`local-G05`. 한 Go package 안의 bounded behavior이고 unit tests로 검증 가능하지만, SSE write timing과 retry semantics가 얽혀 있어 중간 난이도다.
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [ ] 기존 non-stream validation 기반(`toolValidationContractFromRequest`, `validateToolCallResponse`, `completeChatCompletion` retry loop)을 보존하고 stream mode와 충돌하지 않게 정리한다.
|
||||
- [ ] live SSE는 `tools[]`가 있어도 이미 응답을 방출할 수 있는 경로로 명시적으로 validation/retry 대상에서 제외하고, validation attempt metadata를 남기지 않는다.
|
||||
- [ ] strict buffered stream은 tool call chunk를 쓰기 전에 candidate normalize/schema validation을 수행하고, 실패 시 같은 `SubmitRunRequest`를 최대 1회 재제출한다.
|
||||
- [ ] strict buffered stream retry 한도 초과나 재시도 불가 상태에서는 malformed `tool_calls` chunk를 성공으로 내보내지 않고 SSE error로 구분한다.
|
||||
- [ ] `apps/edge/internal/openai/server_test.go`에 buffered retry 성공, buffered retry 한도 초과 실패, live stream validation 제외 테스트를 추가/갱신하고 `go test -count=1 ./apps/edge/internal/openai`를 통과시킨다.
|
||||
- [ ] `go test -count=1 ./apps/edge/...`를 통과시킨다.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
### [API-1] Stream Validation Routing
|
||||
|
||||
#### 문제
|
||||
|
||||
[apps/edge/internal/openai/chat_handler.go](/config/workspace/iop/apps/edge/internal/openai/chat_handler.go:73)는 `tools[]`와 `tool_choice`만으로 validation metadata를 붙인다. [apps/edge/internal/openai/stream.go](/config/workspace/iop/apps/edge/internal/openai/stream.go:29)는 live stream에서 role chunk를 즉시 쓰고, [apps/edge/internal/openai/stream.go](/config/workspace/iop/apps/edge/internal/openai/stream.go:105)는 content delta도 즉시 방출한다. 이 상태에서 live stream까지 validation attempt로 표시하면 실제 검증/재시도와 metadata가 불일치한다.
|
||||
|
||||
Before:
|
||||
|
||||
```go
|
||||
// apps/edge/internal/openai/chat_handler.go:73
|
||||
validation := toolValidationContractFromRequest(req)
|
||||
metadata := chatRunMetadata(runMeta, req, outputPolicy)
|
||||
if validation.enabled {
|
||||
metadata = toolValidationAttemptMetadata(metadata, 1, "", "")
|
||||
}
|
||||
```
|
||||
|
||||
#### 해결 방법
|
||||
|
||||
stream mode를 반영한 validation contract helper를 둔다. non-stream은 현재대로 enabled, strict buffered stream은 enabled, live SSE는 disabled with explicit exclusion reason로 처리한다. `SubmitRunRequest.Metadata`에는 enabled일 때만 attempt key를 둔다.
|
||||
|
||||
After:
|
||||
|
||||
```go
|
||||
validation := toolValidationContractForChatRequest(req, outputPolicy)
|
||||
metadata := chatRunMetadata(runMeta, req, outputPolicy)
|
||||
if validation.enabled {
|
||||
metadata = toolValidationAttemptMetadata(metadata, 1, "", "")
|
||||
}
|
||||
```
|
||||
|
||||
#### 수정 파일 및 체크리스트
|
||||
|
||||
- [ ] `apps/edge/internal/openai/tool_validation.go`: stream-aware contract helper 추가.
|
||||
- [ ] `apps/edge/internal/openai/chat_handler.go`: helper 호출 전환.
|
||||
- [ ] `apps/edge/internal/openai/server_test.go`: live stream exclusion assertion 추가.
|
||||
|
||||
#### 테스트 작성
|
||||
|
||||
`TestChatCompletionsLiveStreamSkipsRuntimeToolValidation`를 추가한다. `stream:true`, `tools[]`, invalid provider tool call metadata를 주고 submit attempts가 1회이며 validation metadata가 없고 SSE가 기존 live semantics를 유지하는지 확인한다.
|
||||
|
||||
#### 중간 검증
|
||||
|
||||
`go test -count=1 ./apps/edge/internal/openai -run 'TestChatCompletionsLiveStreamSkipsRuntimeToolValidation|TestChatCompletionsRetriesMalformedToolCallBeforeResponse'`
|
||||
|
||||
기대: PASS.
|
||||
|
||||
### [API-2] Buffered Stream Retry And Failure
|
||||
|
||||
#### 문제
|
||||
|
||||
[apps/edge/internal/openai/stream.go](/config/workspace/iop/apps/edge/internal/openai/stream.go:304)의 `streamBufferedChatCompletion`은 `collectRunResult` 뒤 바로 native/text tool calls를 SSE로 쓴다. non-stream path의 `validateToolCallResponse`와 retry loop를 공유하지 않아서 strict buffered stream에서 malformed tool call이 그대로 성공 chunk로 나갈 수 있다.
|
||||
|
||||
Before:
|
||||
|
||||
```go
|
||||
// apps/edge/internal/openai/stream.go:323
|
||||
if len(nativeToolCalls) > 0 {
|
||||
nativeToolCalls = normalizeNativeToolCallsForResponse(nativeToolCalls, tools)
|
||||
...
|
||||
writeSSE(w, flusher, chatCompletionChunk{... ToolCalls: toolCallsForStreamDelta(nativeToolCalls) ...})
|
||||
finishReason = "tool_calls"
|
||||
}
|
||||
```
|
||||
|
||||
#### 해결 방법
|
||||
|
||||
buffered stream도 non-stream의 collection/build/validation helper를 공유하거나 같은 결과 DTO를 사용한다. validation 실패 시 첫 user-visible payload를 쓰기 전에 handle을 닫고 `SubmitRunRequest`를 같은 input/prompt/options로 재제출한다. retry 한도 초과 시 `writeSSEError`를 사용하되 error type을 `tool_validation_error`로 구분하는 helper가 필요하면 추가한다. role chunk는 validation-enabled buffered stream에서 collect/retry 이후에 쓰거나, 초기 role-only chunk가 허용되는지 test로 고정한다. malformed tool call chunk는 절대 쓰지 않는다.
|
||||
|
||||
#### 수정 파일 및 체크리스트
|
||||
|
||||
- [ ] `apps/edge/internal/openai/stream.go`: buffered stream에 `submitReq`와 `validation`을 전달하거나 shared completion output helper를 사용한다.
|
||||
- [ ] `apps/edge/internal/openai/chat_handler.go`: stream call signature에 필요한 context를 전달한다.
|
||||
- [ ] `apps/edge/internal/openai/tool_validation.go`: retry metadata helper 재사용.
|
||||
- [ ] `apps/edge/internal/openai/server_test.go`: buffered success/failure tests 추가.
|
||||
|
||||
#### 테스트 작성
|
||||
|
||||
다음 테스트를 작성한다.
|
||||
|
||||
- `TestChatCompletionsStrictBufferedStreamRetriesMalformedToolCallBeforeChunk`: 첫 run은 schema mismatch native tool call, 두 번째 run은 valid tool call. SSE body에 valid call만 있고 attempts metadata가 1/2인지 확인.
|
||||
- `TestChatCompletionsStrictBufferedStreamFailsMalformedToolCallAfterRetryLimit`: 두 run 모두 invalid. SSE body에 `tool_validation_error`가 있고 invalid `tool_calls` chunk가 없는지 확인.
|
||||
|
||||
#### 중간 검증
|
||||
|
||||
`go test -count=1 ./apps/edge/internal/openai -run 'TestChatCompletionsStrictBufferedStream'`
|
||||
|
||||
기대: PASS.
|
||||
|
||||
### [API-3] Final Validation Evidence
|
||||
|
||||
#### 문제
|
||||
|
||||
Milestone `validation-tests`는 runtime validation, retry 성공, retry 한도 초과, stream 제외 또는 buffer 경로 테스트를 요구한다. 현재 target package test는 non-stream 기반만 검증한다.
|
||||
|
||||
#### 해결 방법
|
||||
|
||||
API-1/API-2 테스트가 추가된 뒤 전체 package와 edge package를 fresh run으로 검증한다. cached output은 사용하지 않는다.
|
||||
|
||||
#### 수정 파일 및 체크리스트
|
||||
|
||||
- [ ] `apps/edge/internal/openai/server_test.go`: final behavior tests가 모두 포함되어 있는지 확인.
|
||||
- [ ] `agent-task/m-tool-call-runtime-validation-retry/CODE_REVIEW-local-G05.md`: 실제 검증 stdout/stderr 기록.
|
||||
|
||||
#### 테스트 작성
|
||||
|
||||
별도 테스트 파일은 만들지 않는다. 기존 `server_test.go`가 OpenAI-compatible server behavior의 중심 test file이고, 같은 fake service가 retry attempts를 검증한다.
|
||||
|
||||
#### 중간 검증
|
||||
|
||||
`go test -count=1 ./apps/edge/internal/openai`
|
||||
|
||||
기대: PASS.
|
||||
|
||||
## 수정 파일 요약
|
||||
|
||||
| 파일 | 항목 |
|
||||
|---|---|
|
||||
| `apps/edge/internal/openai/chat_handler.go` | API-1, API-2 |
|
||||
| `apps/edge/internal/openai/stream.go` | API-1, API-2 |
|
||||
| `apps/edge/internal/openai/tool_validation.go` | API-1, API-2 |
|
||||
| `apps/edge/internal/openai/server_test.go` | API-1, API-2, API-3 |
|
||||
| `agent-task/m-tool-call-runtime-validation-retry/CODE_REVIEW-local-G05.md` | API-3 |
|
||||
|
||||
## 최종 검증
|
||||
|
||||
```bash
|
||||
go test -count=1 ./apps/edge/internal/openai
|
||||
go test -count=1 ./apps/edge/...
|
||||
```
|
||||
|
||||
기대: 두 명령 모두 PASS. Go test cache output은 허용하지 않으므로 `-count=1`을 유지한다.
|
||||
|
||||
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -70,32 +70,23 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
|
|||
zap.Any("input_keys", mapKeys(input)),
|
||||
)
|
||||
|
||||
handle, err := s.service.SubmitRun(r.Context(), edgeservice.SubmitRunRequest{
|
||||
NodeRef: dispatch.NodeRef,
|
||||
ModelGroupKey: strings.TrimSpace(req.Model),
|
||||
Adapter: dispatch.Adapter,
|
||||
Target: dispatch.Target,
|
||||
SessionID: dispatch.SessionID,
|
||||
Workspace: workspace,
|
||||
Prompt: prompt,
|
||||
Input: input,
|
||||
TimeoutSec: dispatch.TimeoutSec,
|
||||
MaxQueue: dispatch.MaxQueue,
|
||||
QueueTimeoutMS: dispatch.QueueTimeoutMS,
|
||||
Metadata: chatRunMetadata(runMeta, req, outputPolicy),
|
||||
ProviderPool: dispatch.ProviderPool,
|
||||
})
|
||||
validation := toolValidationContractForChatRequest(req, outputPolicy)
|
||||
metadata := chatRunMetadata(runMeta, req, outputPolicy)
|
||||
if validation.enabled {
|
||||
metadata = toolValidationAttemptMetadata(metadata, 1, "", "")
|
||||
}
|
||||
submitReq := chatSubmitRunRequest(dispatch, req, workspace, prompt, input, metadata)
|
||||
handle, err := s.service.SubmitRun(r.Context(), submitReq)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "node_dispatch_error", err.Error())
|
||||
return
|
||||
}
|
||||
defer handle.Close()
|
||||
|
||||
if req.Stream {
|
||||
s.streamChatCompletion(w, r, req, handle, outputPolicy)
|
||||
s.streamChatCompletion(w, r, req, submitReq, handle, outputPolicy, validation)
|
||||
return
|
||||
}
|
||||
s.completeChatCompletion(w, r, req, handle, outputPolicy)
|
||||
s.completeChatCompletion(w, r, req, submitReq, handle, outputPolicy, validation)
|
||||
}
|
||||
|
||||
func decodeChatCompletionRequest(dec *json.Decoder, req *chatCompletionRequest) error {
|
||||
|
|
@ -158,51 +149,138 @@ func shouldSynthesizeTextToolCalls(dispatch edgeservice.RunDispatch, textToolFal
|
|||
return textToolFallback || strings.TrimSpace(dispatch.Adapter) == "cli"
|
||||
}
|
||||
|
||||
func (s *Server) completeChatCompletion(w http.ResponseWriter, r *http.Request, req chatCompletionRequest, handle edgeservice.RunResult, outputPolicy strictOutputPolicy) {
|
||||
text, reasoning, finishReason, nativeToolCalls, usage, textToolFallback, err := collectRunResult(r.Context(), handle.Stream(), handle.WaitTimeout())
|
||||
if err != nil {
|
||||
s.cancelRunOnHTTPGiveUp(handle.Dispatch(), err)
|
||||
writeError(w, httpStatusForRunError(err), "run_error", err.Error())
|
||||
func chatSubmitRunRequest(dispatch routeDispatch, req chatCompletionRequest, workspace, prompt string, input map[string]any, metadata map[string]string) edgeservice.SubmitRunRequest {
|
||||
return edgeservice.SubmitRunRequest{
|
||||
NodeRef: dispatch.NodeRef,
|
||||
ModelGroupKey: strings.TrimSpace(req.Model),
|
||||
Adapter: dispatch.Adapter,
|
||||
Target: dispatch.Target,
|
||||
SessionID: dispatch.SessionID,
|
||||
Workspace: workspace,
|
||||
Prompt: prompt,
|
||||
Input: input,
|
||||
TimeoutSec: dispatch.TimeoutSec,
|
||||
MaxQueue: dispatch.MaxQueue,
|
||||
QueueTimeoutMS: dispatch.QueueTimeoutMS,
|
||||
Metadata: metadata,
|
||||
ProviderPool: dispatch.ProviderPool,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) completeChatCompletion(w http.ResponseWriter, r *http.Request, req chatCompletionRequest, submitReq edgeservice.SubmitRunRequest, handle edgeservice.RunResult, outputPolicy strictOutputPolicy, validation toolValidationContract) {
|
||||
attempt := 1
|
||||
for {
|
||||
result, err := collectChatCompletionOutput(r.Context(), req, handle, outputPolicy)
|
||||
if err != nil {
|
||||
s.cancelRunOnHTTPGiveUp(handle.Dispatch(), err)
|
||||
handle.Close()
|
||||
writeError(w, httpStatusForRunError(err), "run_error", err.Error())
|
||||
return
|
||||
}
|
||||
if err := validateToolCallResponse(validation, result.toolCalls, result.toolCallOrigin); err != nil {
|
||||
failedRunID := handle.Dispatch().RunID
|
||||
if attempt < maxToolValidationAttempts {
|
||||
handle.Close()
|
||||
attempt++
|
||||
retryReq := submitReq
|
||||
retryReq.Metadata = toolValidationAttemptMetadata(submitReq.Metadata, attempt, failedRunID, err.Error())
|
||||
s.logger.Warn("openai chat completion tool validation retry",
|
||||
zap.String("run_id", failedRunID),
|
||||
zap.Int("attempt", attempt),
|
||||
zap.String("reason", err.Error()),
|
||||
)
|
||||
next, submitErr := s.service.SubmitRun(r.Context(), retryReq)
|
||||
if submitErr != nil {
|
||||
writeError(w, http.StatusBadGateway, "tool_validation_retry_error", submitErr.Error())
|
||||
return
|
||||
}
|
||||
handle = next
|
||||
continue
|
||||
}
|
||||
handle.Close()
|
||||
s.logger.Warn("openai chat completion tool validation failed",
|
||||
zap.String("run_id", failedRunID),
|
||||
zap.Int("attempt", attempt),
|
||||
zap.String("reason", err.Error()),
|
||||
)
|
||||
writeError(w, http.StatusBadGateway, "tool_validation_error", err.Error())
|
||||
return
|
||||
}
|
||||
handle.Close()
|
||||
s.logger.Info("openai chat completion output",
|
||||
zap.String("run_id", handle.Dispatch().RunID),
|
||||
zap.Bool("strict_output", outputPolicy.Strict),
|
||||
zap.Bool("strict_stream_buffer", outputPolicy.StreamBuffer),
|
||||
zap.String("xml_completion_tool", outputPolicy.XMLCompletionTool),
|
||||
zap.Bool("normalized", result.normalized),
|
||||
zap.Int("content_len", result.contentLen),
|
||||
zap.Int("reasoning_len", result.reasoningLen),
|
||||
zap.String("content_preview", previewString(result.message.Content, 1000)),
|
||||
)
|
||||
created := time.Now().Unix()
|
||||
writeJSON(w, http.StatusOK, chatCompletionResponse{
|
||||
ID: "chatcmpl-" + handle.Dispatch().RunID,
|
||||
Object: "chat.completion",
|
||||
Created: created,
|
||||
Model: responseModel(req.Model, handle.Dispatch().Target),
|
||||
Choices: []chatCompletionChoice{{
|
||||
Index: 0,
|
||||
Message: result.message,
|
||||
FinishReason: result.finishReason,
|
||||
}},
|
||||
Usage: result.usage,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
type chatCompletionOutput struct {
|
||||
message chatMessage
|
||||
finishReason string
|
||||
usage *openAIUsage
|
||||
normalized bool
|
||||
contentLen int
|
||||
reasoningLen int
|
||||
toolCalls []any
|
||||
toolCallOrigin string
|
||||
}
|
||||
|
||||
func collectChatCompletionOutput(ctx context.Context, req chatCompletionRequest, handle edgeservice.RunResult, outputPolicy strictOutputPolicy) (chatCompletionOutput, error) {
|
||||
text, reasoning, finishReason, nativeToolCalls, usage, textToolFallback, err := collectRunResult(ctx, handle.Stream(), handle.WaitTimeout())
|
||||
if err != nil {
|
||||
return chatCompletionOutput{}, err
|
||||
}
|
||||
text, reasoning, normalized := normalizeCompletionOutput(outputPolicy, text, reasoning)
|
||||
message := chatMessage{Role: "assistant", Content: text, ReasoningContent: reasoning}
|
||||
var toolCalls []any
|
||||
toolCallOrigin := ""
|
||||
if len(nativeToolCalls) > 0 {
|
||||
nativeToolCalls = normalizeNativeToolCallsForResponse(nativeToolCalls, req.Tools)
|
||||
message.ToolCalls = nativeToolCalls
|
||||
toolCalls = nativeToolCalls
|
||||
toolCallOrigin = toolCallOriginNative
|
||||
finishReason = "tool_calls"
|
||||
} else if shouldSynthesizeTextToolCalls(handle.Dispatch(), textToolFallback) {
|
||||
cleaned, toolCalls := synthesizeToolCallsFromText(text, req.Tools, handle.Dispatch().RunID)
|
||||
if len(toolCalls) > 0 {
|
||||
cleaned, synthesizedToolCalls := synthesizeToolCallsFromText(text, req.Tools, handle.Dispatch().RunID)
|
||||
if len(synthesizedToolCalls) > 0 {
|
||||
message.Content = cleaned
|
||||
message.ReasoningContent = ""
|
||||
message.ToolCalls = toolCalls
|
||||
message.ToolCalls = synthesizedToolCalls
|
||||
toolCalls = synthesizedToolCalls
|
||||
toolCallOrigin = toolCallOriginText
|
||||
finishReason = "tool_calls"
|
||||
}
|
||||
}
|
||||
s.logger.Info("openai chat completion output",
|
||||
zap.String("run_id", handle.Dispatch().RunID),
|
||||
zap.Bool("strict_output", outputPolicy.Strict),
|
||||
zap.Bool("strict_stream_buffer", outputPolicy.StreamBuffer),
|
||||
zap.String("xml_completion_tool", outputPolicy.XMLCompletionTool),
|
||||
zap.Bool("normalized", normalized),
|
||||
zap.Int("content_len", len(text)),
|
||||
zap.Int("reasoning_len", len(reasoning)),
|
||||
zap.String("content_preview", previewString(text, 1000)),
|
||||
)
|
||||
created := time.Now().Unix()
|
||||
writeJSON(w, http.StatusOK, chatCompletionResponse{
|
||||
ID: "chatcmpl-" + handle.Dispatch().RunID,
|
||||
Object: "chat.completion",
|
||||
Created: created,
|
||||
Model: responseModel(req.Model, handle.Dispatch().Target),
|
||||
Choices: []chatCompletionChoice{{
|
||||
Index: 0,
|
||||
Message: message,
|
||||
FinishReason: finishReason,
|
||||
}},
|
||||
Usage: usage,
|
||||
})
|
||||
return chatCompletionOutput{
|
||||
message: message,
|
||||
finishReason: finishReason,
|
||||
usage: usage,
|
||||
normalized: normalized,
|
||||
contentLen: len(text),
|
||||
reasoningLen: len(reasoning),
|
||||
toolCalls: toolCalls,
|
||||
toolCallOrigin: toolCallOrigin,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) resolveAdapter() string {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
|
|
@ -18,10 +19,14 @@ import (
|
|||
|
||||
type fakeRunService struct {
|
||||
req edgeservice.SubmitRunRequest
|
||||
reqs []edgeservice.SubmitRunRequest
|
||||
ollamaReq edgeservice.OllamaAPIRequest
|
||||
ollamaResp edgeservice.OllamaAPIView
|
||||
events chan *iop.RunEvent
|
||||
eventRuns []chan *iop.RunEvent
|
||||
runIDs []string
|
||||
|
||||
submitMu sync.Mutex
|
||||
cancelMu sync.Mutex
|
||||
cancelCalls []edgeservice.CancelRunRequest
|
||||
cancelErr error
|
||||
|
|
@ -43,6 +48,12 @@ func (s *fakeRunService) cancelCallsSnapshot() []edgeservice.CancelRunRequest {
|
|||
return append([]edgeservice.CancelRunRequest(nil), s.cancelCalls...)
|
||||
}
|
||||
|
||||
func (s *fakeRunService) reqsSnapshot() []edgeservice.SubmitRunRequest {
|
||||
s.submitMu.Lock()
|
||||
defer s.submitMu.Unlock()
|
||||
return append([]edgeservice.SubmitRunRequest(nil), s.reqs...)
|
||||
}
|
||||
|
||||
func TestRoutesRequireBearerTokenWhenConfigured(t *testing.T) {
|
||||
srv := NewServer(config.EdgeOpenAIConf{
|
||||
BearerToken: "secret-token",
|
||||
|
|
@ -87,10 +98,24 @@ func TestHealthzDoesNotRequireBearerToken(t *testing.T) {
|
|||
}
|
||||
|
||||
func (s *fakeRunService) SubmitRun(_ context.Context, req edgeservice.SubmitRunRequest) (edgeservice.RunResult, error) {
|
||||
s.submitMu.Lock()
|
||||
s.req = req
|
||||
s.reqs = append(s.reqs, req)
|
||||
attempt := len(s.reqs)
|
||||
events := s.events
|
||||
if attempt-1 < len(s.eventRuns) {
|
||||
events = s.eventRuns[attempt-1]
|
||||
}
|
||||
runID := "run-test"
|
||||
if attempt-1 < len(s.runIDs) {
|
||||
runID = s.runIDs[attempt-1]
|
||||
} else if attempt > 1 {
|
||||
runID = fmt.Sprintf("run-test-%d", attempt)
|
||||
}
|
||||
s.submitMu.Unlock()
|
||||
return &edgeservice.RunHandle{
|
||||
RunDispatch: edgeservice.RunDispatch{
|
||||
RunID: "run-test",
|
||||
RunID: runID,
|
||||
ModelGroupKey: req.ModelGroupKey,
|
||||
Adapter: req.Adapter,
|
||||
Target: req.Target,
|
||||
|
|
@ -98,7 +123,7 @@ func (s *fakeRunService) SubmitRun(_ context.Context, req edgeservice.SubmitRunR
|
|||
TimeoutSec: 5,
|
||||
},
|
||||
RunStream: edgeservice.RunStream{
|
||||
Events: s.events,
|
||||
Events: events,
|
||||
NodeEvents: make(chan *iop.EdgeNodeEvent),
|
||||
},
|
||||
}, nil
|
||||
|
|
@ -112,6 +137,14 @@ func (s *fakeRunService) OllamaAPI(_ context.Context, req edgeservice.OllamaAPIR
|
|||
return s.ollamaResp, nil
|
||||
}
|
||||
|
||||
func bufferedRunEvents(events ...*iop.RunEvent) chan *iop.RunEvent {
|
||||
ch := make(chan *iop.RunEvent, len(events))
|
||||
for _, event := range events {
|
||||
ch <- event
|
||||
}
|
||||
return ch
|
||||
}
|
||||
|
||||
func TestChatCompletionsDispatchesConfiguredOllamaTarget(t *testing.T) {
|
||||
fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)}
|
||||
fake.events <- &iop.RunEvent{Type: "delta", Delta: "hello"}
|
||||
|
|
@ -639,6 +672,351 @@ func TestChatCompletionsNormalizesNativeToolCallArgumentsForProviderRoute(t *tes
|
|||
}
|
||||
}
|
||||
|
||||
func TestChatCompletionsRetriesMalformedToolCallBeforeResponse(t *testing.T) {
|
||||
fake := &fakeRunService{
|
||||
eventRuns: []chan *iop.RunEvent{
|
||||
bufferedRunEvents(&iop.RunEvent{
|
||||
Type: "complete",
|
||||
Metadata: map[string]string{
|
||||
"finish_reason": "tool_calls",
|
||||
runtimeMetadataOpenAIToolCalls: `[{"id":"call_bad","type":"function","function":{"name":"run_commands","arguments":"{\"commands\":\"git status\"}"}}]`,
|
||||
},
|
||||
}),
|
||||
bufferedRunEvents(&iop.RunEvent{
|
||||
Type: "complete",
|
||||
Metadata: map[string]string{
|
||||
"finish_reason": "tool_calls",
|
||||
runtimeMetadataOpenAIToolCalls: `[{"id":"call_good","type":"function","function":{"name":"run_commands","arguments":"{\"commands\":[\"git status\"]}"}}]`,
|
||||
},
|
||||
}),
|
||||
},
|
||||
runIDs: []string{"run-bad", "run-good"},
|
||||
}
|
||||
|
||||
srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
||||
"model":"qwen3.6:35b",
|
||||
"messages":[{"role":"user","content":"status"}],
|
||||
"tools":[{"type":"function","function":{"name":"run_commands","parameters":{"type":"object","properties":{"commands":{"type":"array","items":{"type":"string"}}},"required":["commands"],"additionalProperties":false}}}],
|
||||
"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())
|
||||
}
|
||||
reqs := fake.reqsSnapshot()
|
||||
if len(reqs) != 2 {
|
||||
t.Fatalf("submit attempts: got %d want 2", len(reqs))
|
||||
}
|
||||
if reqs[0].Metadata[runtimeMetadataToolValidationAttempt] != "1" {
|
||||
t.Fatalf("first attempt metadata: %+v", reqs[0].Metadata)
|
||||
}
|
||||
if reqs[1].Metadata[runtimeMetadataToolValidationAttempt] != "2" || reqs[1].Metadata[runtimeMetadataToolValidationRetryOf] != "run-bad" {
|
||||
t.Fatalf("retry metadata: %+v", reqs[1].Metadata)
|
||||
}
|
||||
if !strings.Contains(reqs[1].Metadata[runtimeMetadataToolValidationFailure], "$.arguments.commands type string") {
|
||||
t.Fatalf("retry failure reason: %+v", reqs[1].Metadata)
|
||||
}
|
||||
|
||||
var resp chatCompletionResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if resp.ID != "chatcmpl-run-good" {
|
||||
t.Fatalf("response id: got %q", resp.ID)
|
||||
}
|
||||
call := resp.Choices[0].Message.ToolCalls[0].(map[string]any)
|
||||
if call["id"] != "call_good" {
|
||||
t.Fatalf("tool call id: got %+v", call["id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatCompletionsFailsMalformedToolCallAfterRetryLimit(t *testing.T) {
|
||||
invalidComplete := func(id string) *iop.RunEvent {
|
||||
return &iop.RunEvent{
|
||||
Type: "complete",
|
||||
Metadata: map[string]string{
|
||||
"finish_reason": "tool_calls",
|
||||
runtimeMetadataOpenAIToolCalls: fmt.Sprintf(`[{"id":%q,"type":"function","function":{"name":"run_commands","arguments":"{\"unexpected\":true}"}}]`, id),
|
||||
},
|
||||
}
|
||||
}
|
||||
fake := &fakeRunService{
|
||||
eventRuns: []chan *iop.RunEvent{
|
||||
bufferedRunEvents(invalidComplete("call_bad_1")),
|
||||
bufferedRunEvents(invalidComplete("call_bad_2")),
|
||||
},
|
||||
runIDs: []string{"run-bad-1", "run-bad-2"},
|
||||
}
|
||||
|
||||
srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
||||
"model":"qwen3.6:35b",
|
||||
"messages":[{"role":"user","content":"status"}],
|
||||
"tools":[{"type":"function","function":{"name":"run_commands","parameters":{"type":"object","properties":{"commands":{"type":"array","items":{"type":"string"}}},"required":["commands"],"additionalProperties":false}}}],
|
||||
"tool_choice":{"type":"function","function":{"name":"run_commands"}}
|
||||
}`))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
srv.handleChatCompletions(w, req)
|
||||
|
||||
if w.Code != http.StatusBadGateway {
|
||||
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), `"type":"tool_validation_error"`) || !strings.Contains(w.Body.String(), "$.arguments.commands is required") {
|
||||
t.Fatalf("expected tool validation error body, got %s", w.Body.String())
|
||||
}
|
||||
if reqs := fake.reqsSnapshot(); len(reqs) != 2 {
|
||||
t.Fatalf("submit attempts: got %d want 2", len(reqs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatCompletionsToolChoiceNoneSkipsRuntimeToolValidation(t *testing.T) {
|
||||
fake := &fakeRunService{events: make(chan *iop.RunEvent, 1)}
|
||||
fake.events <- &iop.RunEvent{
|
||||
Type: "complete",
|
||||
Metadata: map[string]string{
|
||||
"finish_reason": "tool_calls",
|
||||
runtimeMetadataOpenAIToolCalls: `[{"id":"call_1","type":"function","function":{"name":"unknown_tool","arguments":"not-json"}}]`,
|
||||
},
|
||||
}
|
||||
|
||||
srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
||||
"model":"qwen3.6:35b",
|
||||
"messages":[{"role":"user","content":"status"}],
|
||||
"tools":[{"type":"function","function":{"name":"run_commands","parameters":{"type":"object","properties":{"commands":{"type":"array","items":{"type":"string"}}},"required":["commands"]}}}],
|
||||
"tool_choice":"none"
|
||||
}`))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
srv.handleChatCompletions(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
reqs := fake.reqsSnapshot()
|
||||
if len(reqs) != 1 {
|
||||
t.Fatalf("submit attempts: got %d want 1", len(reqs))
|
||||
}
|
||||
if _, ok := reqs[0].Metadata[runtimeMetadataToolValidationAttempt]; ok {
|
||||
t.Fatalf("tool_choice none should not enable validation metadata: %+v", reqs[0].Metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatCompletionsLiveStreamSkipsRuntimeToolValidation(t *testing.T) {
|
||||
fake := &fakeRunService{events: make(chan *iop.RunEvent, 1)}
|
||||
fake.events <- &iop.RunEvent{
|
||||
Type: "complete",
|
||||
Metadata: map[string]string{
|
||||
"finish_reason": "tool_calls",
|
||||
runtimeMetadataOpenAIToolCalls: `[{"id":"call_bad","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":"qwen3.6:35b",
|
||||
"stream":true,
|
||||
"messages":[{"role":"user","content":"status"}],
|
||||
"tools":[{"type":"function","function":{"name":"run_commands","parameters":{"type":"object","properties":{"commands":{"type":"array","items":{"type":"string"}}},"required":["commands"],"additionalProperties":false}}}],
|
||||
"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())
|
||||
}
|
||||
reqs := fake.reqsSnapshot()
|
||||
if len(reqs) != 1 {
|
||||
t.Fatalf("submit attempts: got %d want 1 (live stream must not retry)", len(reqs))
|
||||
}
|
||||
if _, ok := reqs[0].Metadata[runtimeMetadataToolValidationAttempt]; ok {
|
||||
t.Fatalf("live stream should not attach validation attempt metadata: %+v", reqs[0].Metadata)
|
||||
}
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, `"tool_calls"`) || !strings.Contains(body, `"finish_reason":"tool_calls"`) || !strings.Contains(body, "data: [DONE]") {
|
||||
t.Fatalf("live stream did not preserve existing tool_calls semantics:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatCompletionsStrictBufferedStreamRetriesMalformedToolCallBeforeChunk(t *testing.T) {
|
||||
fake := &fakeRunService{
|
||||
eventRuns: []chan *iop.RunEvent{
|
||||
bufferedRunEvents(&iop.RunEvent{
|
||||
Type: "complete",
|
||||
Metadata: map[string]string{
|
||||
"finish_reason": "tool_calls",
|
||||
runtimeMetadataOpenAIToolCalls: `[{"id":"call_bad","type":"function","function":{"name":"run_commands","arguments":"{\"commands\":\"git status\"}"}}]`,
|
||||
},
|
||||
}),
|
||||
bufferedRunEvents(&iop.RunEvent{
|
||||
Type: "complete",
|
||||
Metadata: map[string]string{
|
||||
"finish_reason": "tool_calls",
|
||||
runtimeMetadataOpenAIToolCalls: `[{"id":"call_good","type":"function","function":{"name":"run_commands","arguments":"{\"commands\":[\"git status\"]}"}}]`,
|
||||
},
|
||||
}),
|
||||
},
|
||||
runIDs: []string{"run-bad", "run-good"},
|
||||
}
|
||||
|
||||
srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat", StrictOutput: true, StrictStreamBuffer: true}, fake, nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
||||
"model":"qwen3.6:35b",
|
||||
"stream":true,
|
||||
"messages":[{"role":"user","content":"status"}],
|
||||
"tools":[{"type":"function","function":{"name":"run_commands","parameters":{"type":"object","properties":{"commands":{"type":"array","items":{"type":"string"}}},"required":["commands"],"additionalProperties":false}}}],
|
||||
"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())
|
||||
}
|
||||
reqs := fake.reqsSnapshot()
|
||||
if len(reqs) != 2 {
|
||||
t.Fatalf("submit attempts: got %d want 2", len(reqs))
|
||||
}
|
||||
if reqs[0].Metadata[runtimeMetadataToolValidationAttempt] != "1" {
|
||||
t.Fatalf("first attempt metadata: %+v", reqs[0].Metadata)
|
||||
}
|
||||
if reqs[1].Metadata[runtimeMetadataToolValidationAttempt] != "2" || reqs[1].Metadata[runtimeMetadataToolValidationRetryOf] != "run-bad" {
|
||||
t.Fatalf("retry metadata: %+v", reqs[1].Metadata)
|
||||
}
|
||||
body := w.Body.String()
|
||||
if strings.Contains(body, "call_bad") {
|
||||
t.Fatalf("buffered stream leaked invalid tool call before retry:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, "call_good") || !strings.Contains(body, `"finish_reason":"tool_calls"`) || !strings.Contains(body, "data: [DONE]") {
|
||||
t.Fatalf("buffered stream did not emit validated tool call:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatCompletionsStrictBufferedStreamFailsMalformedToolCallAfterRetryLimit(t *testing.T) {
|
||||
invalidComplete := func(id string) *iop.RunEvent {
|
||||
return &iop.RunEvent{
|
||||
Type: "complete",
|
||||
Metadata: map[string]string{
|
||||
"finish_reason": "tool_calls",
|
||||
runtimeMetadataOpenAIToolCalls: fmt.Sprintf(`[{"id":%q,"type":"function","function":{"name":"run_commands","arguments":"{\"unexpected\":true}"}}]`, id),
|
||||
},
|
||||
}
|
||||
}
|
||||
fake := &fakeRunService{
|
||||
eventRuns: []chan *iop.RunEvent{
|
||||
bufferedRunEvents(invalidComplete("call_bad_1")),
|
||||
bufferedRunEvents(invalidComplete("call_bad_2")),
|
||||
},
|
||||
runIDs: []string{"run-bad-1", "run-bad-2"},
|
||||
}
|
||||
|
||||
srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat", StrictOutput: true, StrictStreamBuffer: true}, fake, nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
||||
"model":"qwen3.6:35b",
|
||||
"stream":true,
|
||||
"messages":[{"role":"user","content":"status"}],
|
||||
"tools":[{"type":"function","function":{"name":"run_commands","parameters":{"type":"object","properties":{"commands":{"type":"array","items":{"type":"string"}}},"required":["commands"],"additionalProperties":false}}}],
|
||||
"tool_choice":{"type":"function","function":{"name":"run_commands"}}
|
||||
}`))
|
||||
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, `"type":"tool_validation_error"`) || !strings.Contains(body, "$.arguments.commands is required") {
|
||||
t.Fatalf("expected tool_validation_error SSE body, got:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, "call_bad") {
|
||||
t.Fatalf("buffered stream leaked invalid tool call chunk after retry limit:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, "data: [DONE]") {
|
||||
t.Fatalf("buffered stream error should terminate with [DONE]:\n%s", body)
|
||||
}
|
||||
if reqs := fake.reqsSnapshot(); len(reqs) != 2 {
|
||||
t.Fatalf("submit attempts: got %d want 2", len(reqs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateJSONSchemaSubsetCoversObjectArrayEnumAdditionalProperties(t *testing.T) {
|
||||
schema := map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"mode": map[string]any{
|
||||
"type": "string",
|
||||
"enum": []any{"read", "write"},
|
||||
},
|
||||
"commands": map[string]any{
|
||||
"type": "array",
|
||||
"items": map[string]any{"type": "string"},
|
||||
},
|
||||
"options": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"dry_run": map[string]any{"type": "boolean"},
|
||||
},
|
||||
"additionalProperties": false,
|
||||
},
|
||||
},
|
||||
"required": []any{"mode", "commands"},
|
||||
"additionalProperties": false,
|
||||
}
|
||||
|
||||
valid := map[string]any{
|
||||
"mode": "read",
|
||||
"commands": []any{"git status"},
|
||||
"options": map[string]any{"dry_run": true},
|
||||
}
|
||||
if err := validateJSONSchemaSubset(valid, schema, "$.arguments"); err != nil {
|
||||
t.Fatalf("valid schema rejected: %v", err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
args map[string]any
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "enum",
|
||||
args: map[string]any{"mode": "delete", "commands": []any{"git status"}},
|
||||
want: "$.arguments.mode value",
|
||||
},
|
||||
{
|
||||
name: "array item type",
|
||||
args: map[string]any{"mode": "read", "commands": []any{1}},
|
||||
want: "$.arguments.commands[0] type integer does not match schema type string",
|
||||
},
|
||||
{
|
||||
name: "additional properties",
|
||||
args: map[string]any{"mode": "read", "commands": []any{"git status"}, "extra": true},
|
||||
want: "$.arguments.extra is not allowed",
|
||||
},
|
||||
{
|
||||
name: "nested additional properties",
|
||||
args: map[string]any{"mode": "read", "commands": []any{"git status"}, "options": map[string]any{"dry_run": true, "trace": true}},
|
||||
want: "$.arguments.options.trace is not allowed",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := validateJSONSchemaSubset(tc.args, schema, "$.arguments")
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("error got %v, want substring %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSynthesizesTemplateToolCallConvertsCommandObjectsToShellStrings(t *testing.T) {
|
||||
content := "확인합니다.\n\n{{run_commands(commands=[{'command': 'command -v git && echo \"found\" || echo \"not found\"', 'runInTerminal': True}])}}"
|
||||
tools := []any{map[string]any{
|
||||
|
|
@ -2206,7 +2584,7 @@ func TestStreamChatCompletionTimeoutSendsCancelRun(t *testing.T) {
|
|||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
srv.streamChatCompletion(w, req, chatCompletionRequest{Model: "llama3"}, handle, strictOutputPolicy{})
|
||||
srv.streamChatCompletion(w, req, chatCompletionRequest{Model: "llama3"}, edgeservice.SubmitRunRequest{}, handle, strictOutputPolicy{}, toolValidationContract{})
|
||||
|
||||
calls := fake.cancelCallsSnapshot()
|
||||
if len(calls) != 1 {
|
||||
|
|
|
|||
|
|
@ -13,9 +13,10 @@ import (
|
|||
edgeservice "iop/apps/edge/internal/service"
|
||||
)
|
||||
|
||||
func (s *Server) streamChatCompletion(w http.ResponseWriter, r *http.Request, req chatCompletionRequest, handle edgeservice.RunResult, outputPolicy strictOutputPolicy) {
|
||||
func (s *Server) streamChatCompletion(w http.ResponseWriter, r *http.Request, req chatCompletionRequest, submitReq edgeservice.SubmitRunRequest, handle edgeservice.RunResult, outputPolicy strictOutputPolicy, validation toolValidationContract) {
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
handle.Close()
|
||||
writeError(w, http.StatusInternalServerError, "streaming_not_supported", "response writer does not support streaming")
|
||||
return
|
||||
}
|
||||
|
|
@ -23,6 +24,17 @@ func (s *Server) streamChatCompletion(w http.ResponseWriter, r *http.Request, re
|
|||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
|
||||
// Strict buffered streams collect and validate the full response before
|
||||
// emitting any user-visible chunk, so they own handle lifecycle, retries,
|
||||
// and the initial role chunk internally.
|
||||
if outputPolicy.Strict && outputPolicy.StreamBuffer {
|
||||
s.streamBufferedChatCompletion(w, r, req, submitReq, handle, flusher, outputPolicy, validation)
|
||||
return
|
||||
}
|
||||
|
||||
// Live SSE may emit content deltas before the terminal event, so runtime
|
||||
// tool validation is excluded upstream; write the role chunk immediately.
|
||||
defer handle.Close()
|
||||
created := time.Now().Unix()
|
||||
id := "chatcmpl-" + handle.Dispatch().RunID
|
||||
model := responseModel(req.Model, handle.Dispatch().Target)
|
||||
|
|
@ -37,11 +49,6 @@ func (s *Server) streamChatCompletion(w http.ResponseWriter, r *http.Request, re
|
|||
}},
|
||||
})
|
||||
|
||||
if outputPolicy.Strict && outputPolicy.StreamBuffer {
|
||||
s.streamBufferedChatCompletion(w, r, handle, id, created, model, flusher, outputPolicy, req.Tools)
|
||||
return
|
||||
}
|
||||
|
||||
var contentBuilder strings.Builder
|
||||
var reasoningBuilder strings.Builder
|
||||
var emittedContent strings.Builder
|
||||
|
|
@ -301,100 +308,88 @@ func writeToolCallsDeltaSSE(w http.ResponseWriter, flusher http.Flusher, id stri
|
|||
})
|
||||
}
|
||||
|
||||
func (s *Server) streamBufferedChatCompletion(w http.ResponseWriter, r *http.Request, handle edgeservice.RunResult, id string, created int64, model string, flusher http.Flusher, outputPolicy strictOutputPolicy, tools []any) {
|
||||
text, reasoning, finishReason, nativeToolCalls, _, textToolFallback, err := collectRunResult(r.Context(), handle.Stream(), handle.WaitTimeout())
|
||||
if err != nil {
|
||||
s.cancelRunOnHTTPGiveUp(handle.Dispatch(), err)
|
||||
writeSSEError(w, flusher, err.Error())
|
||||
// streamBufferedChatCompletion collects the full run output, validates tool
|
||||
// calls against the request schema before emitting anything, and — when
|
||||
// validation fails on a run that has not yet written a user-visible chunk —
|
||||
// resubmits the same request as a bounded exact replay. Only a validated (or
|
||||
// validation-disabled) result reaches the SSE writer; a malformed tool call
|
||||
// that survives the retry budget is surfaced as an SSE error instead of a
|
||||
// successful tool_calls chunk.
|
||||
func (s *Server) streamBufferedChatCompletion(w http.ResponseWriter, r *http.Request, req chatCompletionRequest, submitReq edgeservice.SubmitRunRequest, handle edgeservice.RunResult, flusher http.Flusher, outputPolicy strictOutputPolicy, validation toolValidationContract) {
|
||||
attempt := 1
|
||||
for {
|
||||
result, err := collectChatCompletionOutput(r.Context(), req, handle, outputPolicy)
|
||||
if err != nil {
|
||||
s.cancelRunOnHTTPGiveUp(handle.Dispatch(), err)
|
||||
handle.Close()
|
||||
writeSSEError(w, flusher, err.Error())
|
||||
return
|
||||
}
|
||||
if verr := validateToolCallResponse(validation, result.toolCalls, result.toolCallOrigin); verr != nil {
|
||||
failedRunID := handle.Dispatch().RunID
|
||||
if attempt < maxToolValidationAttempts {
|
||||
handle.Close()
|
||||
attempt++
|
||||
retryReq := submitReq
|
||||
retryReq.Metadata = toolValidationAttemptMetadata(submitReq.Metadata, attempt, failedRunID, verr.Error())
|
||||
s.logger.Warn("openai chat completion stream tool validation retry",
|
||||
zap.String("run_id", failedRunID),
|
||||
zap.Int("attempt", attempt),
|
||||
zap.String("reason", verr.Error()),
|
||||
)
|
||||
next, submitErr := s.service.SubmitRun(r.Context(), retryReq)
|
||||
if submitErr != nil {
|
||||
writeSSEErrorWithType(w, flusher, "tool_validation_retry_error", submitErr.Error())
|
||||
return
|
||||
}
|
||||
handle = next
|
||||
continue
|
||||
}
|
||||
handle.Close()
|
||||
s.logger.Warn("openai chat completion stream tool validation failed",
|
||||
zap.String("run_id", failedRunID),
|
||||
zap.Int("attempt", attempt),
|
||||
zap.String("reason", verr.Error()),
|
||||
)
|
||||
writeSSEErrorWithType(w, flusher, "tool_validation_error", verr.Error())
|
||||
return
|
||||
}
|
||||
handle.Close()
|
||||
s.writeBufferedStreamOutput(w, flusher, req, handle.Dispatch(), result, outputPolicy)
|
||||
return
|
||||
}
|
||||
text, reasoning, normalized := normalizeCompletionOutput(outputPolicy, text, reasoning)
|
||||
}
|
||||
|
||||
// writeBufferedStreamOutput emits a validated buffered result as SSE chunks:
|
||||
// the role chunk, an optional content chunk, an optional tool_calls chunk, and
|
||||
// the terminal finish chunk.
|
||||
func (s *Server) writeBufferedStreamOutput(w http.ResponseWriter, flusher http.Flusher, req chatCompletionRequest, dispatch edgeservice.RunDispatch, result chatCompletionOutput, outputPolicy strictOutputPolicy) {
|
||||
created := time.Now().Unix()
|
||||
id := "chatcmpl-" + dispatch.RunID
|
||||
model := responseModel(req.Model, dispatch.Target)
|
||||
content := result.message.Content
|
||||
s.logger.Info("openai chat completion stream closed",
|
||||
zap.String("run_id", handle.Dispatch().RunID),
|
||||
zap.String("run_id", dispatch.RunID),
|
||||
zap.Bool("strict_output", outputPolicy.Strict),
|
||||
zap.Bool("strict_stream_buffer", outputPolicy.StreamBuffer),
|
||||
zap.String("xml_completion_tool", outputPolicy.XMLCompletionTool),
|
||||
zap.Bool("normalized", normalized),
|
||||
zap.Int("content_len", len(text)),
|
||||
zap.Int("reasoning_len", len(reasoning)),
|
||||
zap.String("content_preview", previewString(text, 1000)),
|
||||
zap.String("reasoning_preview", previewString(reasoning, 1000)),
|
||||
zap.Bool("normalized", result.normalized),
|
||||
zap.Int("content_len", len(content)),
|
||||
zap.Int("reasoning_len", result.reasoningLen),
|
||||
zap.String("content_preview", previewString(content, 1000)),
|
||||
)
|
||||
if len(nativeToolCalls) > 0 {
|
||||
nativeToolCalls = normalizeNativeToolCallsForResponse(nativeToolCalls, tools)
|
||||
if text != "" {
|
||||
writeSSE(w, flusher, chatCompletionChunk{
|
||||
ID: id,
|
||||
Object: "chat.completion.chunk",
|
||||
Created: created,
|
||||
Model: model,
|
||||
Choices: []chatCompletionChunkChoice{{
|
||||
Index: 0,
|
||||
Delta: chatDelta{Content: text},
|
||||
}},
|
||||
})
|
||||
}
|
||||
writeSSE(w, flusher, chatCompletionChunk{
|
||||
ID: id,
|
||||
Object: "chat.completion.chunk",
|
||||
Created: created,
|
||||
Model: model,
|
||||
Choices: []chatCompletionChunkChoice{{
|
||||
Index: 0,
|
||||
Delta: chatDelta{ToolCalls: toolCallsForStreamDelta(nativeToolCalls)},
|
||||
}},
|
||||
})
|
||||
finishReason = "tool_calls"
|
||||
} else if shouldSynthesizeTextToolCalls(handle.Dispatch(), textToolFallback) {
|
||||
cleaned, toolCalls := synthesizeToolCallsFromText(text, tools, handle.Dispatch().RunID)
|
||||
if len(toolCalls) > 0 {
|
||||
if cleaned != "" {
|
||||
writeSSE(w, flusher, chatCompletionChunk{
|
||||
ID: id,
|
||||
Object: "chat.completion.chunk",
|
||||
Created: created,
|
||||
Model: model,
|
||||
Choices: []chatCompletionChunkChoice{{
|
||||
Index: 0,
|
||||
Delta: chatDelta{Content: cleaned},
|
||||
}},
|
||||
})
|
||||
}
|
||||
writeSSE(w, flusher, chatCompletionChunk{
|
||||
ID: id,
|
||||
Object: "chat.completion.chunk",
|
||||
Created: created,
|
||||
Model: model,
|
||||
Choices: []chatCompletionChunkChoice{{
|
||||
Index: 0,
|
||||
Delta: chatDelta{ToolCalls: toolCallsForStreamDelta(toolCalls)},
|
||||
}},
|
||||
})
|
||||
finishReason = "tool_calls"
|
||||
} else if text != "" {
|
||||
writeSSE(w, flusher, chatCompletionChunk{
|
||||
ID: id,
|
||||
Object: "chat.completion.chunk",
|
||||
Created: created,
|
||||
Model: model,
|
||||
Choices: []chatCompletionChunkChoice{{
|
||||
Index: 0,
|
||||
Delta: chatDelta{Content: text},
|
||||
}},
|
||||
})
|
||||
}
|
||||
} else if text != "" {
|
||||
writeSSE(w, flusher, chatCompletionChunk{
|
||||
ID: id,
|
||||
Object: "chat.completion.chunk",
|
||||
Created: created,
|
||||
Model: model,
|
||||
Choices: []chatCompletionChunkChoice{{
|
||||
Index: 0,
|
||||
Delta: chatDelta{Content: text},
|
||||
}},
|
||||
})
|
||||
}
|
||||
writeSSE(w, flusher, chatCompletionChunk{
|
||||
ID: id,
|
||||
Object: "chat.completion.chunk",
|
||||
Created: created,
|
||||
Model: model,
|
||||
Choices: []chatCompletionChunkChoice{{
|
||||
Index: 0,
|
||||
Delta: chatDelta{Role: "assistant"},
|
||||
}},
|
||||
})
|
||||
writeContentDeltaSSE(w, flusher, id, created, model, content)
|
||||
writeToolCallsDeltaSSE(w, flusher, id, created, model, result.toolCalls)
|
||||
writeSSE(w, flusher, chatCompletionChunk{
|
||||
ID: id,
|
||||
Object: "chat.completion.chunk",
|
||||
|
|
@ -403,7 +398,7 @@ func (s *Server) streamBufferedChatCompletion(w http.ResponseWriter, r *http.Req
|
|||
Choices: []chatCompletionChunkChoice{{
|
||||
Index: 0,
|
||||
Delta: chatDelta{},
|
||||
FinishReason: finishReason,
|
||||
FinishReason: result.finishReason,
|
||||
}},
|
||||
})
|
||||
fmt.Fprint(w, "data: [DONE]\n\n")
|
||||
|
|
@ -420,7 +415,11 @@ func writeSSE(w http.ResponseWriter, flusher http.Flusher, v any) {
|
|||
}
|
||||
|
||||
func writeSSEError(w http.ResponseWriter, flusher http.Flusher, message string) {
|
||||
writeSSE(w, flusher, errorResponse{Error: errorBody{Type: "run_error", Message: message}})
|
||||
writeSSEErrorWithType(w, flusher, "run_error", message)
|
||||
}
|
||||
|
||||
func writeSSEErrorWithType(w http.ResponseWriter, flusher http.Flusher, errType, message string) {
|
||||
writeSSE(w, flusher, errorResponse{Error: errorBody{Type: errType, Message: message}})
|
||||
fmt.Fprint(w, "data: [DONE]\n\n")
|
||||
flusher.Flush()
|
||||
}
|
||||
|
|
|
|||
410
apps/edge/internal/openai/tool_validation.go
Normal file
410
apps/edge/internal/openai/tool_validation.go
Normal file
|
|
@ -0,0 +1,410 @@
|
|||
package openai
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
maxToolValidationAttempts = 2
|
||||
|
||||
runtimeMetadataToolValidationAttempt = "openai_tool_validation_attempt"
|
||||
runtimeMetadataToolValidationRetryOf = "openai_tool_validation_retry_of"
|
||||
runtimeMetadataToolValidationFailure = "openai_tool_validation_failure"
|
||||
|
||||
toolCallOriginNative = "native"
|
||||
toolCallOriginText = "text"
|
||||
)
|
||||
|
||||
type toolValidationContract struct {
|
||||
enabled bool
|
||||
specs map[string]textToolSpec
|
||||
}
|
||||
|
||||
type toolCallCandidate struct {
|
||||
name string
|
||||
arguments any
|
||||
origin string
|
||||
raw any
|
||||
}
|
||||
|
||||
func toolValidationContractFromRequest(req chatCompletionRequest) toolValidationContract {
|
||||
if len(req.Tools) == 0 || toolChoiceDisablesToolValidation(req.ToolChoice) {
|
||||
return toolValidationContract{}
|
||||
}
|
||||
specs := requestedToolSpecs(req.Tools)
|
||||
if len(specs) == 0 {
|
||||
return toolValidationContract{}
|
||||
}
|
||||
return toolValidationContract{enabled: true, specs: specs}
|
||||
}
|
||||
|
||||
// toolValidationContractForChatRequest resolves the runtime tool validation
|
||||
// contract for a request, accounting for its stream mode. Non-stream and
|
||||
// strict buffered stream responses validate tool calls before emitting any
|
||||
// user-visible payload, so validation stays enabled. Live SSE streams may
|
||||
// already emit content deltas before the terminal event, so exact retry is not
|
||||
// possible and runtime validation is explicitly excluded.
|
||||
func toolValidationContractForChatRequest(req chatCompletionRequest, outputPolicy strictOutputPolicy) toolValidationContract {
|
||||
contract := toolValidationContractFromRequest(req)
|
||||
if !contract.enabled || !req.Stream {
|
||||
return contract
|
||||
}
|
||||
if outputPolicy.Strict && outputPolicy.StreamBuffer {
|
||||
return contract
|
||||
}
|
||||
return toolValidationContract{}
|
||||
}
|
||||
|
||||
func toolChoiceDisablesToolValidation(toolChoice any) bool {
|
||||
choice, ok := toolChoice.(string)
|
||||
return ok && strings.EqualFold(strings.TrimSpace(choice), "none")
|
||||
}
|
||||
|
||||
func toolValidationAttemptMetadata(metadata map[string]string, attempt int, retryOf, failureReason string) map[string]string {
|
||||
out := make(map[string]string, len(metadata)+3)
|
||||
for key, value := range metadata {
|
||||
out[key] = value
|
||||
}
|
||||
out[runtimeMetadataToolValidationAttempt] = fmt.Sprintf("%d", attempt)
|
||||
if strings.TrimSpace(retryOf) != "" {
|
||||
out[runtimeMetadataToolValidationRetryOf] = retryOf
|
||||
}
|
||||
if strings.TrimSpace(failureReason) != "" {
|
||||
out[runtimeMetadataToolValidationFailure] = failureReason
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func validateToolCallResponse(contract toolValidationContract, toolCalls []any, origin string) error {
|
||||
if !contract.enabled || len(toolCalls) == 0 {
|
||||
return nil
|
||||
}
|
||||
candidates, err := toolCallCandidatesFromResponse(toolCalls, origin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return nil
|
||||
}
|
||||
return validateToolCallCandidate(contract, candidates[len(candidates)-1])
|
||||
}
|
||||
|
||||
func toolCallCandidatesFromResponse(toolCalls []any, origin string) ([]toolCallCandidate, error) {
|
||||
candidates := make([]toolCallCandidate, 0, len(toolCalls))
|
||||
for i, raw := range toolCalls {
|
||||
call, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("tool call %d is not an object", i)
|
||||
}
|
||||
fn, ok := call["function"].(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("tool call %d function is not an object", i)
|
||||
}
|
||||
name, _ := fn["name"].(string)
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("tool call %d function.name is required", i)
|
||||
}
|
||||
arguments, err := parseToolCallArguments(fn["arguments"])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tool call %d function.arguments: %w", i, err)
|
||||
}
|
||||
candidates = append(candidates, toolCallCandidate{
|
||||
name: name,
|
||||
arguments: arguments,
|
||||
origin: origin,
|
||||
raw: raw,
|
||||
})
|
||||
}
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
func parseToolCallArguments(raw any) (any, error) {
|
||||
switch v := raw.(type) {
|
||||
case string:
|
||||
trimmed := strings.TrimSpace(v)
|
||||
if trimmed == "" {
|
||||
return nil, fmt.Errorf("must be valid JSON")
|
||||
}
|
||||
var decoded any
|
||||
if err := json.Unmarshal([]byte(trimmed), &decoded); err != nil {
|
||||
return nil, fmt.Errorf("must be valid JSON: %w", err)
|
||||
}
|
||||
return decoded, nil
|
||||
case map[string]any:
|
||||
return v, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("must be a JSON string or object")
|
||||
}
|
||||
}
|
||||
|
||||
func validateToolCallCandidate(contract toolValidationContract, candidate toolCallCandidate) error {
|
||||
spec, ok := contract.specs[candidate.name]
|
||||
if !ok {
|
||||
return fmt.Errorf("tool %q is not in request tools", candidate.name)
|
||||
}
|
||||
if err := validateJSONSchemaSubset(candidate.arguments, spec.parameters, "$.arguments"); err != nil {
|
||||
return fmt.Errorf("tool %q %s", candidate.name, err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateJSONSchemaSubset(value any, schema any, path string) error {
|
||||
m, ok := schema.(map[string]any)
|
||||
if !ok || len(m) == 0 {
|
||||
return nil
|
||||
}
|
||||
if branches := schemaBranches(m["anyOf"]); len(branches) > 0 {
|
||||
return validateAnySchemaBranch(value, branches, path, "anyOf")
|
||||
}
|
||||
if branches := schemaBranches(m["oneOf"]); len(branches) > 0 {
|
||||
return validateAnySchemaBranch(value, branches, path, "oneOf")
|
||||
}
|
||||
for _, branch := range schemaBranches(m["allOf"]) {
|
||||
if err := validateJSONSchemaSubset(value, branch, path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if values := schemaEnumValues(m["enum"]); len(values) > 0 && !valueInJSONEnum(value, values) {
|
||||
return fmt.Errorf("%s value %s is not in enum", path, jsonValuePreview(value))
|
||||
}
|
||||
types := schemaTypeNames(m["type"])
|
||||
if len(types) > 0 && !jsonValueMatchesAnyType(value, types) {
|
||||
return fmt.Errorf("%s type %s does not match schema type %s", path, jsonValueTypeName(value), strings.Join(types, "|"))
|
||||
}
|
||||
if shouldValidateObjectSubset(value, m, types) {
|
||||
obj, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s type %s does not match schema type object", path, jsonValueTypeName(value))
|
||||
}
|
||||
if err := validateJSONObjectSubset(obj, m, path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if shouldValidateArraySubset(value, m, types) {
|
||||
items, ok := value.([]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s type %s does not match schema type array", path, jsonValueTypeName(value))
|
||||
}
|
||||
if itemSchema := schemaArrayItemSchema(m); itemSchema != nil {
|
||||
for i, item := range items {
|
||||
if err := validateJSONSchemaSubset(item, itemSchema, fmt.Sprintf("%s[%d]", path, i)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAnySchemaBranch(value any, branches []any, path, branchType string) error {
|
||||
var reasons []string
|
||||
for _, branch := range branches {
|
||||
if err := validateJSONSchemaSubset(value, branch, path); err == nil {
|
||||
return nil
|
||||
} else {
|
||||
reasons = append(reasons, err.Error())
|
||||
}
|
||||
}
|
||||
sort.Strings(reasons)
|
||||
if len(reasons) == 0 {
|
||||
return fmt.Errorf("%s does not match %s", path, branchType)
|
||||
}
|
||||
return fmt.Errorf("%s does not match %s: %s", path, branchType, reasons[0])
|
||||
}
|
||||
|
||||
func shouldValidateObjectSubset(value any, schema map[string]any, types []string) bool {
|
||||
return containsString(types, "object") || len(schemaObjectProperties(schema)) > 0 || len(schemaStringList(schema["required"])) > 0 || isAdditionalPropertiesFalse(schema)
|
||||
}
|
||||
|
||||
func validateJSONObjectSubset(obj map[string]any, schema map[string]any, path string) error {
|
||||
props := schemaObjectProperties(schema)
|
||||
for _, key := range schemaStringList(schema["required"]) {
|
||||
if _, ok := obj[key]; !ok {
|
||||
return fmt.Errorf("%s.%s is required", path, key)
|
||||
}
|
||||
}
|
||||
if isAdditionalPropertiesFalse(schema) {
|
||||
for key := range obj {
|
||||
if _, ok := props[key]; !ok {
|
||||
return fmt.Errorf("%s.%s is not allowed by additionalProperties=false", path, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
for key, propSchema := range props {
|
||||
child, ok := obj[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if err := validateJSONSchemaSubset(child, propSchema, path+"."+key); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func shouldValidateArraySubset(value any, schema map[string]any, types []string) bool {
|
||||
return containsString(types, "array") || schemaArrayItemSchema(schema) != nil
|
||||
}
|
||||
|
||||
func isAdditionalPropertiesFalse(schema map[string]any) bool {
|
||||
allowed, ok := schema["additionalProperties"].(bool)
|
||||
return ok && !allowed
|
||||
}
|
||||
|
||||
func schemaTypeNames(value any) []string {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
if strings.TrimSpace(v) == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{strings.TrimSpace(v)}
|
||||
case []any:
|
||||
out := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
if s, ok := item.(string); ok && strings.TrimSpace(s) != "" {
|
||||
out = append(out, strings.TrimSpace(s))
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func schemaStringList(value any) []string {
|
||||
raw, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
if s, ok := item.(string); ok && strings.TrimSpace(s) != "" {
|
||||
out = append(out, strings.TrimSpace(s))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func schemaEnumValues(value any) []any {
|
||||
raw, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func valueInJSONEnum(value any, allowed []any) bool {
|
||||
for _, item := range allowed {
|
||||
if jsonValuesEqual(value, item) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func jsonValuesEqual(a, b any) bool {
|
||||
encodedA, errA := json.Marshal(a)
|
||||
encodedB, errB := json.Marshal(b)
|
||||
return errA == nil && errB == nil && string(encodedA) == string(encodedB)
|
||||
}
|
||||
|
||||
func jsonValueMatchesAnyType(value any, types []string) bool {
|
||||
for _, t := range types {
|
||||
if jsonValueMatchesType(value, t) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func jsonValueMatchesType(value any, typ string) bool {
|
||||
switch typ {
|
||||
case "object":
|
||||
_, ok := value.(map[string]any)
|
||||
return ok
|
||||
case "array":
|
||||
_, ok := value.([]any)
|
||||
return ok
|
||||
case "string":
|
||||
_, ok := value.(string)
|
||||
return ok
|
||||
case "boolean":
|
||||
_, ok := value.(bool)
|
||||
return ok
|
||||
case "number":
|
||||
return isJSONNumber(value)
|
||||
case "integer":
|
||||
return isJSONInteger(value)
|
||||
case "null":
|
||||
return value == nil
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func isJSONNumber(value any) bool {
|
||||
switch value.(type) {
|
||||
case float64, float32, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isJSONInteger(value any) bool {
|
||||
switch v := value.(type) {
|
||||
case float64:
|
||||
return math.Trunc(v) == v
|
||||
case float32:
|
||||
return math.Trunc(float64(v)) == float64(v)
|
||||
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func jsonValueTypeName(value any) string {
|
||||
switch value.(type) {
|
||||
case nil:
|
||||
return "null"
|
||||
case map[string]any:
|
||||
return "object"
|
||||
case []any:
|
||||
return "array"
|
||||
case string:
|
||||
return "string"
|
||||
case bool:
|
||||
return "boolean"
|
||||
default:
|
||||
if isJSONInteger(value) {
|
||||
return "integer"
|
||||
}
|
||||
if isJSONNumber(value) {
|
||||
return "number"
|
||||
}
|
||||
return fmt.Sprintf("%T", value)
|
||||
}
|
||||
}
|
||||
|
||||
func jsonValuePreview(value any) string {
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%v", value)
|
||||
}
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
func containsString(values []string, want string) bool {
|
||||
for _, value := range values {
|
||||
if value == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
BIN
openai.test
Executable file
BIN
openai.test
Executable file
Binary file not shown.
Loading…
Reference in a new issue