diff --git a/CLAUDE.md b/CLAUDE.md index b0648d9..c0ff468 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,6 +67,7 @@ HWP/HWPX → Stage 1 (Parser) → IR → Stage 2 (LLM, optional) → Markdown | `HWP2MD_LLM` | Enable Stage 2: `true` | | `HWP2MD_MODEL` | Model name (auto-detects provider) | | `HWP2MD_BASE_URL` | Private API endpoint (Bedrock, Azure, local) | +| `HWP2MD_TIMEOUT` | LLM request timeout (Go duration: `5m`, `300s`, `10m30s`). Empty → provider default | | `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GOOGLE_API_KEY`, `UPSTAGE_API_KEY` | Provider API keys | ## Conventions diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 9389553..a92da7d 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -2,7 +2,9 @@ package cli import ( "os" + "strings" "testing" + "time" ) func TestSetVersion(t *testing.T) { @@ -238,3 +240,50 @@ func TestDetectProviderFromModel(t *testing.T) { }) } } + +func TestParseLLMTimeout(t *testing.T) { + tests := []struct { + name string + flag string + env string + want time.Duration + wantErr bool + wantErrContain string // optional substring expected in the error message + }{ + {name: "both empty falls back to provider default", flag: "", env: "", want: 0}, + {name: "flag value parsed", flag: "5m", want: 5 * time.Minute}, + {name: "env value parsed when flag empty", env: "300s", want: 300 * time.Second}, + {name: "flag overrides env", flag: "10m", env: "30s", want: 10 * time.Minute}, + {name: "compound duration", flag: "10m30s", want: 10*time.Minute + 30*time.Second}, + {name: "whitespace trimmed", flag: " 2m ", want: 2 * time.Minute}, + {name: "whitespace flag falls back to env", flag: " ", env: "5m", want: 5 * time.Minute}, + {name: "whitespace env returns provider default", env: "\t", want: 0}, + {name: "invalid flag is reported as flag", flag: "abc", env: "5m", wantErr: true, wantErrContain: "--timeout"}, + {name: "invalid env is reported as env", env: "abc", wantErr: true, wantErrContain: "HWP2MD_TIMEOUT"}, + {name: "invalid env returns error", env: "5", wantErr: true}, + {name: "zero rejected", flag: "0s", wantErr: true, wantErrContain: "양수"}, + {name: "negative rejected", flag: "-1s", wantErr: true, wantErrContain: "양수"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := parseLLMTimeout(tc.flag, tc.env) + if tc.wantErr { + if err == nil { + t.Fatalf("parseLLMTimeout(%q, %q) expected error, got nil", tc.flag, tc.env) + } + if tc.wantErrContain != "" && !strings.Contains(err.Error(), tc.wantErrContain) { + t.Errorf("parseLLMTimeout(%q, %q) error %q does not contain %q", + tc.flag, tc.env, err.Error(), tc.wantErrContain) + } + return + } + if err != nil { + t.Fatalf("parseLLMTimeout(%q, %q) unexpected error: %v", tc.flag, tc.env, err) + } + if got != tc.want { + t.Errorf("parseLLMTimeout(%q, %q) = %v, want %v", tc.flag, tc.env, got, tc.want) + } + }) + } +} diff --git a/internal/cli/config.go b/internal/cli/config.go index 57b89de..baa1384 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -125,6 +125,7 @@ func runConfigShow(cmd *cobra.Command, args []string) error { {"HWP2MD_LLM", "LLM 활성화", os.Getenv("HWP2MD_LLM")}, {"HWP2MD_MODEL", "모델 (프로바이더 자동 감지)", os.Getenv("HWP2MD_MODEL")}, {"HWP2MD_BASE_URL", "프라이빗 API 엔드포인트", os.Getenv("HWP2MD_BASE_URL")}, + {"HWP2MD_TIMEOUT", "LLM 요청 타임아웃 (예: 5m, 300s; 미설정 시 프로바이더 기본값)", os.Getenv("HWP2MD_TIMEOUT")}, {"ANTHROPIC_API_KEY", "Anthropic API 키", maskAPIKey(os.Getenv("ANTHROPIC_API_KEY"))}, {"OPENAI_API_KEY", "OpenAI API 키", maskAPIKey(os.Getenv("OPENAI_API_KEY"))}, {"GOOGLE_API_KEY", "Google API 키", maskAPIKey(os.Getenv("GOOGLE_API_KEY"))}, diff --git a/internal/cli/convert.go b/internal/cli/convert.go index c82b4dc..a7b5b94 100644 --- a/internal/cli/convert.go +++ b/internal/cli/convert.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/roboco-io/hwp2md/internal/config" "github.com/roboco-io/hwp2md/internal/ir" @@ -31,6 +32,7 @@ var ( convertParser string convertExtractImgs bool convertImagesDir string + convertTimeout string convertVerbose bool convertQuiet bool ) @@ -49,6 +51,7 @@ var convertCmd = &cobra.Command{ HWP2MD_LLM=true Stage 2 활성화 HWP2MD_MODEL=xxx 모델 이름 (프로바이더 자동 감지) HWP2MD_BASE_URL=xxx 프라이빗 API 엔드포인트 (Bedrock, 로컬 서버 등) + HWP2MD_TIMEOUT=xxx LLM 요청 타임아웃 (예: 5m, 300s, 10m30s) 모델 이름 예시: claude-* → Anthropic @@ -74,6 +77,7 @@ var convertCmd = &cobra.Command{ hwp2md convert document.hwpx --llm --model gpt-4o hwp2md convert document.hwpx --llm --model solar-pro hwp2md convert document.hwpx --llm --base-url http://localhost:8080 + hwp2md convert document.hwpx --llm --timeout 10m hwp2md convert document.hwpx --extract-images ./images`, Args: cobra.ExactArgs(1), RunE: runConvert, @@ -88,6 +92,7 @@ func init() { convertCmd.Flags().StringVar(&convertParser, "parser", "", "파서 선택 (native, upstage)") convertCmd.Flags().BoolVar(&convertExtractImgs, "extract-images", false, "이미지 추출 활성화") convertCmd.Flags().StringVar(&convertImagesDir, "images-dir", "./images", "추출된 이미지 저장 디렉토리") + convertCmd.Flags().StringVar(&convertTimeout, "timeout", "", "LLM 요청 타임아웃 (예: 5m, 300s; 미지정 시 프로바이더 기본값)") convertCmd.Flags().BoolVarP(&convertVerbose, "verbose", "v", false, "상세 출력") convertCmd.Flags().BoolVarP(&convertQuiet, "quiet", "q", false, "조용한 모드") @@ -146,7 +151,7 @@ func runConvert(cmd *cobra.Command, args []string) error { } // Stage 2: LLM formatting var result *llm.FormatResult - markdown, result, err = formatWithLLM(cmd, doc) + markdown, result, err = formatWithLLM(doc) if err != nil { return fmt.Errorf("LLM 포맷팅 실패: %w", err) } @@ -243,7 +248,7 @@ func detectProviderFromModel(model string) string { } } -func formatWithLLM(cmd *cobra.Command, doc *ir.Document) (string, *llm.FormatResult, error) { +func formatWithLLM(doc *ir.Document) (string, *llm.FormatResult, error) { // Determine model (from flag or env) model := convertModel if model == "" { @@ -256,6 +261,12 @@ func formatWithLLM(cmd *cobra.Command, doc *ir.Document) (string, *llm.FormatRes baseURL = os.Getenv("HWP2MD_BASE_URL") } + // Determine LLM request timeout (flag > env). Zero means provider default. + timeout, err := parseLLMTimeout(convertTimeout, os.Getenv("HWP2MD_TIMEOUT")) + if err != nil { + return "", nil, err + } + // Auto-detect provider from model name, or use explicit flag providerName := convertProvider if providerName == "" { @@ -264,33 +275,37 @@ func formatWithLLM(cmd *cobra.Command, doc *ir.Document) (string, *llm.FormatRes // Create provider var provider llm.Provider - var err error switch providerName { case "openai": provider, err = openai.New(openai.Config{ Model: model, BaseURL: baseURL, + Timeout: timeout, }) case "anthropic": provider, err = anthropic.New(anthropic.Config{ Model: model, BaseURL: baseURL, + Timeout: timeout, }) case "gemini": // Gemini does not support custom base URL (uses Google API only) provider, err = gemini.New(gemini.Config{ - Model: model, + Model: model, + Timeout: timeout, }) case "upstage": provider, err = llmupstage.New(llmupstage.Config{ Model: model, BaseURL: baseURL, + Timeout: timeout, }) case "ollama": provider, err = ollama.New(ollama.Config{ Model: model, BaseURL: baseURL, + Timeout: timeout, }) default: return "", nil, fmt.Errorf("지원하지 않는 프로바이더: %s (지원: openai, anthropic, gemini, upstage, ollama)", providerName) @@ -313,6 +328,32 @@ func formatWithLLM(cmd *cobra.Command, doc *ir.Document) (string, *llm.FormatRes return result.Markdown, result, nil } +// parseLLMTimeout resolves the LLM request timeout from the --timeout flag +// or the HWP2MD_TIMEOUT environment variable. The flag takes precedence; +// when both are empty it returns 0 so each provider applies its own default. +// The value must be a Go duration string (e.g. "5m", "300s", "10m30s") and +// must be positive. +func parseLLMTimeout(flagVal, envVal string) (time.Duration, error) { + raw := strings.TrimSpace(flagVal) + source := "--timeout" + if raw == "" { + raw = strings.TrimSpace(envVal) + source = "HWP2MD_TIMEOUT" + } + if raw == "" { + return 0, nil + } + + d, err := time.ParseDuration(raw) + if err != nil { + return 0, fmt.Errorf("%s 값 파싱 실패 (%q): `5m`, `300s`, `10m30s` 같은 시간 형식이어야 합니다: %w", source, raw, err) + } + if d <= 0 { + return 0, fmt.Errorf("%s 값은 양수여야 합니다 (입력: %q)", source, raw) + } + return d, nil +} + func convertToBasicMarkdown(doc *ir.Document) string { // If RawMarkdown is available (e.g., from Upstage parser), use it directly if doc.RawMarkdown != "" {