CompletionService 호출 파이프라인
CompletionService는 LLM 호출의 핵심 퍼사드입니다. 프론트엔드의 completion 요청을 완전한 API 호출 파이프라인으로 조율합니다. 이 문서에서는 6단계 파이프라인, 스트리밍 처리, 사고 예산 계산, 재시도 메커니즘, 도구 호출 루프를 자세히 설명합니다.
파일 위치
| 파일 | 경로 |
|---|---|
| CompletionService | packages/desktop/app/main/services/capabilities/llm/completion/CompletionService.ts |
| DirectApiHandler | packages/desktop/app/main/services/capabilities/llm/completion/DirectApiHandler.ts |
| StreamHandler | packages/desktop/app/main/services/capabilities/llm/completion/StreamHandler.ts |
| ToolHandler | packages/desktop/app/main/services/capabilities/llm/completion/ToolHandler.ts |
| TransformerHandler | packages/desktop/app/main/services/capabilities/llm/completion/TransformerHandler.ts |
| ThinkingResolver | packages/desktop/app/main/services/capabilities/llm/completion/ThinkingResolver.ts |
| URL Builder | packages/desktop/app/main/services/capabilities/llm/completion/url-builder.ts |
| Header Builder | packages/desktop/app/main/services/capabilities/llm/completion/header-builder.ts |
| Message Converter | packages/desktop/app/main/services/capabilities/llm/completion/message-converter.ts |
| Types | packages/desktop/app/main/services/capabilities/llm/completion/types.ts |
| NativeSearchInjector | packages/desktop/app/main/services/capabilities/llm/completion/NativeSearchInjector.ts |
| ProviderSearchInjector | packages/desktop/app/main/services/capabilities/llm/completion/ProviderSearchInjector.ts |
아키텍처 컨텍스트
graph TB
subgraph CompletionService ["CompletionService (Facade)"]
direction TB
Complete[complete]
Stream[completeStream]
WithTransformers[completeWithTransformers]
StreamTransformers[completeStreamWithTransformers]
WithTools[streamWithTools]
TestModel[testModel]
end
subgraph PipelineSteps ["파이프라인 단계"]
direction TB
S1["(1) 라우트 결정<br/>resolveRoutedModel"]
S2["(2) 프로바이더 조회<br/>getProvider + 활성화 확인"]
S3["(3) API 키 결정<br/>codingPlan → pool → legacy"]
S4["(4) API 포맷 결정<br/>resolveApiFormat"]
S5["(5) 핸들러 디스패치<br/>callDirectHandler / callStreamHandler"]
S6["(6) 재시도 + 성공 보고"]
end
subgraph Handlers
DAH[DirectApiHandler<br/>비스트리밍]
SH[StreamHandler<br/>SSE 스트리밍]
TH[ToolHandler<br/>도구 루프]
THR[TransformerHandler<br/>트랜스포머 체인]
end
subgraph AuxiliaryServices ["보조 서비스"]
TR2[ThinkingResolver]
NSI[NativeSearchInjector]
PSI[ProviderSearchInjector]
MC[Message Converter]
UB[URL Builder]
HB[Header Builder]
end
Complete --> S1 --> S2 --> S3 --> S4 --> S5 --> S6
S5 --> DAH
S5 --> SH
WithTools --> TH
WithTransformers --> THR
StreamTransformers --> THR
DAH --> UB
DAH --> HB
DAH --> MC
SH --> UB
SH --> HB
SH --> MC
SH --> TR2
TH --> UB
TH --> HB
데이터 구조
요청 및 응답 타입
// Completion 요청 옵션
interface CompletionOptions {
providerId: string; // 프로바이더 ID
model: string; // 모델 ID (v89+: 베어 SDK id, `<backend>:` 접두사 또는 `[1m]` 접미사 없음)
messages: SimpleChatMessage[]; // 대화 메시지
maxTokens?: number; // 최대 생성 토큰 수
temperature?: number; // 온도
stream?: boolean; // 스트리밍 여부
thinkLevel?: ThinkLevel; // 사고 수준: 'none' | 'low' | 'medium' | 'high'
nativeSearchAugmentation?: NativeSearchAugmentation; // SDK 네이티브 검색 증강
sessionId?: string; // 세션 ID (API 키 풀 어피니티)
/**
* 1M 컨텍스트 플래그 (v89+). true이고 모델이 1M 지원 화이트리스트
* (`claude-opus-4-7` / `claude-opus-4-6` / `claude-sonnet-4-6`)에 포함되면
* `TransformerHandler`가 트랜스포머 체인 종료 시 `injectExtendedContextBeta()`를 호출하여
* `'context-1m-2025-08-07'`을 아웃바운드 요청의
* `anthropic-beta` HTTP 헤더에 병합합니다
* (바디 필드 아님; `/v1/messages`는 알 수 없는 바디 필드를 거부합니다).
*/
useExtendedContext?: boolean;
}
// Completion 결과
interface CompletionResult {
success: boolean;
message?: SimpleChatMessage; // 생성된 메시지
error?: string; // 오류 메시지
usage?: {
promptTokens: number;
completionTokens: number;
totalTokens: number;
};
finishReason?: string; // 'stop' | 'tool_use' | 'max_tokens' 등
}
// 스트리밍 콜백
interface StreamCallbacks {
onStart?: (messageId: string) => void;
onDelta?: (content: string) => void;
onReasoning?: (reasoning: string) => void;
onAudio?: (audio: SimpleChatAudio) => void;
onVideo?: (video: SimpleChatVideo) => void;
onBlock?: (block: MessageBlock) => void; // 콘텐츠 블록: thinking/text/tool_use/tool_result
onDone?: (message, usage?, metrics?) => void;
onError?: (error: string) => void;
}
// API 포맷
type ApiFormat = 'openai' | 'anthropic' | 'google' | 'azure-openai' | 'openai-response';
알고리즘 및 로직
6단계 요청 파이프라인
1단계: 라우트 결정
routedInfo = llmConfig.resolveRoutedModel(providerId, model)
actualProviderId = routedInfo?.actualProviderId || providerId
actualModel = routedInfo?.actualModelId || model
라우트 결정은 채팅 → 코드, 코드 → 채팅 모델 라우팅을 처리합니다. model이 라우팅 규칙에 매칭되면 실제 프로바이더와 모델로 대체됩니다.
2단계: 프로바이더 조회
provider = getProvider(actualProviderId)
if (!provider) → return error "Provider not found"
if (!provider.enabled) → return error "Provider is disabled"
조회는 LLMConfigService의 프로바이더 인덱스를 통해 O(1) 조회로 처리됩니다.
3단계: API 키 결정
resolveApiKeyForRequest(provider, providerId, sessionId):
// 우선순위 1: Coding Plan 오버라이드
if provider.codingPlan?.enabled && provider.codingPlan.apiKey:
return resolveApiKey(codingPlan.apiKey)
// 우선순위 2: API 키 풀 (세션 어피니티 가중 라운드 로빈)
if apiKeyPool available:
poolKey = sessionId
? apiKeyPool.getKeyForSession(providerId, sessionId)
: apiKeyPool.getKey(providerId)
if poolKey: return poolKey
// 우선순위 3: 레거시 단일 키
return resolveApiKey(provider.api_key)
resolveApiKey()는 환경 변수 확장을 처리합니다 ($ENV_VAR → process.env.ENV_VAR).
4단계: API 포맷 결정
resolveApiFormat(provider):
// 우선순위 1: apiFormat 필드 (v3 권장)
if provider.apiFormat: return provider.apiFormat
// 우선순위 2: chatApiFormat 필드 (레거시 v3)
if provider.chatApiFormat: return provider.chatApiFormat
// 우선순위 3: 암묵적 apiType 변환
if provider.apiType === 'claudecode' || 'anthropic': return 'anthropic'
if provider.apiType === 'google': return 'google'
// 기본값: OpenAI 포맷
return 'openai'
5단계: 핸들러 디스패치
apiFormat에 따라 적절한 핸들러를 선택합니다:
| apiFormat | 비스트리밍 핸들러 | 스트리밍 핸들러 |
|---|---|---|
openai | callOpenAICompletion | streamOpenAICompletion |
openai-response | callOpenAIResponseCompletion | streamOpenAIResponseCompletion |
anthropic | callAnthropicCompletion | streamAnthropicCompletion |
google | callGeminiCompletion | streamGeminiCompletion |
azure-openai | callOpenAICompletion | streamOpenAICompletion |
6단계: 재시도 + 성공 보고
result = callHandler(...)
if result failed && apiKeyPool available && sessionId present:
status = extractHttpStatus(result.error)
if status in [429, 529, 401, 403]:
newKey = apiKeyPool.reportError(providerId, sessionId, status)
if newKey:
result = callHandler(..., newKey) // 새 키로 한 번 재시도
if result succeeded && apiKeyPool available:
apiKeyPool.reportSuccess(sessionId) // 쿨다운 카운터 초기화
스트리밍
SSE 스트림 파싱
모든 스트리밍 핸들러는 표준 SSE (Server-Sent Events) 프로토콜 기반의 streamSSEResponse() 유틸리티를 사용합니다:
sequenceDiagram
participant CS as CompletionService
participant SH as StreamHandler
participant API as Provider API
CS->>SH: callStreamHandler(format, provider, key, options)
SH->>API: POST 요청 (stream: true)
API-->>SH: SSE 스트림
loop 각 SSE 이벤트
SH->>SH: 이벤트 데이터 파싱
alt 콘텐츠 델타
SH->>CS: callbacks.onDelta(content)
else 추론 델타
SH->>CS: callbacks.onReasoning(reasoning)
else 블록 이벤트
SH->>CS: callbacks.onBlock(block)
else [DONE]
SH->>CS: callbacks.onDone(message, usage, metrics)
else 오류
SH->>CS: callbacks.onError(error)
end
end
스트림 재시도 (풀 모드)
flowchart TD
Start[completeStream] --> HasPool{apiKeyPool 사용 가능?}
HasPool -->|아니오| DirectCall[callStreamHandler 직접 호출]
HasPool -->|예| InterceptCall[인터셉트 콜백으로 호출]
InterceptCall --> StreamDone{스트림 정상 완료?}
StreamDone -->|예| ReportSuccess[reportSuccess]
StreamDone -->|아니오| CheckStatus{429/529/401/403?}
CheckStatus -->|예| GetNewKey[reportError → 새 키 획득]
CheckStatus -->|아니오| PropagatError[프론트엔드로 오류 전파]
GetNewKey --> HasNewKey{새 키 사용 가능?}
HasNewKey -->|예| RetryStream[새 키로 스트림 재시도]
HasNewKey -->|아니오| PropagatError
스트림 재시도의 핵심 설계 포인트:
- 429/529 오류는
callbacks.onError를 래핑하여 인터셉트됩니다 - 클로저 간 오류 상태 추적을 위해
retryState객체 참조를 사용합니다 - 재시도 시
onStart는 다시 트리거되지 않습니다 (이미 한 번 발생했으므로)
Anthropic 사고 예산 계산
flowchart TD
Start[resolveThinkingBudget] --> CheckLevel{thinkLevel === 'none'?}
CheckLevel -->|예| NoThinking[원시 maxTokens 반환<br/>사고 설정 없음]
CheckLevel -->|아니오| CheckModel{isReasoningModel?}
CheckModel -->|아니오| NoThinking
CheckModel -->|예| CalcBudget[calculateThinkingBudget<br/>model, thinkLevel, maxTokens]
CalcBudget --> CheckFormat{API 포맷이 Anthropic?}
CheckFormat -->|예| AdjustTokens[adjustedMaxTokens = getClaudeMaxTokens<br/>maxTokens - thinkingBudget]
CheckFormat -->|아니오| KeepTokens[adjustedMaxTokens = maxTokens]
AdjustTokens --> BuildConfig[buildAnthropicThinking<br/>사고 설정 객체 생성]
BuildConfig --> Return[adjustedMaxTokens + thinkingConfig 반환]
KeepTokens --> Return
Anthropic 특수 처리: Claude 모델의 경우 max_tokens에 사고 토큰이 포함되므로 다음이 필요합니다:
- 사고 예산
thinkingBudget계산 max_tokens에서 사고 예산을 빼서adjustedMaxTokens도출- 요청 바디에 포함할
thinking설정 객체 생성
OpenAI 추론 모델
OpenAI o 시리즈 모델(o1, o3 등)의 경우 토큰 예산 조정 대신 reasoning_effort 파라미터를 사용합니다:
if thinkLevel !== 'none':
effort = getOpenAIReasoningEffort(thinkLevel)
// 'low' | 'medium' | 'high'
request.reasoning_effort = effort
max_tokens 결정 우선순위
resolveEffectiveMaxTokens(providerId, modelId, sessionMaxTokens):
// 1. 세션 수준 설정 (최우선, 사용자가 수동 설정, 상한 없음)
if sessionMaxTokens > 0: return sessionMaxTokens
// 2. 전역 모델 파라미터 (관리자 설정, 상한 없음)
globalParams = llmConfig.getGlobalModelParameters()
if globalParams.maxTokens.enabled && value > 0: return value
// -------- 아래 값들은 자동 결정되며 MAX_TOKENS_CAP=65536으로 제한 --------
// 3. 모델 설정의 maxTokens
modelConfig = provider.modelConfigs.find(id === modelId)
if modelConfig.maxTokens > 0: return min(value, 65536)
// 4. 모델 그룹의 maxTokens
modelGroup = provider.modelGroups.find(models.id === modelId)
if model.maxTokens > 0: return min(value, 65536)
// 5. 모델 탐색 캐시
discovered = llmConfig.getDiscoveredModelMaxTokens(providerId, modelId)
if discovered > 0: return min(value, 65536)
// 6. undefined (API 기본값 사용)
return undefined
// max_tokens가 필수인 프로바이더(예: Anthropic)의 경우:
getRequiredMaxTokens():
resolved = resolveEffectiveMaxTokens(...)
return resolved ?? DEFAULT_MAX_TOKENS // 일반적으로 4096
도구 호출 루프 (ToolHandler)
sequenceDiagram
participant TH as ToolHandler
participant LLM as LLM API
participant MCP as MCP Service
TH->>TH: MAX_ITERATIONS = globalParams.toolMaxTurns ?? 5
TH->>TH: iteration = 0
loop iteration < MAX_ITERATIONS
TH->>TH: iteration++
TH->>TH: buildToolRequest(format, messages, model, options)
TH->>LLM: 도구 포함 POST 요청
LLM-->>TH: SSE 스트림 응답
TH->>TH: extractToolCalls(response)
alt 도구 호출 없음
TH->>TH: break (LLM이 답변 완료)
else 도구 호출 있음
loop 각 도구 호출
TH->>TH: callbacks.onToolCall(toolCall)
TH->>MCP: executeToolCalls(toolCalls, mcpService)
MCP-->>TH: 도구 결과
TH->>TH: callbacks.onToolResult(id, result)
end
TH->>TH: 메시지에 도구 호출 및 결과 추가
TH->>TH: buildIterationBlocks(도구 호출 블록)
end
end
TH->>TH: callbacks.onDone(finalContent, usage)
핵심 동작:
| 파라미터 | 기본값 | 설명 |
|---|---|---|
MAX_ITERATIONS | globalParams.toolMaxTurns ?? 5 | 최대 반복 횟수 |
| 도구 포맷 | apiFormat에서 자동 감지 | OpenAI/Anthropic/Gemini 포맷의 도구 정의 |
| 종료 조건 | 도구 호출 없음 또는 한도 도달 | LLM이 도구 요청을 멈추면 자연 종료 |
도구 호출은 세 가지 포맷을 지원하며 logToolFormat()으로 자동 감지됩니다:
- OpenAI 포맷:
{ type: 'function', function: { name, parameters } } - Anthropic 포맷:
{ name, input_schema } - Gemini 포맷:
{ functionDeclarations: [...] }
비전 폴백
메시지에 이미지가 포함되어 있지만 모델이 비전을 지원하지 않는 경우 보조 비전 모델이 자동으로 사용됩니다:
applyVisionFallback(options):
if no images in messages: return
if model supports vision: return
visionModel = llmConfig.resolveEffectiveModels().vision
if no vision model:
// 이미지 제거
for msg in messages:
msg.images = undefined
return
// VisionDescriptionService를 사용하여 이미지 설명
for msg in messages with images:
description = visionService.describeImages(images, msg.content, visionModel)
msg.content += "\n\n[이미지 설명]\n" + description
msg.images = undefined
오류 인터셉트 패턴
extractHttpStatus()는 오류 메시지 문자열에서 HTTP 상태 코드를 추출합니다:
extractHttpStatus(error: string):
match = error.match(/\((\d{3})\):/)
return match ? parseInt(match[1]) : null
// 예시: "API error (429): Rate limit exceeded" → 429
IPC 통합 테이블
| IPC 채널 | 방향 | 라우터 | 설명 |
|---|---|---|---|
completion:complete | R → M | CompletionRouter | 비스트리밍 completion |
completion:getModels | R → M | CompletionRouter | 사용 가능한 모델 목록 조회 |
completion:testModel | R → M | CompletionRouter | 모델 연결 테스트 |
| 스트리밍 completion | R → M | ChatStreamHandler | IPC 메시지를 통해 전달되는 SSE 스트림 |
| 재생성 | R → M | RegenerateHandler | 답변 재생성 |
확장 포인트
새 API 포맷 추가
types.ts의ApiFormat타입에 새 값 추가url-builder.ts에 URL 빌더 함수 추가header-builder.ts에 헤더 빌드 로직 추가message-converter.ts에 메시지 포맷 변환 추가DirectApiHandler.ts에callXxxCompletion함수 추가StreamHandler.ts에streamXxxCompletion함수 추가CompletionService.callDirectHandler()와callStreamHandler()의 switch에 케이스 추가
커스텀 재시도 전략
현재는 재시도를 한 번만 시도합니다. 더 복잡한 재시도 동작(예: 여러 번 재시도, 다양한 대기 시간)이 필요하다면 complete()와 completeStream()의 재시도 로직을 수정하세요.
새 검색 주입 추가
- SDK 네이티브 검색 (예: Anthropic):
NativeSearchInjector.applyAugmentation()을 통해 주입 - 프로바이더별 검색 (예: model-param / builtin-tool):
ProviderSearchInjector를 통해 주입
관련 파일
| 파일 | 관계 |
|---|---|
capabilities/llm/config-service/LLMConfigService.ts | 프로바이더 조회 및 라우트 결정 제공 |
capabilities/llm/completion/ApiKeyPoolService.ts | 키 선택 및 로드 밸런싱 |
infra/utils/sse-parser.ts | SSE 스트림 파싱 유틸리티 |
shared/completion-types.ts | SimpleChatMessage 등 공유 타입 |
shared/thinking-config.ts | 사고 예산 계산 및 추론 모델 감지 |
shared/llm-config.ts | LLMProvider 타입 정의 |
capabilities/tools/mcp-users/McpService.ts | 도구 실행 (ToolHandler에서 호출) |
capabilities/llm/api-converter/openai-to-anthropic.ts | OpenAI → Anthropic 포맷 변환 |
routers/CompletionRouter.ts | IPC 진입점 |