diff --git a/agent-task/m-openai-compatible-think-control/02+01_node_provider_mapping/CODE_REVIEW-local-G05.md b/agent-task/m-openai-compatible-think-control/02+01_node_provider_mapping/CODE_REVIEW-local-G05.md deleted file mode 100644 index 342d06d..0000000 --- a/agent-task/m-openai-compatible-think-control/02+01_node_provider_mapping/CODE_REVIEW-local-G05.md +++ /dev/null @@ -1,105 +0,0 @@ - - -# 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. -> Start only after predecessor `01_edge_request_contract` has `complete.log`. -> Fill implementation-owned sections, then stop with active files in place and report ready for review. Finalization is review-agent-only. - -## 개요 - -date=2026-07-03 -task=m-openai-compatible-think-control/02+01_node_provider_mapping, plan=0, tag=API - -## Roadmap Targets - -- Milestone: `agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/openai-compatible-think-control.md` -- Task ids: - - `provider-mapping`: Edge run input과 Node `openai_compat` adapter가 요청별 think 설정을 provider별 top-level field 또는 내부 `chat_template_kwargs.enable_thinking`으로 매핑한다. - - `default-compat`: think 관련 필드가 생략된 기존 요청은 현재 provider 기본 thinking 동작을 유지한다. -- Completion mode: check-on-pass - -## 이 파일을 읽는 리뷰 에이전트에게 - -> **[REVIEW AGENT ONLY]** 구현 에이전트는 종결 절차를 실행하지 않는다. - -실제 provider body mapping, omitted-field absence, predecessor dependency 준수, 검증 출력 일치를 확인한다. - ---- - -## 구현 항목별 완료 여부 - -| 항목 | 완료 여부 | -|------|---------| -| [API-1] Provider-Specific Think Body Mapping | [ ] | -| [API-2] Default Compatibility Regression | [ ] | - -## 구현 체크리스트 - -- [ ] `01_edge_request_contract` predecessor `complete.log`를 확인한 뒤 시작한다. -- [ ] `openai_compat` adapter request body builder가 provider label을 받아 think-control input을 provider별 body로 매핑하도록 바꾼다. 검증: vLLM Qwen3, vLLM-MLX, Lemonade mock이 같은 요청 플래그에 대해 기대 request body를 받는다. -- [ ] omitted think-control field가 `think`, `reasoning_effort`, `thinking_token_budget`, `chat_template_kwargs`를 만들지 않는 회귀 테스트를 추가한다. 검증: 기존 chat/tool/provider pool targeted tests가 통과한다. -- [ ] `go test -count=1 ./apps/node/internal/adapters/openai_compat ./apps/edge/internal/openai`를 실행한다. -- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. - -## 코드리뷰 전용 체크리스트 - -- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. -- [ ] active plan/review 파일을 `.log`로 아카이브한다. -- [ ] PASS이면 `complete.log` 작성 후 active task 디렉터리를 archive로 이동한다. -- [ ] PASS이고 task group이 `m-`이면 완료 이벤트 메타데이터를 보고하고 roadmap을 직접 수정하지 않는다. -- [ ] WARN/FAIL이면 다음 active plan/review 파일 또는 정당한 `USER_REVIEW.md`를 작성한다. - -## 계획 대비 변경 사항 - -_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._ - -## 주요 설계 결정 - -_구현 에이전트가 주요 설계 결정 사항을 기록한다._ - -## 사용자 리뷰 요청 - -_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._ - -- 상태: 없음 -- 사유 유형: 없음 -- 연결 대상: 없음 -- 결정 필요: 없음 -- 차단 근거: 없음 -- 실행한 검증/명령: 없음 -- 자동 후속 불가 이유: 없음 -- 재개 조건: 없음 - -## 리뷰어를 위한 체크포인트 - -- vLLM/vLLM-MLX body가 raw Edge wrapper 없이 adapter 내부 `chat_template_kwargs`를 생성하는지 확인한다. -- Lemonade mapping과 omitted default absence가 별도 테스트되는지 확인한다. -- `tools`/`tool_choice` fallback behavior가 깨지지 않았는지 확인한다. - -## 검증 결과 - -_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ - -### API-1 중간 검증 -```bash -$ go test -count=1 ./apps/node/internal/adapters/openai_compat -(output) -``` - -### API-2 중간 검증 -```bash -$ go test -count=1 ./apps/node/internal/adapters/openai_compat ./apps/edge/internal/openai -(output) -``` - -### 최종 검증 -```bash -$ go test -count=1 ./apps/node/internal/adapters/openai_compat ./apps/edge/internal/openai -(output) -``` - ---- - -> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?** diff --git a/agent-task/m-openai-compatible-think-control/02+01_node_provider_mapping/PLAN-local-G05.md b/agent-task/m-openai-compatible-think-control/02+01_node_provider_mapping/PLAN-local-G05.md deleted file mode 100644 index ba1cf8a..0000000 --- a/agent-task/m-openai-compatible-think-control/02+01_node_provider_mapping/PLAN-local-G05.md +++ /dev/null @@ -1,217 +0,0 @@ - - -# Plan - API Node Provider Mapping - -## 이 파일을 읽는 구현 에이전트에게 - -`01_edge_request_contract`의 `complete.log`가 있어야 이 plan을 구현한다. 구현 완료는 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션 작성까지다. Milestone lock decision이 막으면 review stub의 `사용자 리뷰 요청`에만 기록하고, 직접 사용자에게 질문하거나 archive/complete 파일을 만들지 않는다. - -## 배경 - -Edge가 공개 think-control 필드를 내부 input map으로 전달해도 Node adapter가 provider별 request body로 매핑하지 않으면 실제 generation 제어가 동작하지 않는다. 현재 `openai_compat` adapter는 `think`를 단순 top-level passthrough하고, provider label별 `chat_template_kwargs.enable_thinking` 매핑이 없다. 이 split은 vLLM, vLLM-MLX, Lemonade mock request body를 고정하고 omitted field 회귀를 같이 닫는다. - -## 사용자 리뷰 요청 흐름 - -사용자 리뷰 요청은 선택된 Milestone lock decision이 실구현을 막을 때만 review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 환경/secret/provider 접근 실패는 사용자 리뷰가 아니라 검증 blocker 또는 follow-up이다. - -## Roadmap Targets - -- Milestone: `agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/openai-compatible-think-control.md` -- Task ids: - - `provider-mapping`: Edge run input과 Node `openai_compat` adapter가 요청별 think 설정을 provider별 top-level field 또는 내부 `chat_template_kwargs.enable_thinking`으로 매핑한다. - - `default-compat`: think 관련 필드가 생략된 기존 요청은 현재 provider 기본 thinking 동작을 유지한다. -- Completion mode: check-on-pass - -## 분석 결과 - -### 읽은 파일 - -- `agent-roadmap/current.md` -- `agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/openai-compatible-think-control.md` -- `agent-roadmap/sdd/knowledge-tool-optimization-extension/openai-compatible-think-control/SDD.md` -- `agent-ops/rules/project/domain/node/rules.md` -- `agent-ops/rules/project/domain/testing/rules.md` -- `agent-test/local/rules.md` -- `agent-test/local/node-smoke.md` -- `agent-test/local/edge-smoke.md` -- `apps/node/internal/adapters/openai_compat/openai_compat.go` -- `apps/node/internal/adapters/openai_compat/openai_compat_test.go` -- `apps/edge/internal/openai/types.go` -- `apps/edge/internal/openai/server_test.go` - -### SDD 기준 - -- SDD: `agent-roadmap/sdd/knowledge-tool-optimization-extension/openai-compatible-think-control/SDD.md` -- 상태: `[승인됨]`, SDD 잠금: `해제`, 사용자 리뷰: 없음 -- Acceptance Scenario: S02 -> `provider-mapping`, S04 -> `default-compat` -- Evidence Map: S02 requires Node request body unit tests with provider mocks; S04 requires existing chat/tool/provider-pool regression evidence. -- 이 plan은 provider body mock tests를 completion evidence로 삼는다. External three-provider smoke is excluded to `05+01,02,03,04_dev_smoke`. - -### 테스트 환경 규칙 - -- test_env: `local` -- 읽은 env/profile: `agent-test/local/rules.md`, `agent-test/local/node-smoke.md`, `agent-test/local/edge-smoke.md` -- 적용 명령: `go test -count=1 ./apps/node/internal/adapters/openai_compat ./apps/edge/internal/openai` -- baseline 확인: `go test -count=1 ./apps/edge/internal/openai ./apps/node/internal/adapters/openai_compat` PASS -- 외부 provider preflight: 이 split은 mock HTTP server만 사용하므로 없음. - -### 테스트 커버리지 공백 - -- `think=false` provider mapping: current tests do not assert `chat_template_kwargs.enable_thinking=false` for vLLM/vLLM-MLX or top-level Lemonade mapping. -- `think=true` and `reasoning_effort=none`: no provider body test. -- Omitted field default compatibility: existing tests check generic options/tool passthrough but not absence of `think`/`chat_template_kwargs`. - -### 심볼 참조 - -- renamed/removed symbol: none. If `buildRequestBody` signature changes, update its call at `apps/node/internal/adapters/openai_compat/openai_compat.go:131` and tests in `apps/node/internal/adapters/openai_compat/openai_compat_test.go`. - -### 분할 판단 - -- shared task group: `agent-task/m-openai-compatible-think-control/` -- current subtask: `02+01_node_provider_mapping` -- dependency from directory name: predecessor `01_edge_request_contract` -- predecessor completion: missing in active and archive lookup. Implementation must wait for `agent-task/m-openai-compatible-think-control/01_edge_request_contract/complete.log` or matching archive complete log. -- split reason: provider mapping touches Node adapter and mock provider semantics; Edge response visibility and docs have separate ownership/risk. - -### 범위 결정 근거 - -- Edge request decoder is excluded except for running its targeted tests after dependency completion. -- Edge response `reasoning_content` filtering is excluded to `03+01_edge_reasoning_visibility`. -- Real dev-runtime provider validation is excluded to `05+01,02,03,04_dev_smoke`. - -### 빌드 등급 - -- `local-G05`: bounded Node adapter change, but provider-specific compatibility matrix and default-preservation tests raise risk. - -## 구현 체크리스트 - -- [ ] `01_edge_request_contract` predecessor `complete.log`를 확인한 뒤 시작한다. -- [ ] `openai_compat` adapter request body builder가 provider label을 받아 think-control input을 provider별 body로 매핑하도록 바꾼다. 검증: vLLM Qwen3, vLLM-MLX, Lemonade mock이 같은 요청 플래그에 대해 기대 request body를 받는다. -- [ ] omitted think-control field가 `think`, `reasoning_effort`, `thinking_token_budget`, `chat_template_kwargs`를 만들지 않는 회귀 테스트를 추가한다. 검증: 기존 chat/tool/provider pool targeted tests가 통과한다. -- [ ] `go test -count=1 ./apps/node/internal/adapters/openai_compat ./apps/edge/internal/openai`를 실행한다. -- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. - -## 의존 관계 및 구현 순서 - -- `02+01_node_provider_mapping` decodes to `depends_on=[01]`. -- Do not implement until predecessor `01_edge_request_contract` has a `complete.log`. - -### [API-1] Provider-Specific Think Body Mapping - -#### 문제 - -- [apps/node/internal/adapters/openai_compat/openai_compat.go](/config/workspace/iop/apps/node/internal/adapters/openai_compat/openai_compat.go:36)의 adapter는 `provider` label을 갖고 있지만 body builder가 이를 사용하지 않는다. -- [apps/node/internal/adapters/openai_compat/openai_compat.go](/config/workspace/iop/apps/node/internal/adapters/openai_compat/openai_compat.go:440)의 `buildRequestBody`는 `think`만 top-level passthrough한다. - -Before: - -```go -// apps/node/internal/adapters/openai_compat/openai_compat.go:440 -func buildRequestBody(model string, messages []chatMessage, input map[string]any) map[string]any { - body := make(map[string]any) - ... - for _, key := range []string{"tools", "tool_choice", "format", "think", "keep_alive"} { - if v, ok := input[key]; ok { - body[key] = v - } - } - body["model"] = model - body["messages"] = messages - body["stream"] = true - return body -} -``` - -After: - -```go -// apps/node/internal/adapters/openai_compat/openai_compat.go -reqBody, err := a.buildRequestBody(model, messages, spec.Input) -if err != nil { - return err -} -``` - -Provider mapping baseline: - -| Provider label | `think=false` / `reasoning_effort=none` | `think=true` | Budget/effort | -|---|---|---|---| -| `vllm`, `vllm-mlx` | `chat_template_kwargs.enable_thinking=false` | `chat_template_kwargs.enable_thinking=true` | place supported budget/effort under `chat_template_kwargs`; unsupported values return adapter error with `unsupported think control` | -| `lemonade` | top-level `think=false` | top-level `think=true` | top-level `reasoning_effort` / `thinking_token_budget` if present | -| empty/unknown | keep current top-level `think` passthrough for compatibility | same | top-level passthrough for explicit fields | - -#### 해결 방법 - -- Convert `buildRequestBody` into an adapter method or pass provider label explicitly. -- Merge `chat_template_kwargs` only inside the adapter; do not expose raw wrapper at Edge. -- Preserve `tools`, `tool_choice`, `format`, `keep_alive` existing behavior unless tests show a regression. -- Return a clear adapter error before HTTP for explicitly unsupported provider/value combinations. - -#### 수정 파일 및 체크리스트 - -- [ ] `apps/node/internal/adapters/openai_compat/openai_compat.go`: provider-aware body builder and helper functions. -- [ ] `apps/node/internal/adapters/openai_compat/openai_compat_test.go`: provider body assertions for vLLM, vLLM-MLX, Lemonade, omitted default. - -#### 테스트 작성 - -- Add `TestOpenAICompatExecuteMapsThinkFalseForVLLMChatTemplateKwargs`. -- Add `TestOpenAICompatExecuteMapsThinkFalseForVLLMMLXChatTemplateKwargs`. -- Add `TestOpenAICompatExecuteMapsThinkFalseForLemonadeTopLevel`. -- Add `TestOpenAICompatExecuteOmitsThinkControlWhenRequestOmitsIt`. -- Add/extend tests for `reasoning_effort=none`, `think=true`, and explicit unsupported budget/effort behavior. - -#### 중간 검증 - -```bash -go test -count=1 ./apps/node/internal/adapters/openai_compat -``` - -Expected: PASS. - -### [API-2] Default Compatibility Regression - -#### 문제 - -SDD S04 requires omitted think fields to preserve existing provider default thinking behavior. Current body tests do not assert that omitted requests avoid `think` and `chat_template_kwargs`. - -#### 해결 방법 - -- In the omitted-field test, decode the provider request body and assert absence of: - - `think` - - `reasoning_effort` - - `thinking_token_budget` - - `chat_template_kwargs` -- Keep existing options/tool tests passing. - -#### 수정 파일 및 체크리스트 - -- [ ] `apps/node/internal/adapters/openai_compat/openai_compat_test.go`: absence assertions. - -#### 테스트 작성 - -- Covered by `TestOpenAICompatExecuteOmitsThinkControlWhenRequestOmitsIt`. - -#### 중간 검증 - -```bash -go test -count=1 ./apps/node/internal/adapters/openai_compat ./apps/edge/internal/openai -``` - -Expected: PASS. - -## 수정 파일 요약 - -| 파일 | 항목 | -|------|------| -| `apps/node/internal/adapters/openai_compat/openai_compat.go` | API-1 | -| `apps/node/internal/adapters/openai_compat/openai_compat_test.go` | API-1, API-2 | - -## 최종 검증 - -```bash -go test -count=1 ./apps/node/internal/adapters/openai_compat ./apps/edge/internal/openai -``` - -Expected: PASS. Fresh execution is required; do not rely on Go test cache. - -모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/m-openai-compatible-think-control/03+01_edge_reasoning_visibility/CODE_REVIEW-local-G04.md b/agent-task/m-openai-compatible-think-control/03+01_edge_reasoning_visibility/CODE_REVIEW-local-G04.md deleted file mode 100644 index 557511f..0000000 --- a/agent-task/m-openai-compatible-think-control/03+01_edge_reasoning_visibility/CODE_REVIEW-local-G04.md +++ /dev/null @@ -1,104 +0,0 @@ - - -# Code Review Reference - API - -> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.** -> Start only after predecessor `01_edge_request_contract` has `complete.log`. -> Fill all implementation-owned sections and verification output, then stop with active files in place. Finalization is review-agent-only. - -## 개요 - -date=2026-07-03 -task=m-openai-compatible-think-control/03+01_edge_reasoning_visibility, plan=0, tag=API - -## Roadmap Targets - -- Milestone: `agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/openai-compatible-think-control.md` -- Task ids: - - `reasoning-visibility`: `include_reasoning=false`가 provider reasoning 생성 여부와 별개로 OpenAI-compatible 응답의 `reasoning_content` 노출을 억제한다. -- Completion mode: check-on-pass - -## 이 파일을 읽는 리뷰 에이전트에게 - -> **[REVIEW AGENT ONLY]** 구현 에이전트는 종결 절차를 실행하지 않는다. - -stream/non-stream suppression, default exposure regression, tool/content preservation, 검증 출력 일치를 확인한다. - ---- - -## 구현 항목별 완료 여부 - -| 항목 | 완료 여부 | -|------|---------| -| [API-1] Non-Streaming Reasoning Suppression | [ ] | -| [API-2] Streaming Reasoning Suppression | [ ] | - -## 구현 체크리스트 - -- [ ] `01_edge_request_contract` predecessor `complete.log`를 확인한 뒤 시작한다. -- [ ] non-streaming completion에서 `include_reasoning=false`이면 `message.reasoning_content`를 비우되 `content`, `tool_calls`, `finish_reason`, usage는 유지한다. 검증: non-stream 응답에서 reasoning field가 빠지고 content/tool output은 유지된다. -- [ ] live SSE stream에서 `include_reasoning=false`이면 `delta.reasoning_content` chunk를 쓰지 않되 content/tool chunks와 `[DONE]`은 유지한다. 검증: stream 응답에서 reasoning delta가 빠지고 content/tool output은 유지된다. -- [ ] default/omitted `include_reasoning`은 기존 reasoning exposure tests를 유지한다. -- [ ] `go test -count=1 ./apps/edge/internal/openai`를 실행한다. -- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. - -## 코드리뷰 전용 체크리스트 - -- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. -- [ ] active plan/review 파일을 `.log`로 아카이브한다. -- [ ] PASS이면 `complete.log` 작성 후 active task 디렉터리를 archive로 이동한다. -- [ ] PASS이고 task group이 `m-`이면 완료 이벤트 메타데이터를 보고하고 roadmap을 직접 수정하지 않는다. -- [ ] WARN/FAIL이면 다음 active plan/review 파일 또는 정당한 `USER_REVIEW.md`를 작성한다. - -## 계획 대비 변경 사항 - -_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._ - -## 주요 설계 결정 - -_구현 에이전트가 주요 설계 결정 사항을 기록한다._ - -## 사용자 리뷰 요청 - -_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._ - -- 상태: 없음 -- 사유 유형: 없음 -- 연결 대상: 없음 -- 결정 필요: 없음 -- 차단 근거: 없음 -- 실행한 검증/명령: 없음 -- 자동 후속 불가 이유: 없음 -- 재개 조건: 없음 - -## 리뷰어를 위한 체크포인트 - -- `include_reasoning=false`가 response-only filter인지 확인한다. -- content/tool output과 `[DONE]`이 suppression 중에도 유지되는지 확인한다. -- omitted `include_reasoning`의 기존 reasoning exposure tests가 계속 통과하는지 확인한다. - -## 검증 결과 - -_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ - -### API-1 중간 검증 -```bash -$ go test -count=1 ./apps/edge/internal/openai -(output) -``` - -### API-2 중간 검증 -```bash -$ go test -count=1 ./apps/edge/internal/openai -(output) -``` - -### 최종 검증 -```bash -$ go test -count=1 ./apps/edge/internal/openai -(output) -``` - ---- - -> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?** diff --git a/agent-task/m-openai-compatible-think-control/03+01_edge_reasoning_visibility/PLAN-local-G04.md b/agent-task/m-openai-compatible-think-control/03+01_edge_reasoning_visibility/PLAN-local-G04.md deleted file mode 100644 index 87fcebb..0000000 --- a/agent-task/m-openai-compatible-think-control/03+01_edge_reasoning_visibility/PLAN-local-G04.md +++ /dev/null @@ -1,219 +0,0 @@ - - -# Plan - API Edge Reasoning Visibility - -## 이 파일을 읽는 구현 에이전트에게 - -`01_edge_request_contract`의 `complete.log`가 있어야 이 plan을 구현한다. 구현 완료는 active `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 내용과 검증 출력으로 채운 상태다. review finalization, archive, `complete.log` 작성은 code-review 전용이다. - -## 배경 - -SDD는 `include_reasoning=false`를 generation control이 아니라 OpenAI-compatible response exposure control로 정의했다. 현재 Edge는 non-streaming `message.reasoning_content`와 live SSE `delta.reasoning_content`를 기본으로 노출한다. 요청자가 reasoning 생성을 유지하면서 노출만 숨길 수 있도록 response writer 단에서 필터를 추가해야 한다. - -## 사용자 리뷰 요청 흐름 - -선택된 Milestone lock decision이 실구현을 막을 때만 review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 외부 환경이나 검증 evidence 공백은 사용자 리뷰가 아니라 blocker/follow-up으로 기록한다. - -## Roadmap Targets - -- Milestone: `agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/openai-compatible-think-control.md` -- Task ids: - - `reasoning-visibility`: `include_reasoning=false`가 provider reasoning 생성 여부와 별개로 OpenAI-compatible 응답의 `reasoning_content` 노출을 억제한다. -- Completion mode: check-on-pass - -## 분석 결과 - -### 읽은 파일 - -- `agent-roadmap/current.md` -- `agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/openai-compatible-think-control.md` -- `agent-roadmap/sdd/knowledge-tool-optimization-extension/openai-compatible-think-control/SDD.md` -- `agent-ops/rules/project/domain/edge/rules.md` -- `agent-ops/rules/project/domain/testing/rules.md` -- `agent-test/local/rules.md` -- `agent-test/local/edge-smoke.md` -- `apps/edge/internal/openai/types.go` -- `apps/edge/internal/openai/chat_handler.go` -- `apps/edge/internal/openai/stream.go` -- `apps/edge/internal/openai/server_test.go` - -### SDD 기준 - -- SDD: `agent-roadmap/sdd/knowledge-tool-optimization-extension/openai-compatible-think-control/SDD.md` -- 상태: `[승인됨]`, SDD 잠금: `해제`, 사용자 리뷰: 없음 -- Acceptance Scenario: S03 -> `reasoning-visibility` -- Evidence Map: S03 requires stream/non-stream response filtering tests and `Roadmap Completion` evidence for `reasoning-visibility`. - -### 테스트 환경 규칙 - -- test_env: `local` -- 읽은 env/profile: `agent-test/local/rules.md`, `agent-test/local/edge-smoke.md` -- 적용 명령: `go test -count=1 ./apps/edge/internal/openai` -- baseline 확인: `go test -count=1 ./apps/edge/internal/openai ./apps/node/internal/adapters/openai_compat` PASS -- 외부 runner preflight: 이 split은 current checkout unit test만 필요하므로 없음. - -### 테스트 커버리지 공백 - -- Current tests assert default reasoning exposure: - - non-streaming `TestChatCompletionsReturnsReasoningContentSeparately` - - streaming `TestChatCompletionsStreamsReasoningSSE` -- No tests assert `include_reasoning=false` suppression while preserving content/tool output. -- Strict-output tests suppress reasoning for a different policy and must not be reused as this feature's evidence. - -### 심볼 참조 - -- renamed/removed symbol: none. - -### 분할 판단 - -- current subtask: `03+01_edge_reasoning_visibility` -- dependency from directory name: predecessor `01_edge_request_contract` -- predecessor completion: missing in active and archive lookup. Implementation must wait for `01_edge_request_contract/complete.log`. -- split reason: response filtering is Edge-only but depends on the request field introduced by 01; Node provider body mapping remains separate. - -### 범위 결정 근거 - -- Request decoder validation is excluded to predecessor 01. -- Provider-side generation control is excluded to sibling 02. -- Contract docs and dev smoke are excluded to later dependent subtasks. - -### 빌드 등급 - -- `local-G04`: bounded Edge stream/non-stream filtering with deterministic tests, but SSE behavior and tool output preservation add moderate risk. - -## 구현 체크리스트 - -- [ ] `01_edge_request_contract` predecessor `complete.log`를 확인한 뒤 시작한다. -- [ ] non-streaming completion에서 `include_reasoning=false`이면 `message.reasoning_content`를 비우되 `content`, `tool_calls`, `finish_reason`, usage는 유지한다. 검증: non-stream 응답에서 reasoning field가 빠지고 content/tool output은 유지된다. -- [ ] live SSE stream에서 `include_reasoning=false`이면 `delta.reasoning_content` chunk를 쓰지 않되 content/tool chunks와 `[DONE]`은 유지한다. 검증: stream 응답에서 reasoning delta가 빠지고 content/tool output은 유지된다. -- [ ] default/omitted `include_reasoning`은 기존 reasoning exposure tests를 유지한다. -- [ ] `go test -count=1 ./apps/edge/internal/openai`를 실행한다. -- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. - -## 의존 관계 및 구현 순서 - -- `03+01_edge_reasoning_visibility` decodes to `depends_on=[01]`. -- Do not implement until predecessor `01_edge_request_contract` has a `complete.log`. - -### [API-1] Non-Streaming Reasoning Suppression - -#### 문제 - -[apps/edge/internal/openai/chat_handler.go](/config/workspace/iop/apps/edge/internal/openai/chat_handler.go:249)의 collection path always builds `chatMessage{ReasoningContent: reasoning}` after normalization. No request-level visibility switch is applied. - -Before: - -```go -// apps/edge/internal/openai/chat_handler.go:249 -text, reasoning, finishReason, nativeToolCalls, usage, textToolFallback, err := collectRunResult(ctx, handle.Stream(), handle.WaitTimeout()) -... -message := chatMessage{Role: "assistant", Content: text, ReasoningContent: reasoning} -``` - -After: - -```go -// apply after normalization, before response serialization -if !req.includeReasoning() { - message.ReasoningContent = "" -} -``` - -#### 해결 방법 - -- Add a small helper such as `req.includeReasoning()` that defaults to true. -- Filter only response exposure. Do not alter collected reasoning logs or provider generation input. -- Ensure tool call synthesis still sees content and does not depend on reasoning. - -#### 수정 파일 및 체크리스트 - -- [ ] `apps/edge/internal/openai/types.go`: include helper if not already added by 01. -- [ ] `apps/edge/internal/openai/chat_handler.go`: non-streaming response filter. -- [ ] `apps/edge/internal/openai/server_test.go`: non-stream suppression tests. - -#### 테스트 작성 - -- Add `TestChatCompletionsSuppressesReasoningContentWhenExcluded`. -- Add a tool-call preserving case if existing native/text tool tests make this cheap; otherwise include content preservation in the suppression test. - -#### 중간 검증 - -```bash -go test -count=1 ./apps/edge/internal/openai -``` - -Expected: PASS. - -### [API-2] Streaming Reasoning Suppression - -#### 문제 - -- [apps/edge/internal/openai/stream.go](/config/workspace/iop/apps/edge/internal/openai/stream.go:65) writes every reasoning delta to SSE. -- [apps/edge/internal/openai/stream.go](/config/workspace/iop/apps/edge/internal/openai/stream.go:167) only suppresses reasoning for strict-output, not request visibility. - -Before: - -```go -// apps/edge/internal/openai/stream.go:167 -case "reasoning_delta": - if event.GetDelta() == "" { - continue - } - reasoningBuilder.WriteString(event.GetDelta()) - if outputPolicy.Strict { - continue - } - writeTracedReasoningDelta(event.GetDelta()) -``` - -After: - -```go -if outputPolicy.Strict || !req.includeReasoning() { - continue -} -writeTracedReasoningDelta(event.GetDelta()) -``` - -#### 해결 방법 - -- Suppress live SSE reasoning chunks when `include_reasoning=false`. -- Keep `reasoningBuilder` for logs and diagnostics. -- Buffered strict stream currently emits only content/tool output; verify it still does not leak reasoning and add a non-strict live SSE test. - -#### 수정 파일 및 체크리스트 - -- [ ] `apps/edge/internal/openai/stream.go`: live SSE reasoning filter. -- [ ] `apps/edge/internal/openai/server_test.go`: streaming suppression tests. - -#### 테스트 작성 - -- Add `TestChatCompletionsStreamSuppressesReasoningWhenExcluded` asserting no `reasoning_content`, content chunk present, `[DONE]` present. -- Keep `TestChatCompletionsStreamsReasoningSSE` passing for omitted `include_reasoning`. - -#### 중간 검증 - -```bash -go test -count=1 ./apps/edge/internal/openai -``` - -Expected: PASS. - -## 수정 파일 요약 - -| 파일 | 항목 | -|------|------| -| `apps/edge/internal/openai/types.go` | API-1 | -| `apps/edge/internal/openai/chat_handler.go` | API-1 | -| `apps/edge/internal/openai/stream.go` | API-2 | -| `apps/edge/internal/openai/server_test.go` | API-1, API-2 | - -## 최종 검증 - -```bash -go test -count=1 ./apps/edge/internal/openai -``` - -Expected: PASS. Fresh execution is required; do not rely on Go test cache. - -모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/apps/edge/internal/openai/chat_handler.go b/apps/edge/internal/openai/chat_handler.go index 800550f..62b782f 100644 --- a/apps/edge/internal/openai/chat_handler.go +++ b/apps/edge/internal/openai/chat_handler.go @@ -284,6 +284,9 @@ func collectChatCompletionOutput(ctx context.Context, req chatCompletionRequest, } text, reasoning, normalized := normalizeCompletionOutput(outputPolicy, text, reasoning) message := chatMessage{Role: "assistant", Content: text, ReasoningContent: reasoning} + if !req.includeReasoning() { + message.ReasoningContent = "" + } var toolCalls []any toolCallOrigin := "" if len(nativeToolCalls) > 0 { diff --git a/apps/edge/internal/openai/server_test.go b/apps/edge/internal/openai/server_test.go index 7137cd5..5403d45 100644 --- a/apps/edge/internal/openai/server_test.go +++ b/apps/edge/internal/openai/server_test.go @@ -1683,6 +1683,42 @@ func TestChatCompletionsReturnsReasoningContentSeparately(t *testing.T) { } } +func TestChatCompletionsSuppressesReasoningContentWhenExcluded(t *testing.T) { + fake := &fakeRunService{events: make(chan *iop.RunEvent, 5)} + fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "thinking "} + fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "more"} + fake.events <- &iop.RunEvent{Type: "delta", Delta: "answer"} + fake.events <- &iop.RunEvent{Type: "complete", Usage: &iop.Usage{InputTokens: 2, OutputTokens: 2}} + + srv := NewServer(config.EdgeOpenAIConf{ + Adapter: "ollama", + Target: "llama-fixed", + }, fake, nil) + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ + "model":"client-model", + "messages":[{"role":"user","content":"hi"}], + "include_reasoning": false + }`)) + w := httptest.NewRecorder() + + srv.handleChatCompletions(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) + } + + var resp chatCompletionResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp.Choices[0].Message.Content != "answer" { + t.Fatalf("content: got %q, expected \"answer\"", resp.Choices[0].Message.Content) + } + if resp.Choices[0].Message.ReasoningContent != "" { + t.Fatalf("reasoning_content: got %q, expected empty string due to include_reasoning=false", resp.Choices[0].Message.ReasoningContent) + } +} + func TestChatCompletionsStreamsReasoningSSE(t *testing.T) { fake := &fakeRunService{events: make(chan *iop.RunEvent, 4)} fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "think"} @@ -1708,6 +1744,35 @@ func TestChatCompletionsStreamsReasoningSSE(t *testing.T) { } } +func TestChatCompletionsStreamSuppressesReasoningWhenExcluded(t *testing.T) { + fake := &fakeRunService{events: make(chan *iop.RunEvent, 4)} + fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "think"} + fake.events <- &iop.RunEvent{Type: "delta", Delta: "hi"} + fake.events <- &iop.RunEvent{Type: "complete"} + + srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil) + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ + "model":"stream-model", + "stream":true, + "messages":[{"role":"user","content":"hi"}], + "include_reasoning": false + }`)) + 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, "reasoning_content") || strings.Contains(body, "think") { + t.Fatalf("unexpected reasoning_content in SSE body when excluded:\n%s", body) + } + if !strings.Contains(body, `"content":"hi"`) || !strings.Contains(body, "data: [DONE]") { + t.Fatalf("unexpected SSE body, missing content or DONE:\n%s", body) + } +} + func TestChatCompletionsStrictOutputNormalizesXMLStyleAgentResponse(t *testing.T) { fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)} fake.events <- &iop.RunEvent{Type: "delta", Delta: "---\nhidden\n\n\nok\n\n```\ntrailing noise"} diff --git a/apps/edge/internal/openai/stream.go b/apps/edge/internal/openai/stream.go index a351dda..b618e63 100644 --- a/apps/edge/internal/openai/stream.go +++ b/apps/edge/internal/openai/stream.go @@ -169,7 +169,7 @@ func (s *Server) streamChatCompletion(w http.ResponseWriter, r *http.Request, re continue } reasoningBuilder.WriteString(event.GetDelta()) - if outputPolicy.Strict { + if outputPolicy.Strict || !req.includeReasoning() { continue } writeTracedReasoningDelta(event.GetDelta()) diff --git a/apps/edge/internal/openai/types.go b/apps/edge/internal/openai/types.go index d429ad7..15f7fe3 100644 --- a/apps/edge/internal/openai/types.go +++ b/apps/edge/internal/openai/types.go @@ -124,6 +124,13 @@ func (req chatCompletionRequest) runInput(prompt string, messages []chatMessage, return input } +func (req chatCompletionRequest) includeReasoning() bool { + if req.IncludeReasoning != nil { + return *req.IncludeReasoning + } + return true +} + func shouldForwardProviderToolChoice(toolChoice any) bool { if toolChoice == nil { return false diff --git a/apps/node/internal/adapters/openai_compat/openai_compat.go b/apps/node/internal/adapters/openai_compat/openai_compat.go index 9418ae4..43082cb 100644 --- a/apps/node/internal/adapters/openai_compat/openai_compat.go +++ b/apps/node/internal/adapters/openai_compat/openai_compat.go @@ -128,7 +128,11 @@ func (a *Adapter) Execute(ctx context.Context, spec runtime.ExecutionSpec, sink return err } - reqBody := buildRequestBody(model, messages, spec.Input) + reqBody, err := a.buildRequestBody(model, messages, spec.Input) + if err != nil { + _ = emitError(ctx, sink, spec.RunID, fmt.Sprintf("openai_compat build request body failed: %v", err)) + return fmt.Errorf("openai_compat adapter: build request body: %w", err) + } body, err := json.Marshal(reqBody) if err != nil { return fmt.Errorf("openai_compat adapter: marshal request: %w", err) @@ -437,7 +441,7 @@ func (a *Adapter) doChatCompletion(ctx context.Context, body []byte) (*http.Resp return a.client.Do(req) } -func buildRequestBody(model string, messages []chatMessage, input map[string]any) map[string]any { +func (a *Adapter) buildRequestBody(model string, messages []chatMessage, input map[string]any) (map[string]any, error) { body := make(map[string]any) // Copy caller options as top-level OpenAI-compatible request fields first so // adapter-owned fields below always win over them. @@ -446,8 +450,8 @@ func buildRequestBody(model string, messages []chatMessage, input map[string]any body[k] = v } } - // Pass through optional OpenAI-compatible fields when present in the input. - for _, key := range []string{"tools", "tool_choice", "format", "think", "keep_alive"} { + // Pass through optional OpenAI-compatible fields when present in the input (except think). + for _, key := range []string{"tools", "tool_choice", "format", "keep_alive"} { if v, ok := input[key]; ok { body[key] = v } @@ -455,7 +459,110 @@ func buildRequestBody(model string, messages []chatMessage, input map[string]any body["model"] = model body["messages"] = messages body["stream"] = true - return body + + // Extract think-control fields + hasThink := false + var thinkVal bool + if v, ok := input["think"]; ok { + if bv, ok := v.(bool); ok { + hasThink = true + thinkVal = bv + } + } + + hasReasoningEffort := false + var reasoningEffortVal string + if v, ok := input["reasoning_effort"]; ok { + if sv, ok := v.(string); ok { + hasReasoningEffort = true + reasoningEffortVal = sv + } + } + + hasBudget := false + var budgetVal any + if v, ok := input["thinking_token_budget"]; ok { + hasBudget = true + budgetVal = v + } + + isVllm := a.provider == "vllm" || a.provider == "vllm-mlx" + if isVllm { + if hasReasoningEffort && reasoningEffortVal != "" && reasoningEffortVal != "none" { + return nil, fmt.Errorf("unsupported think control: reasoning_effort %q is not supported by %s", reasoningEffortVal, a.provider) + } + + if hasBudget { + var budgetInt int64 + valid := false + switch bv := budgetVal.(type) { + case int: + budgetInt = int64(bv) + valid = true + case int64: + budgetInt = bv + valid = true + case float64: + if bv == float64(int64(bv)) { + budgetInt = int64(bv) + valid = true + } + } + if !valid || budgetInt < 0 { + return nil, fmt.Errorf("unsupported think control: invalid thinking_token_budget %v", budgetVal) + } + } + + if hasThink || (hasReasoningEffort && reasoningEffortVal == "none") || hasBudget { + var chatTemplateKwargs map[string]any + if ctk, ok := body["chat_template_kwargs"].(map[string]any); ok { + chatTemplateKwargs = make(map[string]any) + for k, v := range ctk { + chatTemplateKwargs[k] = v + } + } else { + chatTemplateKwargs = make(map[string]any) + } + + if (hasThink && !thinkVal) || (hasReasoningEffort && reasoningEffortVal == "none") { + chatTemplateKwargs["enable_thinking"] = false + } else if (hasThink && thinkVal) || hasBudget { + chatTemplateKwargs["enable_thinking"] = true + } + + if hasBudget { + chatTemplateKwargs["thinking_token_budget"] = budgetVal + } + + body["chat_template_kwargs"] = chatTemplateKwargs + } + } else if a.provider == "lemonade" { + if (hasThink && !thinkVal) || (hasReasoningEffort && reasoningEffortVal == "none") { + body["think"] = false + } else if hasThink && thinkVal { + body["think"] = true + } + + if hasReasoningEffort && reasoningEffortVal != "none" { + body["reasoning_effort"] = reasoningEffortVal + } + if hasBudget { + body["thinking_token_budget"] = budgetVal + } + } else { + // empty/unknown provider + if hasThink { + body["think"] = thinkVal + } + if hasReasoningEffort { + body["reasoning_effort"] = reasoningEffortVal + } + if hasBudget { + body["thinking_token_budget"] = budgetVal + } + } + + return body, nil } func retryBodyWithForcedSingleTool(body map[string]any, errorBody string) (map[string]any, bool) { diff --git a/apps/node/internal/adapters/openai_compat/openai_compat_test.go b/apps/node/internal/adapters/openai_compat/openai_compat_test.go index 300b3e6..faeff2c 100644 --- a/apps/node/internal/adapters/openai_compat/openai_compat_test.go +++ b/apps/node/internal/adapters/openai_compat/openai_compat_test.go @@ -158,7 +158,6 @@ func TestOpenAICompatProbeProviderAvailability(t *testing.T) { } }) } - func TestOpenAICompatExecuteStreamsDeltasAndFinishReason(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/chat/completions" { @@ -616,3 +615,470 @@ func TestOpenAICompatJoinOpenAIPathNoDuplicateV1(t *testing.T) { } } } + +func TestOpenAICompatExecuteMapsThinkFalseForVLLMChatTemplateKwargs(t *testing.T) { + var body map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = fmt.Fprintf(w, "data: %s\n\n", `{"choices":[{"delta":{"content":"ok"}}]}`) + _, _ = fmt.Fprintf(w, "data: [DONE]\n\n") + })) + defer server.Close() + + adapter := New(config.OpenAICompatConf{ + Provider: "vllm", + Endpoint: server.URL, + }, zap.NewNop()) + + sink := &fakeSink{} + err := adapter.Execute(context.Background(), runtime.ExecutionSpec{ + RunID: "run-vllm", + Target: "qwen-model", + Input: map[string]any{ + "prompt": "hello", + "think": false, + }, + }, sink) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + + ctk, ok := body["chat_template_kwargs"].(map[string]any) + if !ok { + t.Fatalf("expected chat_template_kwargs to be a map, got %T", body["chat_template_kwargs"]) + } + if ctk["enable_thinking"] != false { + t.Fatalf("expected enable_thinking to be false, got %v", ctk["enable_thinking"]) + } +} + +func TestOpenAICompatExecuteMapsThinkFalseForVLLMMLXChatTemplateKwargs(t *testing.T) { + var body map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = fmt.Fprintf(w, "data: %s\n\n", `{"choices":[{"delta":{"content":"ok"}}]}`) + _, _ = fmt.Fprintf(w, "data: [DONE]\n\n") + })) + defer server.Close() + + adapter := New(config.OpenAICompatConf{ + Provider: "vllm-mlx", + Endpoint: server.URL, + }, zap.NewNop()) + + sink := &fakeSink{} + err := adapter.Execute(context.Background(), runtime.ExecutionSpec{ + RunID: "run-vllm-mlx", + Target: "qwen-model", + Input: map[string]any{ + "prompt": "hello", + "think": false, + }, + }, sink) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + + ctk, ok := body["chat_template_kwargs"].(map[string]any) + if !ok { + t.Fatalf("expected chat_template_kwargs to be a map, got %T", body["chat_template_kwargs"]) + } + if ctk["enable_thinking"] != false { + t.Fatalf("expected enable_thinking to be false, got %v", ctk["enable_thinking"]) + } +} + +func TestOpenAICompatExecuteMapsThinkFalseForLemonadeTopLevel(t *testing.T) { + var body map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = fmt.Fprintf(w, "data: %s\n\n", `{"choices":[{"delta":{"content":"ok"}}]}`) + _, _ = fmt.Fprintf(w, "data: [DONE]\n\n") + })) + defer server.Close() + + adapter := New(config.OpenAICompatConf{ + Provider: "lemonade", + Endpoint: server.URL, + }, zap.NewNop()) + + sink := &fakeSink{} + err := adapter.Execute(context.Background(), runtime.ExecutionSpec{ + RunID: "run-lemonade", + Target: "lemonade-model", + Input: map[string]any{ + "prompt": "hello", + "think": false, + }, + }, sink) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + + if body["think"] != false { + t.Fatalf("expected think to be false, got %v", body["think"]) + } +} + +func TestOpenAICompatExecuteOmitsThinkControlWhenRequestOmitsIt(t *testing.T) { + for _, provider := range []string{"vllm", "vllm-mlx", "lemonade", ""} { + t.Run(provider, func(t *testing.T) { + var body map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = fmt.Fprintf(w, "data: %s\n\n", `{"choices":[{"delta":{"content":"ok"}}]}`) + _, _ = fmt.Fprintf(w, "data: [DONE]\n\n") + })) + defer server.Close() + + adapter := New(config.OpenAICompatConf{ + Provider: provider, + Endpoint: server.URL, + }, zap.NewNop()) + + sink := &fakeSink{} + err := adapter.Execute(context.Background(), runtime.ExecutionSpec{ + RunID: "run-omit", + Target: "qwen-model", + Input: map[string]any{ + "prompt": "hello", + }, + }, sink) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + + for _, key := range []string{"think", "reasoning_effort", "thinking_token_budget", "chat_template_kwargs"} { + if _, ok := body[key]; ok { + t.Fatalf("expected key %q to be omitted for provider %q", key, provider) + } + } + }) + } +} + +func TestOpenAICompatExecuteVLLMUnsupportedReasoningEffort(t *testing.T) { + adapter := New(config.OpenAICompatConf{ + Provider: "vllm", + Endpoint: "http://localhost:8000", + }, zap.NewNop()) + + sink := &fakeSink{} + err := adapter.Execute(context.Background(), runtime.ExecutionSpec{ + RunID: "run-unsupported", + Target: "qwen-model", + Input: map[string]any{ + "prompt": "hello", + "reasoning_effort": "low", + }, + }, sink) + if err == nil { + t.Fatalf("expected error for unsupported reasoning_effort, got nil") + } + if !strings.Contains(err.Error(), "unsupported think control") { + t.Fatalf("expected error message to contain 'unsupported think control', got: %v", err) + } +} + +func TestOpenAICompatExecuteVLLMUnsupportedNegativeBudget(t *testing.T) { + adapter := New(config.OpenAICompatConf{ + Provider: "vllm", + Endpoint: "http://localhost:8000", + }, zap.NewNop()) + + sink := &fakeSink{} + err := adapter.Execute(context.Background(), runtime.ExecutionSpec{ + RunID: "run-unsupported", + Target: "qwen-model", + Input: map[string]any{ + "prompt": "hello", + "thinking_token_budget": -10, + }, + }, sink) + if err == nil { + t.Fatalf("expected error for negative budget, got nil") + } + if !strings.Contains(err.Error(), "unsupported think control") { + t.Fatalf("expected error message to contain 'unsupported think control', got: %v", err) + } +} + +func TestOpenAICompatExecuteMapsReasoningEffortNone(t *testing.T) { + t.Run("vllm", func(t *testing.T) { + var body map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = fmt.Fprintf(w, "data: %s\n\n", `{"choices":[{"delta":{"content":"ok"}}]}`) + _, _ = fmt.Fprintf(w, "data: [DONE]\n\n") + })) + defer server.Close() + + adapter := New(config.OpenAICompatConf{ + Provider: "vllm", + Endpoint: server.URL, + }, zap.NewNop()) + + sink := &fakeSink{} + err := adapter.Execute(context.Background(), runtime.ExecutionSpec{ + RunID: "run-vllm-none", + Target: "qwen-model", + Input: map[string]any{ + "prompt": "hello", + "reasoning_effort": "none", + }, + }, sink) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + + ctk, ok := body["chat_template_kwargs"].(map[string]any) + if !ok { + t.Fatalf("expected chat_template_kwargs to be a map, got %T", body["chat_template_kwargs"]) + } + if ctk["enable_thinking"] != false { + t.Fatalf("expected enable_thinking to be false, got %v", ctk["enable_thinking"]) + } + }) + + t.Run("lemonade", func(t *testing.T) { + var body map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = fmt.Fprintf(w, "data: %s\n\n", `{"choices":[{"delta":{"content":"ok"}}]}`) + _, _ = fmt.Fprintf(w, "data: [DONE]\n\n") + })) + defer server.Close() + + adapter := New(config.OpenAICompatConf{ + Provider: "lemonade", + Endpoint: server.URL, + }, zap.NewNop()) + + sink := &fakeSink{} + err := adapter.Execute(context.Background(), runtime.ExecutionSpec{ + RunID: "run-lemonade-none", + Target: "lemonade-model", + Input: map[string]any{ + "prompt": "hello", + "reasoning_effort": "none", + }, + }, sink) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + + if body["think"] != false { + t.Fatalf("expected think to be false, got %v", body["think"]) + } + if _, ok := body["reasoning_effort"]; ok { + t.Fatalf("expected reasoning_effort to be omitted, got %v", body["reasoning_effort"]) + } + }) +} + +func TestOpenAICompatExecuteMapsThinkTrueForVLLMChatTemplateKwargs(t *testing.T) { + var body map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = fmt.Fprintf(w, "data: %s\n\n", `{"choices":[{"delta":{"content":"ok"}}]}`) + _, _ = fmt.Fprintf(w, "data: [DONE]\n\n") + })) + defer server.Close() + + adapter := New(config.OpenAICompatConf{ + Provider: "vllm", + Endpoint: server.URL, + }, zap.NewNop()) + + sink := &fakeSink{} + err := adapter.Execute(context.Background(), runtime.ExecutionSpec{ + RunID: "run-vllm", + Target: "qwen-model", + Input: map[string]any{ + "prompt": "hello", + "think": true, + }, + }, sink) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + + ctk, ok := body["chat_template_kwargs"].(map[string]any) + if !ok { + t.Fatalf("expected chat_template_kwargs to be a map, got %T", body["chat_template_kwargs"]) + } + if ctk["enable_thinking"] != true { + t.Fatalf("expected enable_thinking to be true, got %v", ctk["enable_thinking"]) + } +} + +func TestOpenAICompatExecuteMapsThinkTrueForVLLMMLXChatTemplateKwargs(t *testing.T) { + var body map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = fmt.Fprintf(w, "data: %s\n\n", `{"choices":[{"delta":{"content":"ok"}}]}`) + _, _ = fmt.Fprintf(w, "data: [DONE]\n\n") + })) + defer server.Close() + + adapter := New(config.OpenAICompatConf{ + Provider: "vllm-mlx", + Endpoint: server.URL, + }, zap.NewNop()) + + sink := &fakeSink{} + err := adapter.Execute(context.Background(), runtime.ExecutionSpec{ + RunID: "run-vllm-mlx", + Target: "qwen-model", + Input: map[string]any{ + "prompt": "hello", + "think": true, + }, + }, sink) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + + ctk, ok := body["chat_template_kwargs"].(map[string]any) + if !ok { + t.Fatalf("expected chat_template_kwargs to be a map, got %T", body["chat_template_kwargs"]) + } + if ctk["enable_thinking"] != true { + t.Fatalf("expected enable_thinking to be true, got %v", ctk["enable_thinking"]) + } +} + +func TestOpenAICompatExecuteMapsThinkTrueForLemonadeTopLevel(t *testing.T) { + var body map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = fmt.Fprintf(w, "data: %s\n\n", `{"choices":[{"delta":{"content":"ok"}}]}`) + _, _ = fmt.Fprintf(w, "data: [DONE]\n\n") + })) + defer server.Close() + + adapter := New(config.OpenAICompatConf{ + Provider: "lemonade", + Endpoint: server.URL, + }, zap.NewNop()) + + sink := &fakeSink{} + err := adapter.Execute(context.Background(), runtime.ExecutionSpec{ + RunID: "run-lemonade", + Target: "lemonade-model", + Input: map[string]any{ + "prompt": "hello", + "think": true, + "thinking_token_budget": 1024, + }, + }, sink) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + + if body["think"] != true { + t.Fatalf("expected think to be true, got %v", body["think"]) + } + if body["thinking_token_budget"] != float64(1024) { + t.Fatalf("expected thinking_token_budget to be float64 1024, got %v", body["thinking_token_budget"]) + } +} + +func TestOpenAICompatExecuteMapsThinkingTokenBudgetForVLLMChatTemplateKwargs(t *testing.T) { + var body map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = fmt.Fprintf(w, "data: %s\n\n", `{"choices":[{"delta":{"content":"ok"}}]}`) + _, _ = fmt.Fprintf(w, "data: [DONE]\n\n") + })) + defer server.Close() + + adapter := New(config.OpenAICompatConf{ + Provider: "vllm", + Endpoint: server.URL, + }, zap.NewNop()) + + sink := &fakeSink{} + err := adapter.Execute(context.Background(), runtime.ExecutionSpec{ + RunID: "run-vllm-budget", + Target: "qwen-model", + Input: map[string]any{ + "prompt": "hello", + "thinking_token_budget": 2048, + }, + }, sink) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + + if _, ok := body["thinking_token_budget"]; ok { + t.Fatalf("expected top-level thinking_token_budget to be omitted for vllm, got %v", body["thinking_token_budget"]) + } + + ctk, ok := body["chat_template_kwargs"].(map[string]any) + if !ok { + t.Fatalf("expected chat_template_kwargs to be a map, got %T", body["chat_template_kwargs"]) + } + if ctk["enable_thinking"] != true { + t.Fatalf("expected enable_thinking to be true for budget-only request, got %v", ctk["enable_thinking"]) + } + if ctk["thinking_token_budget"] != float64(2048) { + t.Fatalf("expected thinking_token_budget to be 2048, got %v", ctk["thinking_token_budget"]) + } +} + +func TestOpenAICompatExecuteVLLMUnsupportedInvalidBudget(t *testing.T) { + adapter := New(config.OpenAICompatConf{ + Provider: "vllm", + Endpoint: "http://localhost:8000", + }, zap.NewNop()) + + sink := &fakeSink{} + invalidBudgets := []any{"invalid", -5, 12.34} + for _, val := range invalidBudgets { + err := adapter.Execute(context.Background(), runtime.ExecutionSpec{ + RunID: "run-vllm-invalid-budget", + Target: "qwen-model", + Input: map[string]any{ + "prompt": "hello", + "thinking_token_budget": val, + }, + }, sink) + if err == nil { + t.Fatalf("expected error for budget val %v, got nil", val) + } + if !strings.Contains(err.Error(), "unsupported think control") { + t.Fatalf("expected error message to contain 'unsupported think control' for val %v, got: %v", val, err) + } + } +}