# SDD: Model Group Mixed Provider Dispatch

## 위치

- Milestone: [Model Group Mixed Provider Dispatch](../../../phase/routing-policy-model-orchestration/milestones/model-group-mixed-provider-dispatch.md)
- Phase: [PHASE.md](../../../phase/routing-policy-model-orchestration/PHASE.md)

## 상태

[승인됨]

## SDD 잠금

- 상태: 해제
- 사용자 리뷰: 없음
- 잠금 항목:
  - 없음

## 문제 / 비목표

- 문제: 현재 provider-pool/model group route는 provider pool이면 raw tunnel을 먼저 가정하는 경향이 있어, 같은 model group 안에 Ollama 같은 normalized-only provider가 들어오면 선택된 provider의 OpenAI-compatible 지원 여부와 실행 경로가 어긋날 수 있다. 반대로 model group 전체를 normalized로 낮추면 vLLM/vLLM-MLX/Lemonade/openweight cloud provider의 provider-original passthrough 장점이 사라진다. Model group은 provider 후보를 동일하게 평가하되, 선택된 provider의 OpenAI-compatible 지원 여부에 따라 실행 경로를 자동 결정해야 한다.
- 비목표:
  - provider 선택 기준을 `capacity + priority` 밖의 score policy로 바꾼다.
  - Ollama를 model group에서 제외한다.
  - Ollama를 고동시성 기본 provider로 취급한다.
  - client 요청 field로 passthrough/normalized/transformed 경로를 선택하게 한다.
  - 이 Milestone에서 cloud fallback, route learning loop, provider auth 정책을 추가한다.

## Source of Truth

| 영역 | 기준 | 메모 |
|------|------|------|
| Roadmap | [Model Group Mixed Provider Dispatch](../../../phase/routing-policy-model-orchestration/milestones/model-group-mixed-provider-dispatch.md) | 목표, 기능 Task, 범위 제외 기준 |
| Contract | [openai-compatible-api.md](../../../../../agent-contract/outer/openai-compatible-api.md), [edge-node-runtime-wire.md](../../../../../agent-contract/inner/edge-node-runtime-wire.md), [edge-config-runtime-refresh.md](../../../../../agent-contract/inner/edge-config-runtime-refresh.md) | 외부 OpenAI-compatible model group surface, provider tunnel, normalized run dispatch, provider config 계약 |
| Spec | [openai-compatible-surface.md](../../../../../agent-spec/input/openai-compatible-surface.md), [provider-pool-config-refresh.md](../../../../../agent-spec/runtime/provider-pool-config-refresh.md), [edge-node-execution.md](../../../../../agent-spec/runtime/edge-node-execution.md) | 현재 구현 surface와 runtime behavior 문서 |
| Code | `packages/go/config`, `apps/edge/internal/openai`, `apps/edge/internal/service`, `apps/edge/internal/node`, `apps/node/internal/adapters/openai_compat`, `apps/node/internal/adapters/ollama`, `apps/node/internal/runtime`, `proto/iop/runtime.proto` | config validation, provider selection, OpenAI handler, Edge-Node wire, adapter implementation 기준 |
| User Decision | 현재 사용자 요청 | Ollama는 model group에서 제외하지 않는다. 후보는 동일하게 두고 capacity/priority로 가중한다. model group request에는 response path selector를 두지 않는다. OpenAI-compatible 호출 방식을 지원하는 provider는 모두 passthrough 방식으로 처리하고, Ollama/CLI/native provider는 normalized로 처리한다. Seulgivibe Claude/OpenAI aliases는 passthrough provider에 포함한다. custom request fields는 provider-pool ingress에서 보존한다. |

## State Machine

| 상태 | 진입 조건 | 다음 상태 | 근거 |
|------|-----------|-----------|------|
| `ingress` | OpenAI-compatible Chat 또는 Responses request가 `models[]` catalog의 model group id를 사용한다 | `route-envelope-parsed` 또는 `invalid-request` | Edge OpenAI handler |
| `route-envelope-parsed` | raw body는 보존하고 routing에 필요한 `model`, `metadata`, `stream` 등 최소 envelope를 읽었다 | `candidate-selection` | provider-pool route |
| `invalid-request` | model group request가 `metadata.iop_response_mode` 또는 동등한 execution path selector를 포함하거나 필수 routing field가 없다 | terminal error | model group client surface contract |
| `candidate-selection` | model group의 provider mapping과 connected node status가 있다 | `provider-selected` 또는 `no-capacity` | existing capacity/priority/health rules |
| `no-capacity` | 사용 가능한 provider candidate가 없다 | terminal error | queue policy |
| `provider-selected` | queue slot이 특정 provider id/node/adapter/served target에 할당되었다 | `execution-path-resolved` | selected provider의 OpenAI-compatible 지원 여부 |
| `execution-path-resolved` | selected provider가 OpenAI-compatible 호출 방식을 지원한다 | `passthrough-dispatch` | OpenAI-compatible provider |
| `execution-path-resolved` | selected provider가 normalized-only다 | `normalized-dispatch` | Ollama/CLI/native provider |
| `passthrough-dispatch` | raw request body를 model rewrite와 provider auth/header 정책만 적용해 Node tunnel로 보낸다 | `provider-response-relayed` 또는 `provider-error` | `ProviderTunnelRequest` |
| `normalized-dispatch` | standard OpenAI-compatible request subset을 internal `RunRequest`로 변환해 selected adapter로 보낸다 | `normalized-response-built` 또는 `provider-error` | `RunRequest`/adapter `Execute` |
| `provider-response-relayed` | provider tunnel frames가 caller response로 relay되고 sideband/log/metric observation이 기록된다 | terminal success | passthrough bridge |
| `normalized-response-built` | normalized runtime events가 OpenAI-compatible response shape로 변환되고 sideband/log/metric observation이 기록된다 | terminal success | normalized response builder |
| `provider-error` | provider HTTP/tunnel 또는 adapter execution이 실패한다 | terminal error | OpenAI-compatible error mapping |

## Interface Contract

- 계약 원문: [openai-compatible-api.md](../../../../../agent-contract/outer/openai-compatible-api.md)
- 입력:
  - `model`: caller-facing model group id다. `models[]` catalog와 provider mapping이 source of truth다.
  - `metadata.workspace`, `metadata.task_id` 등 routing/operation metadata는 기존 OpenAI-compatible surface를 따른다.
  - `metadata.iop_response_mode` 또는 같은 의미의 execution path selector는 model group route에서 지원하지 않는다. 포함 시 silent fallback 없이 `400 invalid_request_error`로 거부한다.
  - unknown/Codex/provider-specific request fields는 provider-pool ingress에서 strict decode로 버리거나 거부하지 않는다.
  - `nodes[].providers[].capacity`, `priority`, `enabled`, health/status, model membership은 기존 provider selection 기준이다.
- 실행 경로:
  - OpenAI-compatible 호출 방식을 지원하는 provider는 모두 passthrough 실행 경로를 사용한다.
  - OpenAI-compatible provider는 Node adapter가 `ProviderTunnelAdapter` interface를 구현해야 한다. 이 interface가 없으면 normalized fallback이 아니라 구현 결함 또는 unsupported 상태로 처리한다.
  - `ollama`, `cli` 및 OpenAI-compatible 호출 방식을 지원하지 않는 native provider는 normalized `RunRequest` 실행 경로를 사용한다.
  - provider type/label/선언된 지원 방식이 `openai_compat`, `openai_api`, `vllm`, `vllm-mlx`, `lemonade`, `sglang`, `seulgivibe_claude`, `seulgivibe_openai` 계열이면 OpenAI-compatible provider로 분류한다.
  - provider type/선언된 지원 방식이 `ollama`, `cli`이면 normalized-only provider로 분류한다.
  - Seulgivibe Claude/OpenAI provider는 현재 Seulgivibe Milestone에서 다루는 auth/catalog/Responses passthrough 구현을 유지하면서도, 이 classifier에서는 OpenAI-compatible passthrough provider에 포함된다.
  - selection에서 provider type만을 이유로 Ollama를 제외하지 않는다. 운영자는 작은 capacity/priority로 Ollama 동시성 한계를 표현한다.
- 출력:
  - 표준 OpenAI-compatible client 요청에는 표준-compatible response를 반환하고 불필요한 IOP custom field/event를 섞지 않는다.
  - OpenAI-compatible provider 선택 시 provider HTTP status/header/body 또는 provider SSE를 최대한 보존하며, model rewrite와 문서화된 IOP extension-safe 지점만 예외다.
  - normalized provider 선택 시 runtime event를 OpenAI-compatible Chat/Responses response shape로 변환한다.
  - 모든 실행 경로는 내부 observation으로 selected provider id/type, adapter, served target, execution path, queue decision, usage 후보를 남긴다.
  - IOP-aware/custom request surface가 문서화된 extension-safe 지점을 사용하면 sideband/custom observation을 받을 수 있다. 표준-only request에는 응답 custom field를 만들지 않는다.
- custom field 처리:
  - OpenAI-compatible provider가 선택되면 raw body의 unknown/Codex/provider-specific field는 model rewrite와 명시 변환 외에는 provider로 보존 전달한다.
  - normalized-only provider가 선택되면 standard OpenAI-compatible subset과 IOP가 아는 extension만 native adapter request로 변환한다. 변환할 수 없는 provider-specific required field는 dispatch 전 명시적 unsupported error로 끝내고, 단순 unknown field는 observation에 남기되 native provider request에 임의로 invent하지 않는다.
- 금지:
  - model group 전체를 normalized로 낮춰 vLLM/vLLM-MLX/Lemonade/openweight cloud provider의 raw passthrough 경로를 잃지 않는다.
  - provider pool이라는 이유만으로 Ollama/CLI/native provider에 `ProviderTunnelRequest`를 보내지 않는다.
  - client가 `metadata.iop_response_mode`로 model group의 passthrough/normalized/transformed 경로를 고르게 하지 않는다.
  - provider selection을 두 번 수행해 queue slot과 실제 실행 provider가 달라지게 하지 않는다.

## Acceptance Scenarios

| ID | Milestone Task | Given | When | Then |
|----|----------------|-------|------|------|
| S01 | `selection-first-path` | mixed model group에 vLLM provider와 Ollama provider가 있고 둘 다 healthy/capacity가 있다 | provider-pool route를 실행한다 | 기존 capacity/priority 기준으로 provider를 한 번 선택하고, 같은 selected provider로 tunnel 또는 normalized 실행을 수행한다 |
| S02 | `selection-first-path` | selected provider가 Ollama다 | Chat Completions request를 처리한다 | Edge가 `ProviderTunnelRequest`를 보내지 않고 normalized `RunRequest`로 Ollama adapter를 실행한다 |
| S03 | `provider-path-classifier` | provider type/label/선언된 지원 방식이 `vllm`, `vllm-mlx`, `lemonade`, `openai_compat`, `seulgivibe_claude`, `seulgivibe_openai` 중 하나이거나 OpenAI-compatible 호출 방식을 지원한다 | execution path를 resolve한다 | passthrough path를 선택한다 |
| S04 | `provider-path-classifier` | provider type/선언된 지원 방식이 `ollama` 또는 `cli`다 | execution path를 resolve한다 | normalized-only로 분류하고 model group 후보에서는 제외하지 않는다 |
| S05 | `mixed-model-group` | Ollama-only model group config가 있다 | OpenAI-compatible Chat request를 보낸다 | provider-pool route가 정상 후보를 찾고 normalized OpenAI-compatible response를 반환한다 |
| S06 | `no-client-response-mode` | model group request가 `metadata.iop_response_mode`를 포함한다 | Edge handler가 route envelope를 parse한다 | request는 `400 invalid_request_error`로 거부되고 provider dispatch가 발생하지 않는다 |
| S07 | `custom-field-preservation` | Chat request에 Codex/provider-specific unknown field가 있고 selected provider가 OpenAI-compatible provider다 | provider tunnel request body를 만든다 | unknown field가 model rewrite 외에는 그대로 provider로 전달된다 |
| S08 | `custom-field-preservation` | 같은 unknown field request에서 selected provider가 Ollama다 | normalized request를 만든다 | standard field는 Ollama request로 변환되고, 변환 불가능한 required field는 explicit unsupported error 또는 observation으로 처리된다 |
| S09 | `sideband-observation` | 표준 OpenAI-compatible client가 model group을 호출한다 | provider가 성공 응답을 반환한다 | caller response에는 불필요한 IOP custom field가 없고, internal log/metric에는 selected provider와 execution path가 남는다 |
| S10 | `contract-sync` | contract/spec 문서를 검토한다 | Milestone 구현 diff가 준비된다 | model group의 provider-derived execution path, selector 금지, custom field 보존 기준이 문서와 구현에 동일하게 반영되어 있다 |

## Evidence Map

| Scenario | Required Evidence | `agent-task` 연결 | 완료 Evidence 기대 |
|----------|-------------------|------------------|---------------------------|
| S01 | service queue/selection unit tests | `agent-task/m-model-group-mixed-provider-dispatch/...` | `Roadmap Completion`에 `selection-first-path`와 `go test ./apps/edge/internal/service -count=1` 결과 |
| S02 | Edge service/openai handler tests proving no tunnel for Ollama | `agent-task/m-model-group-mixed-provider-dispatch/...` | `Roadmap Completion`에 `selection-first-path`와 Ollama normalized dispatch assertion |
| S03 | provider classifier tests for OpenAI-compatible aliases | `agent-task/m-model-group-mixed-provider-dispatch/...` | `Roadmap Completion`에 `provider-path-classifier`, config/node/adapters test 결과 |
| S04 | provider classifier tests for Ollama/CLI | `agent-task/m-model-group-mixed-provider-dispatch/...` | `Roadmap Completion`에 `provider-path-classifier`, normalized-only provider candidate assertion |
| S05 | mixed and Ollama-only model group fixtures | `agent-task/m-model-group-mixed-provider-dispatch/...` | `Roadmap Completion`에 `mixed-model-group`, `go test ./apps/edge/internal/openai -count=1`, `go test ./apps/edge/internal/service -count=1` 결과 |
| S06 | handler rejection tests and contract/spec diff | `agent-task/m-model-group-mixed-provider-dispatch/...` | `Roadmap Completion`에 `no-client-response-mode`, 400 invalid_request_error assertion |
| S07 | Chat raw body preservation tunnel test | `agent-task/m-model-group-mixed-provider-dispatch/...` | `Roadmap Completion`에 `custom-field-preservation`, provider request body fixture |
| S08 | normalized custom field policy test | `agent-task/m-model-group-mixed-provider-dispatch/...` | `Roadmap Completion`에 `custom-field-preservation`, unsupported/observation assertion |
| S09 | standard response and observation tests | `agent-task/m-model-group-mixed-provider-dispatch/...` | `Roadmap Completion`에 `sideband-observation`, response body/log/metric fixture |
| S10 | contract/spec sync review | `agent-task/m-model-group-mixed-provider-dispatch/...` | `Roadmap Completion`에 `contract-sync`, linked contract/spec paths and final test output |

## Cross-repo Dependencies

- 없음

## Drift Check

- [x] Milestone 기능 Task와 Acceptance Scenario가 일치한다.
- [x] Evidence Map이 code-review/complete.log에서 검증 가능하다.
- [x] agent-contract를 쓰는 경우 SDD에 계약 원문을 복제하지 않았다.
- [x] 사용자 리뷰가 필요한 항목은 `USER_REVIEW.md`에만 남겼다.

## 사용자 리뷰 이력

- 없음

## 작업 컨텍스트

- 표준선: Model group provider 후보는 provider type 때문에 제외하지 않고 기존 capacity/priority/health/model membership으로 선택한다.
- 표준선: 선택된 provider가 OpenAI-compatible 호출 방식을 지원하면 passthrough path를 사용하고, Ollama/CLI/native provider면 normalized path를 사용한다. Seulgivibe Claude/OpenAI aliases는 passthrough provider에 포함한다.
- 표준선: OpenAI-compatible provider에서 tunnel/passthrough 구현이 누락된 경우 normalized fallback으로 처리하지 않는다.
- 표준선: model group client request는 provider execution path를 지정하지 않는다. `metadata.iop_response_mode`는 model group route에서 제거/거부 대상이다.
- 표준선: Chat provider-pool ingress는 Responses provider-pool ingress처럼 raw body와 route envelope를 분리해 custom/provider-specific field를 보존할 준비를 한다.
- 후속 SDD: 없음
