diff --git a/scripts/docs-i18n/pi_rpc_client.go b/scripts/docs-i18n/pi_rpc_client.go index d995c6a171f..7535b04799b 100644 --- a/scripts/docs-i18n/pi_rpc_client.go +++ b/scripts/docs-i18n/pi_rpc_client.go @@ -73,8 +73,8 @@ func startDocsPiClient(ctx context.Context, options docsPiClientOptions) (*docsP args := append([]string{}, command.Args...) args = append(args, "--mode", "rpc", - "--provider", "anthropic", - "--model", modelVersion, + "--provider", docsPiProvider(), + "--model", docsPiModel(), "--thinking", options.Thinking, "--no-session", ) diff --git a/scripts/docs-i18n/process.go b/scripts/docs-i18n/process.go index c792d3c11bc..cbcd1d4abc2 100644 --- a/scripts/docs-i18n/process.go +++ b/scripts/docs-i18n/process.go @@ -64,8 +64,8 @@ func processFile(ctx context.Context, translator *PiTranslator, tm *TranslationM TextHash: seg.TextHash, Text: seg.Text, Translated: translated, - Provider: providerName, - Model: modelVersion, + Provider: docsPiProvider(), + Model: docsPiModel(), SrcLang: srcLang, TgtLang: tgtLang, UpdatedAt: time.Now().UTC().Format(time.RFC3339), @@ -121,8 +121,8 @@ func encodeFrontMatter(frontData map[string]any, relPath string, source []byte) frontData["x-i18n"] = map[string]any{ "source_path": relPath, "source_hash": hashBytes(source), - "provider": providerName, - "model": modelVersion, + "provider": docsPiProvider(), + "model": docsPiModel(), "workflow": workflowVersion, "generated_at": time.Now().UTC().Format(time.RFC3339), } @@ -191,8 +191,8 @@ func translateSnippet(ctx context.Context, translator *PiTranslator, tm *Transla TextHash: textHash, Text: textValue, Translated: translated, - Provider: providerName, - Model: modelVersion, + Provider: docsPiProvider(), + Model: docsPiModel(), SrcLang: srcLang, TgtLang: tgtLang, UpdatedAt: time.Now().UTC().Format(time.RFC3339), diff --git a/scripts/docs-i18n/translator.go b/scripts/docs-i18n/translator.go index 122a30ec5d5..59a41959af1 100644 --- a/scripts/docs-i18n/translator.go +++ b/scripts/docs-i18n/translator.go @@ -4,14 +4,16 @@ import ( "context" "errors" "fmt" + "os" "strings" "time" ) const ( - translateMaxAttempts = 3 - translateBaseDelay = 15 * time.Second - translatePromptTimeout = 2 * time.Minute + translateMaxAttempts = 3 + translateBaseDelay = 15 * time.Second + defaultPromptTimeout = 2 * time.Minute + envDocsI18nPromptTimeout = "OPENCLAW_DOCS_I18N_PROMPT_TIMEOUT" ) var errEmptyTranslation = errors.New("empty translation") @@ -112,10 +114,16 @@ func isRetryableTranslateError(err error) bool { if err == nil { return false } + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + return false + } if errors.Is(err, errEmptyTranslation) { return true } message := strings.ToLower(err.Error()) + if strings.Contains(message, "authentication failed") { + return false + } return strings.Contains(message, "placeholder missing") || strings.Contains(message, "rate limit") || strings.Contains(message, "429") } @@ -142,7 +150,7 @@ type promptRunner interface { } func runPrompt(ctx context.Context, client promptRunner, message string) (string, error) { - promptCtx, cancel := context.WithTimeout(ctx, translatePromptTimeout) + promptCtx, cancel := context.WithTimeout(ctx, docsI18nPromptTimeout()) defer cancel() result, err := client.Prompt(promptCtx, message) @@ -171,3 +179,15 @@ func normalizeThinking(value string) string { return "high" } } + +func docsI18nPromptTimeout() time.Duration { + value := strings.TrimSpace(os.Getenv(envDocsI18nPromptTimeout)) + if value == "" { + return defaultPromptTimeout + } + parsed, err := time.ParseDuration(value) + if err != nil || parsed <= 0 { + return defaultPromptTimeout + } + return parsed +} diff --git a/scripts/docs-i18n/translator_test.go b/scripts/docs-i18n/translator_test.go index 3872d6dff07..759f3aa276a 100644 --- a/scripts/docs-i18n/translator_test.go +++ b/scripts/docs-i18n/translator_test.go @@ -50,11 +50,35 @@ func TestRunPromptAddsTimeout(t *testing.T) { } remaining := time.Until(deadline) - if remaining <= time.Minute || remaining > translatePromptTimeout { + if remaining <= time.Minute || remaining > docsI18nPromptTimeout() { t.Fatalf("unexpected timeout window %s", remaining) } } +func TestDocsI18nPromptTimeoutUsesEnvOverride(t *testing.T) { + t.Setenv(envDocsI18nPromptTimeout, "5m") + + if got := docsI18nPromptTimeout(); got != 5*time.Minute { + t.Fatalf("expected 5m timeout, got %s", got) + } +} + +func TestIsRetryableTranslateErrorRejectsDeadlineExceeded(t *testing.T) { + t.Parallel() + + if isRetryableTranslateError(context.DeadlineExceeded) { + t.Fatal("deadline exceeded should not retry") + } +} + +func TestIsRetryableTranslateErrorRejectsAuthenticationFailures(t *testing.T) { + t.Parallel() + + if isRetryableTranslateError(errors.New(`Authentication failed for "openai"`)) { + t.Fatal("auth failures should not retry") + } +} + func TestRunPromptIncludesStderr(t *testing.T) { t.Parallel() diff --git a/scripts/docs-i18n/util.go b/scripts/docs-i18n/util.go index 3be70ee3076..e7a8e6daaf3 100644 --- a/scripts/docs-i18n/util.go +++ b/scripts/docs-i18n/util.go @@ -10,13 +10,24 @@ import ( ) const ( - workflowVersion = 15 - providerName = "pi" - modelVersion = "claude-opus-4-6" + workflowVersion = 15 + docsI18nEngineName = "pi" + envDocsI18nProvider = "OPENCLAW_DOCS_I18N_PROVIDER" + envDocsI18nModel = "OPENCLAW_DOCS_I18N_MODEL" + defaultOpenAIModel = "gpt-5.4" + defaultAnthropicModel = "claude-opus-4-6" + defaultFallbackProvider = "openai" + defaultFallbackModelName = defaultOpenAIModel ) func cacheNamespace() string { - return fmt.Sprintf("wf=%d|provider=%s|model=%s", workflowVersion, providerName, modelVersion) + return fmt.Sprintf( + "wf=%d|engine=%s|provider=%s|model=%s", + workflowVersion, + docsI18nEngineName, + docsPiProvider(), + docsPiModel(), + ) } func cacheKey(namespace, srcLang, tgtLang, segmentID, textHash string) string { @@ -40,6 +51,33 @@ func normalizeText(text string) string { return strings.Join(strings.Fields(strings.TrimSpace(text)), " ") } +func docsPiProvider() string { + if value := strings.TrimSpace(os.Getenv(envDocsI18nProvider)); value != "" { + return value + } + if strings.TrimSpace(os.Getenv("OPENAI_API_KEY")) != "" { + return "openai" + } + if strings.TrimSpace(os.Getenv("ANTHROPIC_API_KEY")) != "" { + return "anthropic" + } + return defaultFallbackProvider +} + +func docsPiModel() string { + if value := strings.TrimSpace(os.Getenv(envDocsI18nModel)); value != "" { + return value + } + switch docsPiProvider() { + case "anthropic": + return defaultAnthropicModel + case "openai": + return defaultOpenAIModel + default: + return defaultFallbackModelName + } +} + func segmentID(relPath, textHash string) string { shortHash := textHash if len(shortHash) > 16 { diff --git a/scripts/docs-i18n/util_test.go b/scripts/docs-i18n/util_test.go new file mode 100644 index 00000000000..77b5ca82a73 --- /dev/null +++ b/scripts/docs-i18n/util_test.go @@ -0,0 +1,41 @@ +package main + +import "testing" + +func TestDocsPiProviderPrefersExplicitOverride(t *testing.T) { + t.Setenv(envDocsI18nProvider, "anthropic") + t.Setenv("OPENAI_API_KEY", "openai-key") + t.Setenv("ANTHROPIC_API_KEY", "anthropic-key") + + if got := docsPiProvider(); got != "anthropic" { + t.Fatalf("expected anthropic override, got %q", got) + } +} + +func TestDocsPiProviderPrefersOpenAIEnvWhenAvailable(t *testing.T) { + t.Setenv(envDocsI18nProvider, "") + t.Setenv("OPENAI_API_KEY", "openai-key") + t.Setenv("ANTHROPIC_API_KEY", "anthropic-key") + + if got := docsPiProvider(); got != "openai" { + t.Fatalf("expected openai provider, got %q", got) + } +} + +func TestDocsPiModelUsesProviderDefault(t *testing.T) { + t.Setenv(envDocsI18nProvider, "anthropic") + t.Setenv(envDocsI18nModel, "") + + if got := docsPiModel(); got != defaultAnthropicModel { + t.Fatalf("expected anthropic default model, got %q", got) + } +} + +func TestDocsPiModelPrefersExplicitOverride(t *testing.T) { + t.Setenv(envDocsI18nProvider, "openai") + t.Setenv(envDocsI18nModel, "gpt-5.2") + + if got := docsPiModel(); got != "gpt-5.2" { + t.Fatalf("expected explicit model override, got %q", got) + } +}