mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-28 06:31:11 +00:00
fix: use codex for docs i18n
This commit is contained in:
parent
b2d102109b
commit
3945193257
14 changed files with 337 additions and 1062 deletions
|
|
@ -187,6 +187,9 @@ func validateDocChunkTranslation(source, translated string) error {
|
|||
if hasUnexpectedTopLevelProtocolWrapper(source, translated) {
|
||||
return fmt.Errorf("protocol token leaked: top-level wrapper")
|
||||
}
|
||||
if err := validateNoTranslationTranscriptArtifacts(source, translated); err != nil {
|
||||
return err
|
||||
}
|
||||
sourceLower := strings.ToLower(source)
|
||||
translatedLower := strings.ToLower(translated)
|
||||
for _, token := range docsProtocolTokens {
|
||||
|
|
|
|||
|
|
@ -460,6 +460,21 @@ func TestValidateDocChunkTranslationRejectsProtocolTokenLeakage(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestValidateDocChunkTranslationRejectsTranscriptArtifact(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
source := "Regular paragraph.\n\n"
|
||||
translated := `Regular paragraph. assistant to=functions.read commentary {"path":"/home/runner/work/docs/docs/source/AGENTS.md"} code`
|
||||
|
||||
err := validateDocChunkTranslation(source, translated)
|
||||
if err == nil {
|
||||
t.Fatal("expected transcript artifact to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "agent transcript artifact") {
|
||||
t.Fatalf("expected transcript artifact error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDocChunkTranslationRejectsTopLevelBodyWrapperLeakEvenWhenSourceMentionsBodyTag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
module github.com/openclaw/openclaw/scripts/docs-i18n
|
||||
|
||||
go 1.24.0
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/joshp123/pi-golang v0.0.4
|
||||
github.com/yuin/goldmark v1.7.8
|
||||
golang.org/x/net v0.50.0
|
||||
github.com/yuin/goldmark v1.8.2
|
||||
golang.org/x/net v0.53.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
github.com/joshp123/pi-golang v0.0.4 h1:82HISyKNN8bIl2lvAd65462LVCQIsjhaUFQxyQgg5Xk=
|
||||
github.com/joshp123/pi-golang v0.0.4/go.mod h1:9mHEQkeJELYzubXU3b86/T8yedI/iAOKx0Tz0c41qes=
|
||||
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ func main() {
|
|||
maxFiles: *maxFiles,
|
||||
parallel: *parallel,
|
||||
}, files, func(srcLang, tgtLang string, glossary []GlossaryEntry, thinking string) (docsTranslator, error) {
|
||||
return NewPiTranslator(srcLang, tgtLang, glossary, thinking)
|
||||
return NewCodexTranslator(srcLang, tgtLang, glossary, thinking)
|
||||
}); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,18 @@ func (invalidFrontmatterTranslator) TranslateRaw(_ context.Context, text, _, _ s
|
|||
|
||||
func (invalidFrontmatterTranslator) Close() {}
|
||||
|
||||
type transcriptFrontmatterTranslator struct{}
|
||||
|
||||
func (transcriptFrontmatterTranslator) Translate(_ context.Context, text, _, _ string) (string, error) {
|
||||
return text + ` analysis to=functions.read {"path":"/home/runner/work/docs/docs/source/.agents/skills/openclaw-pr-maintainer/SKILL.md"} code`, nil
|
||||
}
|
||||
|
||||
func (transcriptFrontmatterTranslator) TranslateRaw(_ context.Context, text, _, _ string) (string, error) {
|
||||
return text, nil
|
||||
}
|
||||
|
||||
func (transcriptFrontmatterTranslator) Close() {}
|
||||
|
||||
func TestRunDocsI18NRewritesFinalLocalizedPageLinks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
|
@ -106,3 +118,45 @@ func TestTranslateSnippetDoesNotCacheFallbackToSource(t *testing.T) {
|
|||
t.Fatalf("expected fallback translation not to be cached")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateSnippetRejectsTranscriptArtifact(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tm := &TranslationMemory{entries: map[string]TMEntry{}}
|
||||
source := "Working with reactions across channels"
|
||||
|
||||
translated, err := translateSnippet(context.Background(), transcriptFrontmatterTranslator{}, tm, "tools/reactions.md:frontmatter:read_when:0", source, "en", "th")
|
||||
if err != nil {
|
||||
t.Fatalf("translateSnippet returned error: %v", err)
|
||||
}
|
||||
if translated != source {
|
||||
t.Fatalf("expected fallback to source text, got %q", translated)
|
||||
}
|
||||
|
||||
cacheKey := cacheKey(cacheNamespace(), "en", "th", "tools/reactions.md:frontmatter:read_when:0", hashText(source))
|
||||
if _, ok := tm.Get(cacheKey); ok {
|
||||
t.Fatalf("expected fallback translation not to be cached")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateNoTranslationTranscriptArtifacts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []string{
|
||||
`表情回应 analysis to=functions.read {"path":"/home/runner/work/docs/docs/source/.agents/skills/openclaw-qa-testing/SKILL.md"} code`,
|
||||
`กำลังทำงานกับ reactions to=functions.read commentary  ̄第四色json 皇平台`,
|
||||
`คุณต้องการแผนที่เอกสาร analysis to=final code omitted`,
|
||||
`Potrzebujesz listy funkcji TUI force_parallel: false} code`,
|
||||
`กำลังตัดสินใจว่าจะกำหนดค่าผู้ให้บริการสื่อรายใด 全民彩票 casino`,
|
||||
}
|
||||
for _, translated := range tests {
|
||||
if err := validateNoTranslationTranscriptArtifacts("Working with reactions across channels", translated); err == nil {
|
||||
t.Fatalf("expected artifact to be rejected: %q", translated)
|
||||
}
|
||||
}
|
||||
|
||||
source := "Document `functions.read` examples exactly."
|
||||
if err := validateNoTranslationTranscriptArtifacts(source, "Document `functions.read` examples exactly."); err != nil {
|
||||
t.Fatalf("expected source-owned token to be allowed: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,130 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
envDocsPiExecutable = "OPENCLAW_DOCS_I18N_PI_EXECUTABLE"
|
||||
envDocsPiArgs = "OPENCLAW_DOCS_I18N_PI_ARGS"
|
||||
envDocsPiPackageVersion = "OPENCLAW_DOCS_I18N_PI_PACKAGE_VERSION"
|
||||
envDocsPiOmitProvider = "OPENCLAW_DOCS_I18N_PI_OMIT_PROVIDER"
|
||||
defaultPiPackageVersion = "0.58.3"
|
||||
)
|
||||
|
||||
type docsPiCommand struct {
|
||||
Executable string
|
||||
Args []string
|
||||
}
|
||||
|
||||
var (
|
||||
materializedPiRuntimeMu sync.Mutex
|
||||
materializedPiRuntimeCommand docsPiCommand
|
||||
materializedPiRuntimeErr error
|
||||
)
|
||||
|
||||
func resolveDocsPiCommand(ctx context.Context) (docsPiCommand, error) {
|
||||
if executable := strings.TrimSpace(os.Getenv(envDocsPiExecutable)); executable != "" {
|
||||
return docsPiCommand{
|
||||
Executable: executable,
|
||||
Args: strings.Fields(os.Getenv(envDocsPiArgs)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
piPath, err := exec.LookPath("pi")
|
||||
if err == nil && !shouldMaterializePiRuntime(piPath) {
|
||||
return docsPiCommand{Executable: piPath}, nil
|
||||
}
|
||||
|
||||
return ensureMaterializedPiRuntime(ctx)
|
||||
}
|
||||
|
||||
func shouldMaterializePiRuntime(piPath string) bool {
|
||||
realPath, err := filepath.EvalSymlinks(piPath)
|
||||
if err != nil {
|
||||
realPath = piPath
|
||||
}
|
||||
return strings.Contains(filepath.ToSlash(realPath), "/Projects/pi-mono/")
|
||||
}
|
||||
|
||||
func ensureMaterializedPiRuntime(ctx context.Context) (docsPiCommand, error) {
|
||||
materializedPiRuntimeMu.Lock()
|
||||
defer materializedPiRuntimeMu.Unlock()
|
||||
|
||||
if materializedPiRuntimeErr == nil && materializedPiRuntimeCommand.Executable != "" {
|
||||
return materializedPiRuntimeCommand, nil
|
||||
}
|
||||
|
||||
runtimeDir, err := getMaterializedPiRuntimeDir()
|
||||
if err != nil {
|
||||
materializedPiRuntimeErr = err
|
||||
return docsPiCommand{}, err
|
||||
}
|
||||
cliPath := filepath.Join(runtimeDir, "node_modules", "@mariozechner", "pi-coding-agent", "dist", "cli.js")
|
||||
if _, err := os.Stat(cliPath); errors.Is(err, os.ErrNotExist) {
|
||||
installCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
if err := os.MkdirAll(runtimeDir, 0o755); err != nil {
|
||||
materializedPiRuntimeErr = err
|
||||
return docsPiCommand{}, err
|
||||
}
|
||||
|
||||
packageVersion := getMaterializedPiPackageVersion()
|
||||
install := exec.CommandContext(
|
||||
installCtx,
|
||||
"npm",
|
||||
"install",
|
||||
"--silent",
|
||||
"--no-audit",
|
||||
"--no-fund",
|
||||
fmt.Sprintf("@mariozechner/pi-coding-agent@%s", packageVersion),
|
||||
)
|
||||
install.Dir = runtimeDir
|
||||
install.Env = os.Environ()
|
||||
output, err := install.CombinedOutput()
|
||||
if err != nil {
|
||||
materializedPiRuntimeErr = fmt.Errorf("materialize pi runtime: %w (%s)", err, strings.TrimSpace(string(output)))
|
||||
return docsPiCommand{}, materializedPiRuntimeErr
|
||||
}
|
||||
}
|
||||
|
||||
materializedPiRuntimeCommand = docsPiCommand{
|
||||
Executable: "node",
|
||||
Args: []string{cliPath},
|
||||
}
|
||||
materializedPiRuntimeErr = nil
|
||||
return materializedPiRuntimeCommand, nil
|
||||
}
|
||||
|
||||
func getMaterializedPiRuntimeDir() (string, error) {
|
||||
cacheDir, err := os.UserCacheDir()
|
||||
if err != nil {
|
||||
cacheDir = os.TempDir()
|
||||
}
|
||||
return filepath.Join(cacheDir, "openclaw", "docs-i18n", "pi-runtime", getMaterializedPiPackageVersion()), nil
|
||||
}
|
||||
|
||||
func getMaterializedPiPackageVersion() string {
|
||||
if version := strings.TrimSpace(os.Getenv(envDocsPiPackageVersion)); version != "" {
|
||||
return version
|
||||
}
|
||||
return defaultPiPackageVersion
|
||||
}
|
||||
|
||||
func docsPiOmitProvider() bool {
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv(envDocsPiOmitProvider))) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -1,351 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
type docsPiClientOptions struct {
|
||||
SystemPrompt string
|
||||
Thinking string
|
||||
}
|
||||
|
||||
type docsPiClient struct {
|
||||
process *exec.Cmd
|
||||
stdin io.WriteCloser
|
||||
stderr bytes.Buffer
|
||||
events chan piEvent
|
||||
promptLock sync.Mutex
|
||||
closeOnce sync.Once
|
||||
closed chan struct{}
|
||||
requestID uint64
|
||||
}
|
||||
|
||||
type piEvent struct {
|
||||
Type string
|
||||
Raw json.RawMessage
|
||||
}
|
||||
|
||||
type agentEndPayload struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Messages []agentMessage `json:"messages"`
|
||||
}
|
||||
|
||||
type rpcResponse struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Command string `json:"command,omitempty"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type agentMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
StopReason string `json:"stopReason,omitempty"`
|
||||
ErrorMessage string `json:"errorMessage,omitempty"`
|
||||
}
|
||||
|
||||
type contentBlock struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
func startDocsPiClient(ctx context.Context, options docsPiClientOptions) (*docsPiClient, error) {
|
||||
command, err := resolveDocsPiCommand(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
args := append([]string{}, command.Args...)
|
||||
args = append(args, "--mode", "rpc")
|
||||
if provider := docsPiProviderArg(); provider != "" && !docsPiOmitProvider() {
|
||||
args = append(args, "--provider", provider)
|
||||
}
|
||||
args = append(args,
|
||||
"--model", docsPiModelRef(),
|
||||
"--thinking", options.Thinking,
|
||||
"--no-session",
|
||||
)
|
||||
if strings.TrimSpace(options.SystemPrompt) != "" {
|
||||
args = append(args, "--system-prompt", options.SystemPrompt)
|
||||
}
|
||||
|
||||
process := exec.Command(command.Executable, args...)
|
||||
agentDir, err := resolveDocsPiAgentDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
process.Env = append(os.Environ(), fmt.Sprintf("PI_CODING_AGENT_DIR=%s", agentDir))
|
||||
stdin, err := process.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stdout, err := process.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stderr, err := process.StderrPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := &docsPiClient{
|
||||
process: process,
|
||||
stdin: stdin,
|
||||
events: make(chan piEvent, 256),
|
||||
closed: make(chan struct{}),
|
||||
}
|
||||
|
||||
if err := process.Start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
go client.captureStderr(stderr)
|
||||
go client.readStdout(stdout)
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (client *docsPiClient) Prompt(ctx context.Context, message string) (string, error) {
|
||||
client.promptLock.Lock()
|
||||
defer client.promptLock.Unlock()
|
||||
|
||||
command := map[string]string{
|
||||
"type": "prompt",
|
||||
"id": fmt.Sprintf("req-%d", atomic.AddUint64(&client.requestID, 1)),
|
||||
"message": message,
|
||||
}
|
||||
payload, err := json.Marshal(command)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if _, err := client.stdin.Write(append(payload, '\n')); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
case <-client.closed:
|
||||
return "", errors.New("pi process closed")
|
||||
case event, ok := <-client.events:
|
||||
if !ok {
|
||||
return "", errors.New("pi event stream closed")
|
||||
}
|
||||
if event.Type == "response" {
|
||||
response, err := decodeRpcResponse(event.Raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !response.Success {
|
||||
if strings.TrimSpace(response.Error) == "" {
|
||||
return "", errors.New("pi prompt failed")
|
||||
}
|
||||
return "", errors.New(strings.TrimSpace(response.Error))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if event.Type == "agent_end" {
|
||||
return extractTranslationResult(event.Raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (client *docsPiClient) Stderr() string {
|
||||
return client.stderr.String()
|
||||
}
|
||||
|
||||
func (client *docsPiClient) Close() error {
|
||||
client.closeOnce.Do(func() {
|
||||
close(client.closed)
|
||||
if client.stdin != nil {
|
||||
_ = client.stdin.Close()
|
||||
}
|
||||
if client.process != nil && client.process.Process != nil {
|
||||
_ = client.process.Process.Signal(syscall.SIGTERM)
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
if client.process != nil {
|
||||
_ = client.process.Wait()
|
||||
}
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
if client.process != nil && client.process.Process != nil {
|
||||
_ = client.process.Process.Kill()
|
||||
}
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (client *docsPiClient) captureStderr(stderr io.Reader) {
|
||||
_, _ = io.Copy(&client.stderr, stderr)
|
||||
}
|
||||
|
||||
func (client *docsPiClient) readStdout(stdout io.Reader) {
|
||||
defer close(client.events)
|
||||
|
||||
reader := bufio.NewReader(stdout)
|
||||
for {
|
||||
line, err := reader.ReadBytes('\n')
|
||||
line = bytes.TrimSpace(line)
|
||||
if len(line) > 0 {
|
||||
var envelope struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
if json.Unmarshal(line, &envelope) == nil && envelope.Type != "" {
|
||||
select {
|
||||
case client.events <- piEvent{Type: envelope.Type, Raw: append([]byte{}, line...)}:
|
||||
case <-client.closed:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func extractTranslationResult(raw json.RawMessage) (string, error) {
|
||||
var payload agentEndPayload
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for index := len(payload.Messages) - 1; index >= 0; index-- {
|
||||
message := payload.Messages[index]
|
||||
if message.Role != "assistant" {
|
||||
continue
|
||||
}
|
||||
if message.ErrorMessage != "" || isTerminalPiStopReason(message.StopReason) {
|
||||
text, _ := extractContentText(message.Content)
|
||||
return "", formatPiAgentError(message, text)
|
||||
}
|
||||
text, err := extractContentText(message.Content)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
return "", errors.New("assistant message not found")
|
||||
}
|
||||
|
||||
func isTerminalPiStopReason(stopReason string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(stopReason)) {
|
||||
case "error", "terminated", "cancelled", "canceled", "aborted":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func formatPiAgentError(message agentMessage, assistantText string) error {
|
||||
parts := []string{}
|
||||
if msg := strings.TrimSpace(message.ErrorMessage); msg != "" {
|
||||
parts = append(parts, msg)
|
||||
}
|
||||
if stop := strings.TrimSpace(message.StopReason); stop != "" {
|
||||
parts = append(parts, "stopReason="+stop)
|
||||
}
|
||||
if preview := previewPiAssistantText(assistantText); preview != "" {
|
||||
parts = append(parts, "assistant="+preview)
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
parts = append(parts, "unknown error")
|
||||
}
|
||||
return fmt.Errorf("pi error: %s", strings.Join(parts, "; "))
|
||||
}
|
||||
|
||||
func previewPiAssistantText(text string) string {
|
||||
trimmed := strings.TrimSpace(text)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
trimmed = strings.ReplaceAll(trimmed, "\n", " ")
|
||||
trimmed = strings.Join(strings.Fields(trimmed), " ")
|
||||
const limit = 160
|
||||
if len(trimmed) <= limit {
|
||||
return trimmed
|
||||
}
|
||||
return trimmed[:limit] + "..."
|
||||
}
|
||||
|
||||
func extractContentText(content json.RawMessage) (string, error) {
|
||||
trimmed := strings.TrimSpace(string(content))
|
||||
if trimmed == "" {
|
||||
return "", nil
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "\"") {
|
||||
var text string
|
||||
if err := json.Unmarshal(content, &text); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
|
||||
var blocks []contentBlock
|
||||
if err := json.Unmarshal(content, &blocks); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var parts []string
|
||||
for _, block := range blocks {
|
||||
if block.Type == "text" && block.Text != "" {
|
||||
parts = append(parts, block.Text)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ""), nil
|
||||
}
|
||||
|
||||
func decodeRpcResponse(raw json.RawMessage) (rpcResponse, error) {
|
||||
var response rpcResponse
|
||||
if err := json.Unmarshal(raw, &response); err != nil {
|
||||
return rpcResponse{}, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func getDocsPiAgentDir() (string, error) {
|
||||
cacheDir, err := os.UserCacheDir()
|
||||
if err != nil {
|
||||
cacheDir = os.TempDir()
|
||||
}
|
||||
dir := filepath.Join(cacheDir, "openclaw", "docs-i18n", "agent")
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
func resolveDocsPiAgentDir() (string, error) {
|
||||
if override := strings.TrimSpace(os.Getenv("PI_CODING_AGENT_DIR")); override != "" {
|
||||
if err := os.MkdirAll(override, 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return override, nil
|
||||
}
|
||||
return getDocsPiAgentDir()
|
||||
}
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractTranslationResultIncludesStopReasonAndPreview(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := []byte(`{
|
||||
"type":"agent_end",
|
||||
"messages":[
|
||||
{
|
||||
"role":"assistant",
|
||||
"stopReason":"terminated",
|
||||
"content":[
|
||||
{"type":"text","text":"provider disconnected while streaming the translation chunk"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
_, err := extractTranslationResult(raw)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
message := err.Error()
|
||||
for _, want := range []string{
|
||||
"pi error:",
|
||||
"stopReason=terminated",
|
||||
"assistant=provider disconnected while streaming the translation chunk",
|
||||
} {
|
||||
if !strings.Contains(message, want) {
|
||||
t.Fatalf("expected %q in error, got %q", want, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewPiAssistantTextTruncatesAndFlattensWhitespace(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
input := "line one\n\nline two\tline three " + strings.Repeat("x", 200)
|
||||
preview := previewPiAssistantText(input)
|
||||
if strings.Contains(preview, "\n") {
|
||||
t.Fatalf("expected flattened whitespace, got %q", preview)
|
||||
}
|
||||
if !strings.HasPrefix(preview, "line one line two line three ") {
|
||||
t.Fatalf("unexpected preview prefix: %q", preview)
|
||||
}
|
||||
if !strings.HasSuffix(preview, "...") {
|
||||
t.Fatalf("expected truncation suffix, got %q", preview)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractTranslationResultReturnsPiErrorBeforeDecodingStructuredErrorContent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := []byte(`{
|
||||
"type":"agent_end",
|
||||
"messages":[
|
||||
{
|
||||
"role":"assistant",
|
||||
"stopReason":"terminated",
|
||||
"content":{"type":"error","message":"provider disconnected"}
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
_, err := extractTranslationResult(raw)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
message := err.Error()
|
||||
if !strings.Contains(message, "pi error:") {
|
||||
t.Fatalf("expected normalized pi error, got %q", message)
|
||||
}
|
||||
if !strings.Contains(message, "stopReason=terminated") {
|
||||
t.Fatalf("expected stopReason in error, got %q", message)
|
||||
}
|
||||
if strings.Contains(message, "cannot unmarshal") {
|
||||
t.Fatalf("expected terminal pi error before decode failure, got %q", message)
|
||||
}
|
||||
}
|
||||
|
|
@ -65,8 +65,8 @@ func processFile(ctx context.Context, translator docsTranslator, tm *Translation
|
|||
TextHash: seg.TextHash,
|
||||
Text: seg.Text,
|
||||
Translated: translated,
|
||||
Provider: docsPiProvider(),
|
||||
Model: docsPiModel(),
|
||||
Provider: docsI18nProvider(),
|
||||
Model: docsI18nModel(),
|
||||
SrcLang: srcLang,
|
||||
TgtLang: tgtLang,
|
||||
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
|
|
@ -122,8 +122,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": docsPiProvider(),
|
||||
"model": docsPiModel(),
|
||||
"provider": docsI18nProvider(),
|
||||
"model": docsI18nModel(),
|
||||
"workflow": workflowVersion,
|
||||
"generated_at": time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
|
@ -229,8 +229,8 @@ func translateSnippet(ctx context.Context, translator docsTranslator, tm *Transl
|
|||
TextHash: textHash,
|
||||
Text: textValue,
|
||||
Translated: translated,
|
||||
Provider: docsPiProvider(),
|
||||
Model: docsPiModel(),
|
||||
Provider: docsI18nProvider(),
|
||||
Model: docsI18nModel(),
|
||||
SrcLang: srcLang,
|
||||
TgtLang: tgtLang,
|
||||
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
|
|
@ -250,6 +250,9 @@ func validateFrontmatterScalarTranslation(source, translated string) error {
|
|||
if strings.Contains(lower, "<frontmatter>") || strings.Contains(lower, "</frontmatter>") || strings.Contains(lower, "<body>") || strings.Contains(lower, "</body>") {
|
||||
return fmt.Errorf("tagged document wrapper detected")
|
||||
}
|
||||
if err := validateNoTranslationTranscriptArtifacts(source, trimmed); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.Contains(trimmed, "[[[FM_") {
|
||||
return fmt.Errorf("frontmatter marker leaked into scalar translation")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,34 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
translateMaxAttempts = 3
|
||||
translateBaseDelay = 15 * time.Second
|
||||
defaultPromptTimeout = 2 * time.Minute
|
||||
envDocsI18nPromptTimeout = "OPENCLAW_DOCS_I18N_PROMPT_TIMEOUT"
|
||||
translateMaxAttempts = 3
|
||||
translateBaseDelay = 15 * time.Second
|
||||
defaultPromptTimeout = 2 * time.Minute
|
||||
envDocsI18nPromptTimeout = "OPENCLAW_DOCS_I18N_PROMPT_TIMEOUT"
|
||||
envDocsI18nCodexExecutable = "OPENCLAW_DOCS_I18N_CODEX_EXECUTABLE"
|
||||
)
|
||||
|
||||
var errEmptyTranslation = errors.New("empty translation")
|
||||
|
||||
type PiTranslator struct {
|
||||
client docsPiPromptClient
|
||||
clientFactory docsPiClientFactory
|
||||
var translateRetryDelay = func(attempt int) time.Duration {
|
||||
return translateBaseDelay * time.Duration(attempt)
|
||||
}
|
||||
|
||||
type CodexTranslator struct {
|
||||
systemPrompt string
|
||||
thinking string
|
||||
runPrompt codexPromptRunner
|
||||
}
|
||||
|
||||
type docsTranslator interface {
|
||||
|
|
@ -31,40 +39,32 @@ type docsTranslator interface {
|
|||
|
||||
type docsTranslatorFactory func(string, string, []GlossaryEntry, string) (docsTranslator, error)
|
||||
|
||||
type docsPiPromptClient interface {
|
||||
promptRunner
|
||||
Close() error
|
||||
type codexPromptRunner func(context.Context, codexPromptRequest) (string, error)
|
||||
|
||||
type codexPromptRequest struct {
|
||||
SystemPrompt string
|
||||
Message string
|
||||
Model string
|
||||
Thinking string
|
||||
}
|
||||
|
||||
type docsPiClientFactory func(context.Context) (docsPiPromptClient, error)
|
||||
|
||||
func NewPiTranslator(srcLang, tgtLang string, glossary []GlossaryEntry, thinking string) (*PiTranslator, error) {
|
||||
options := docsPiClientOptions{
|
||||
SystemPrompt: translationPrompt(srcLang, tgtLang, glossary),
|
||||
Thinking: normalizeThinking(thinking),
|
||||
}
|
||||
clientFactory := func(ctx context.Context) (docsPiPromptClient, error) {
|
||||
return startDocsPiClient(ctx, options)
|
||||
}
|
||||
client, err := clientFactory(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &PiTranslator{client: client, clientFactory: clientFactory}, nil
|
||||
func NewCodexTranslator(srcLang, tgtLang string, glossary []GlossaryEntry, thinking string) (*CodexTranslator, error) {
|
||||
return &CodexTranslator{
|
||||
systemPrompt: translationPrompt(srcLang, tgtLang, glossary),
|
||||
thinking: normalizeThinking(thinking),
|
||||
runPrompt: runCodexExecPrompt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *PiTranslator) Translate(ctx context.Context, text, srcLang, tgtLang string) (string, error) {
|
||||
func (t *CodexTranslator) Translate(ctx context.Context, text, srcLang, tgtLang string) (string, error) {
|
||||
return t.translate(ctx, text, t.translateMasked)
|
||||
}
|
||||
|
||||
func (t *PiTranslator) TranslateRaw(ctx context.Context, text, srcLang, tgtLang string) (string, error) {
|
||||
func (t *CodexTranslator) TranslateRaw(ctx context.Context, text, srcLang, tgtLang string) (string, error) {
|
||||
return t.translate(ctx, text, t.translateRaw)
|
||||
}
|
||||
|
||||
func (t *PiTranslator) translate(ctx context.Context, text string, run func(context.Context, string) (string, error)) (string, error) {
|
||||
if t.client == nil {
|
||||
return "", errors.New("pi client unavailable")
|
||||
}
|
||||
func (t *CodexTranslator) translate(ctx context.Context, text string, run func(context.Context, string) (string, error)) (string, error) {
|
||||
prefix, core, suffix := splitWhitespace(text)
|
||||
if core == "" {
|
||||
return text, nil
|
||||
|
|
@ -78,7 +78,7 @@ func (t *PiTranslator) translate(ctx context.Context, text string, run func(cont
|
|||
return prefix + translated + suffix, nil
|
||||
}
|
||||
|
||||
func (t *PiTranslator) translateWithRetry(ctx context.Context, run func(context.Context) (string, error)) (string, error) {
|
||||
func (t *CodexTranslator) translateWithRetry(ctx context.Context, run func(context.Context) (string, error)) (string, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < translateMaxAttempts; attempt++ {
|
||||
translated, err := run(ctx)
|
||||
|
|
@ -90,13 +90,7 @@ func (t *PiTranslator) translateWithRetry(ctx context.Context, run func(context.
|
|||
}
|
||||
lastErr = err
|
||||
if attempt+1 < translateMaxAttempts {
|
||||
if shouldRestartPiClientForError(err) {
|
||||
if err := t.restartClient(ctx); err != nil {
|
||||
return "", fmt.Errorf("%w (pi client restart failed: %v)", lastErr, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
delay := translateBaseDelay * time.Duration(attempt+1)
|
||||
delay := translateRetryDelay(attempt + 1)
|
||||
if err := sleepWithContext(ctx, delay); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
|
@ -105,12 +99,12 @@ func (t *PiTranslator) translateWithRetry(ctx context.Context, run func(context.
|
|||
return "", lastErr
|
||||
}
|
||||
|
||||
func (t *PiTranslator) translateMasked(ctx context.Context, core string) (string, error) {
|
||||
func (t *CodexTranslator) translateMasked(ctx context.Context, core string) (string, error) {
|
||||
state := NewPlaceholderState(core)
|
||||
placeholders := make([]string, 0, 8)
|
||||
mapping := map[string]string{}
|
||||
masked := maskMarkdown(core, state.Next, &placeholders, mapping)
|
||||
resText, err := runPrompt(ctx, t.client, masked)
|
||||
resText, err := t.prompt(ctx, masked)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
|
@ -124,8 +118,8 @@ func (t *PiTranslator) translateMasked(ctx context.Context, core string) (string
|
|||
return unmaskMarkdown(translated, placeholders, mapping), nil
|
||||
}
|
||||
|
||||
func (t *PiTranslator) translateRaw(ctx context.Context, core string) (string, error) {
|
||||
resText, err := runPrompt(ctx, t.client, core)
|
||||
func (t *CodexTranslator) translateRaw(ctx context.Context, core string) (string, error) {
|
||||
resText, err := t.prompt(ctx, core)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
|
@ -136,6 +130,20 @@ func (t *PiTranslator) translateRaw(ctx context.Context, core string) (string, e
|
|||
return translated, nil
|
||||
}
|
||||
|
||||
func (t *CodexTranslator) prompt(ctx context.Context, message string) (string, error) {
|
||||
if t.runPrompt == nil {
|
||||
return "", errors.New("codex prompt runner unavailable")
|
||||
}
|
||||
promptCtx, cancel := context.WithTimeout(ctx, docsI18nPromptTimeout())
|
||||
defer cancel()
|
||||
return t.runPrompt(promptCtx, codexPromptRequest{
|
||||
SystemPrompt: t.systemPrompt,
|
||||
Message: message,
|
||||
Model: docsI18nModel(),
|
||||
Thinking: t.thinking,
|
||||
})
|
||||
}
|
||||
|
||||
func isRetryableTranslateError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
|
|
@ -147,44 +155,87 @@ func isRetryableTranslateError(err error) bool {
|
|||
return true
|
||||
}
|
||||
message := strings.ToLower(err.Error())
|
||||
if strings.Contains(message, "authentication failed") {
|
||||
if strings.Contains(message, "authentication failed") || strings.Contains(message, "invalid_api_key") || strings.Contains(message, "api key") {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(message, "placeholder missing") ||
|
||||
strings.Contains(message, "rate limit") ||
|
||||
strings.Contains(message, "429") ||
|
||||
shouldRestartPiClientForError(err)
|
||||
strings.Contains(message, "500") ||
|
||||
strings.Contains(message, "502") ||
|
||||
strings.Contains(message, "503") ||
|
||||
strings.Contains(message, "504") ||
|
||||
strings.Contains(message, "temporarily unavailable") ||
|
||||
strings.Contains(message, "connection reset") ||
|
||||
strings.Contains(message, "stream")
|
||||
}
|
||||
|
||||
func shouldRestartPiClientForError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
message := strings.ToLower(err.Error())
|
||||
return strings.Contains(message, "pi error: terminated") ||
|
||||
strings.Contains(message, "stopreason=cancelled") ||
|
||||
strings.Contains(message, "stopreason=canceled") ||
|
||||
strings.Contains(message, "stopreason=aborted") ||
|
||||
strings.Contains(message, "stopreason=terminated") ||
|
||||
strings.Contains(message, "stopreason=error") ||
|
||||
strings.Contains(message, "pi process closed") ||
|
||||
strings.Contains(message, "pi event stream closed")
|
||||
}
|
||||
|
||||
func (t *PiTranslator) restartClient(ctx context.Context) error {
|
||||
if t.clientFactory == nil {
|
||||
return errors.New("pi client restart unavailable")
|
||||
}
|
||||
if t.client != nil {
|
||||
_ = t.client.Close()
|
||||
t.client = nil
|
||||
}
|
||||
client, err := t.clientFactory(ctx)
|
||||
func runCodexExecPrompt(ctx context.Context, req codexPromptRequest) (string, error) {
|
||||
outputFile, err := os.CreateTemp("", "openclaw-docs-i18n-codex-*.txt")
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
t.client = client
|
||||
return nil
|
||||
outputPath := outputFile.Name()
|
||||
_ = outputFile.Close()
|
||||
defer os.Remove(outputPath)
|
||||
|
||||
args := []string{
|
||||
"exec",
|
||||
"--model", req.Model,
|
||||
"-c", fmt.Sprintf("model_reasoning_effort=%q", normalizeThinking(req.Thinking)),
|
||||
"--sandbox", "read-only",
|
||||
"--ignore-rules",
|
||||
"--skip-git-repo-check",
|
||||
"--output-last-message", outputPath,
|
||||
"-",
|
||||
}
|
||||
command := exec.CommandContext(ctx, docsCodexExecutable(), args...)
|
||||
command.Stdin = strings.NewReader(buildCodexTranslationPrompt(req.SystemPrompt, req.Message))
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
command.Stdout = &stdout
|
||||
command.Stderr = &stderr
|
||||
if err := command.Run(); err != nil {
|
||||
return "", fmt.Errorf("codex exec failed: %w (%s)", err, previewCommandOutput(stdout.String(), stderr.String()))
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(outputPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
translated := strings.TrimSpace(string(data))
|
||||
if translated == "" {
|
||||
return "", errEmptyTranslation
|
||||
}
|
||||
return translated, nil
|
||||
}
|
||||
|
||||
func docsCodexExecutable() string {
|
||||
if executable := strings.TrimSpace(os.Getenv(envDocsI18nCodexExecutable)); executable != "" {
|
||||
return executable
|
||||
}
|
||||
return "codex"
|
||||
}
|
||||
|
||||
func buildCodexTranslationPrompt(systemPrompt, message string) string {
|
||||
return strings.TrimSpace(systemPrompt) + "\n\n" +
|
||||
"Translate the exact input below. Return only the translated text, with no code fences, no tool calls, no reasoning, and no commentary.\n\n" +
|
||||
"<openclaw_docs_i18n_input>\n" +
|
||||
message +
|
||||
"\n</openclaw_docs_i18n_input>\n"
|
||||
}
|
||||
|
||||
func previewCommandOutput(stdout, stderr string) string {
|
||||
combined := strings.TrimSpace(strings.Join([]string{stdout, stderr}, "\n"))
|
||||
if combined == "" {
|
||||
return "no output"
|
||||
}
|
||||
combined = strings.Join(strings.Fields(combined), " ")
|
||||
const limit = 500
|
||||
if len(combined) <= limit {
|
||||
return combined
|
||||
}
|
||||
return combined[:limit] + "..."
|
||||
}
|
||||
|
||||
func sleepWithContext(ctx context.Context, delay time.Duration) error {
|
||||
|
|
@ -198,42 +249,11 @@ func sleepWithContext(ctx context.Context, delay time.Duration) error {
|
|||
}
|
||||
}
|
||||
|
||||
func (t *PiTranslator) Close() {
|
||||
if t.client != nil {
|
||||
_ = t.client.Close()
|
||||
}
|
||||
}
|
||||
|
||||
type promptRunner interface {
|
||||
Prompt(context.Context, string) (string, error)
|
||||
Stderr() string
|
||||
}
|
||||
|
||||
func runPrompt(ctx context.Context, client promptRunner, message string) (string, error) {
|
||||
promptCtx, cancel := context.WithTimeout(ctx, docsI18nPromptTimeout())
|
||||
defer cancel()
|
||||
|
||||
result, err := client.Prompt(promptCtx, message)
|
||||
if err != nil {
|
||||
return "", decoratePromptError(err, client.Stderr())
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func decoratePromptError(err error, stderr string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
trimmed := strings.TrimSpace(stderr)
|
||||
if trimmed == "" {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("%w (pi stderr: %s)", err, trimmed)
|
||||
}
|
||||
func (t *CodexTranslator) Close() {}
|
||||
|
||||
func normalizeThinking(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "low", "high":
|
||||
case "low", "medium", "high", "xhigh":
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
default:
|
||||
return "high"
|
||||
|
|
|
|||
|
|
@ -10,59 +10,33 @@ import (
|
|||
"time"
|
||||
)
|
||||
|
||||
type fakePromptRunner struct {
|
||||
prompt func(context.Context, string) (string, error)
|
||||
stderr string
|
||||
}
|
||||
|
||||
func (runner fakePromptRunner) Prompt(ctx context.Context, message string) (string, error) {
|
||||
return runner.prompt(ctx, message)
|
||||
}
|
||||
|
||||
func (runner fakePromptRunner) Stderr() string {
|
||||
return runner.stderr
|
||||
}
|
||||
|
||||
type fakePiPromptClient struct {
|
||||
prompt func(context.Context, string) (string, error)
|
||||
stderr string
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (client *fakePiPromptClient) Prompt(ctx context.Context, message string) (string, error) {
|
||||
return client.prompt(ctx, message)
|
||||
}
|
||||
|
||||
func (client *fakePiPromptClient) Stderr() string {
|
||||
return client.stderr
|
||||
}
|
||||
|
||||
func (client *fakePiPromptClient) Close() error {
|
||||
client.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestRunPromptAddsTimeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
func TestCodexTranslatorAddsTimeout(t *testing.T) {
|
||||
var deadline time.Time
|
||||
client := fakePromptRunner{
|
||||
prompt: func(ctx context.Context, message string) (string, error) {
|
||||
translator := &CodexTranslator{
|
||||
systemPrompt: "Translate from English to Chinese.",
|
||||
thinking: "high",
|
||||
runPrompt: func(ctx context.Context, req codexPromptRequest) (string, error) {
|
||||
var ok bool
|
||||
deadline, ok = ctx.Deadline()
|
||||
if !ok {
|
||||
t.Fatal("expected prompt deadline")
|
||||
}
|
||||
if message != "Translate me" {
|
||||
t.Fatalf("unexpected message %q", message)
|
||||
if req.Message != "Translate me" {
|
||||
t.Fatalf("unexpected message %q", req.Message)
|
||||
}
|
||||
if req.Model != defaultOpenAIModel {
|
||||
t.Fatalf("unexpected model %q", req.Model)
|
||||
}
|
||||
if req.Thinking != "high" {
|
||||
t.Fatalf("unexpected thinking %q", req.Thinking)
|
||||
}
|
||||
return "translated", nil
|
||||
},
|
||||
}
|
||||
|
||||
got, err := runPrompt(context.Background(), client, "Translate me")
|
||||
got, err := translator.TranslateRaw(context.Background(), "Translate me", "en", "zh-CN")
|
||||
if err != nil {
|
||||
t.Fatalf("runPrompt returned error: %v", err)
|
||||
t.Fatalf("TranslateRaw returned error: %v", err)
|
||||
}
|
||||
if got != "translated" {
|
||||
t.Fatalf("unexpected translation %q", got)
|
||||
|
|
@ -96,157 +70,43 @@ func TestIsRetryableTranslateErrorRejectsAuthenticationFailures(t *testing.T) {
|
|||
if isRetryableTranslateError(errors.New(`Authentication failed for "openai"`)) {
|
||||
t.Fatal("auth failures should not retry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRetryableTranslateErrorRetriesPiTermination(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if !isRetryableTranslateError(errors.New("pi error: terminated; stopReason=error; assistant=partial output")) {
|
||||
t.Fatal("terminated pi session should retry")
|
||||
if isRetryableTranslateError(errors.New("invalid_api_key")) {
|
||||
t.Fatal("API key failures should not retry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRetryableTranslateErrorRetriesTerminatedStopReason(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if !isRetryableTranslateError(errors.New("pi error: stopReason=terminated; assistant=partial output")) {
|
||||
t.Fatal("terminated stopReason should retry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRetryableTranslateErrorRetriesCanceledStopReasons(t *testing.T) {
|
||||
func TestIsRetryableTranslateErrorRetriesTransientCodexFailures(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, message := range []string{
|
||||
"pi error: stopReason=cancelled; assistant=partial output",
|
||||
"pi error: stopReason=canceled; assistant=partial output",
|
||||
"pi error: stopReason=aborted; assistant=partial output",
|
||||
"codex exec failed: rate limit 429",
|
||||
"codex exec failed: stream disconnected",
|
||||
"codex exec failed: 503 temporarily unavailable",
|
||||
} {
|
||||
if !isRetryableTranslateError(errors.New(message)) {
|
||||
t.Fatalf("expected retryable stop reason for %q", message)
|
||||
t.Fatalf("expected retryable error for %q", message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPromptIncludesStderr(t *testing.T) {
|
||||
t.Parallel()
|
||||
func TestCodexTranslatorRetriesTransientFailure(t *testing.T) {
|
||||
previousDelay := translateRetryDelay
|
||||
translateRetryDelay = func(int) time.Duration { return 0 }
|
||||
defer func() { translateRetryDelay = previousDelay }()
|
||||
|
||||
rootErr := errors.New("context deadline exceeded")
|
||||
client := fakePromptRunner{
|
||||
prompt: func(context.Context, string) (string, error) {
|
||||
return "", rootErr
|
||||
attempts := 0
|
||||
translator := &CodexTranslator{
|
||||
systemPrompt: "Translate from English to Chinese.",
|
||||
thinking: "high",
|
||||
runPrompt: func(context.Context, codexPromptRequest) (string, error) {
|
||||
attempts++
|
||||
if attempts == 1 {
|
||||
return "", errors.New("codex exec failed: stream disconnected")
|
||||
}
|
||||
return "translated", nil
|
||||
},
|
||||
stderr: "boom",
|
||||
}
|
||||
|
||||
_, err := runPrompt(context.Background(), client, "Translate me")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !errors.Is(err, rootErr) {
|
||||
t.Fatalf("expected wrapped root error, got %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "pi stderr: boom") {
|
||||
t.Fatalf("expected stderr in error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecoratePromptErrorLeavesCleanErrorsAlone(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rootErr := errors.New("plain failure")
|
||||
got := decoratePromptError(rootErr, " ")
|
||||
if !errors.Is(got, rootErr) {
|
||||
t.Fatalf("expected original error, got %v", got)
|
||||
}
|
||||
if got.Error() != rootErr.Error() {
|
||||
t.Fatalf("expected unchanged message, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDocsPiCommandUsesOverrideEnv(t *testing.T) {
|
||||
t.Setenv(envDocsPiExecutable, "/tmp/custom-pi")
|
||||
t.Setenv(envDocsPiArgs, "--mode rpc --foo bar")
|
||||
|
||||
command, err := resolveDocsPiCommand(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("resolveDocsPiCommand returned error: %v", err)
|
||||
}
|
||||
|
||||
if command.Executable != "/tmp/custom-pi" {
|
||||
t.Fatalf("unexpected executable %q", command.Executable)
|
||||
}
|
||||
if strings.Join(command.Args, " ") != "--mode rpc --foo bar" {
|
||||
t.Fatalf("unexpected args %v", command.Args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsPiModelRefUsesProviderPrefixWhenProviderFlagIsOmitted(t *testing.T) {
|
||||
t.Setenv(envDocsI18nProvider, "openai")
|
||||
t.Setenv(envDocsI18nModel, "gpt-5.5")
|
||||
t.Setenv(envDocsPiOmitProvider, "1")
|
||||
|
||||
if got := docsPiProviderArg(); got != "" {
|
||||
t.Fatalf("expected empty provider arg when omit-provider is enabled, got %q", got)
|
||||
}
|
||||
if got := docsPiModelRef(); got != "openai/gpt-5.5" {
|
||||
t.Fatalf("expected provider-qualified model ref, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldMaterializePiRuntimeForPiMonoWrapper(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
sourceDir := filepath.Join(root, "Projects", "pi-mono", "packages", "coding-agent", "dist")
|
||||
binDir := filepath.Join(root, "bin")
|
||||
if err := os.MkdirAll(sourceDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir source dir: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(binDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir bin dir: %v", err)
|
||||
}
|
||||
|
||||
target := filepath.Join(sourceDir, "cli.js")
|
||||
if err := os.WriteFile(target, []byte("console.log('pi');\n"), 0o644); err != nil {
|
||||
t.Fatalf("write target: %v", err)
|
||||
}
|
||||
link := filepath.Join(binDir, "pi")
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Fatalf("symlink: %v", err)
|
||||
}
|
||||
|
||||
if !shouldMaterializePiRuntime(link) {
|
||||
t.Fatal("expected pi-mono wrapper to materialize runtime")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPiTranslatorRestartsClientAfterPiTermination(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
clients := []*fakePiPromptClient{}
|
||||
factoryCalls := 0
|
||||
factory := func(context.Context) (docsPiPromptClient, error) {
|
||||
factoryCalls++
|
||||
index := factoryCalls
|
||||
client := &fakePiPromptClient{
|
||||
prompt: func(context.Context, string) (string, error) {
|
||||
if index == 1 {
|
||||
return "", errors.New("pi error: terminated; stopReason=error; assistant=partial output")
|
||||
}
|
||||
return "translated", nil
|
||||
},
|
||||
}
|
||||
clients = append(clients, client)
|
||||
return client, nil
|
||||
}
|
||||
|
||||
client, err := factory(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("factory failed: %v", err)
|
||||
}
|
||||
translator := &PiTranslator{client: client, clientFactory: factory}
|
||||
|
||||
got, err := translator.TranslateRaw(context.Background(), "Translate me", "en", "zh-CN")
|
||||
if err != nil {
|
||||
t.Fatalf("TranslateRaw returned error: %v", err)
|
||||
|
|
@ -254,107 +114,71 @@ func TestPiTranslatorRestartsClientAfterPiTermination(t *testing.T) {
|
|||
if got != "translated" {
|
||||
t.Fatalf("unexpected translation %q", got)
|
||||
}
|
||||
if factoryCalls != 2 {
|
||||
t.Fatalf("expected factory to run twice, got %d", factoryCalls)
|
||||
}
|
||||
if len(clients) != 2 {
|
||||
t.Fatalf("expected 2 clients, got %d", len(clients))
|
||||
}
|
||||
if !clients[0].closed {
|
||||
t.Fatal("expected first client to close before retry")
|
||||
}
|
||||
if clients[1].closed {
|
||||
t.Fatal("expected replacement client to remain open")
|
||||
if attempts != 2 {
|
||||
t.Fatalf("expected 2 attempts, got %d", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPiTranslatorRestartsClientAfterTerminatedStopReason(t *testing.T) {
|
||||
t.Parallel()
|
||||
func TestBuildCodexTranslationPromptIncludesGuardrailsAndInput(t *testing.T) {
|
||||
prompt := buildCodexTranslationPrompt("System prompt.", "Hello\nworld")
|
||||
|
||||
clients := []*fakePiPromptClient{}
|
||||
factoryCalls := 0
|
||||
factory := func(context.Context) (docsPiPromptClient, error) {
|
||||
factoryCalls++
|
||||
index := factoryCalls
|
||||
client := &fakePiPromptClient{
|
||||
prompt: func(context.Context, string) (string, error) {
|
||||
if index == 1 {
|
||||
return "", errors.New("pi error: stopReason=terminated; assistant=partial output")
|
||||
}
|
||||
return "translated", nil
|
||||
},
|
||||
for _, want := range []string{
|
||||
"System prompt.",
|
||||
"Return only the translated text",
|
||||
"<openclaw_docs_i18n_input>",
|
||||
"Hello\nworld",
|
||||
"</openclaw_docs_i18n_input>",
|
||||
} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("expected %q in prompt:\n%s", want, prompt)
|
||||
}
|
||||
clients = append(clients, client)
|
||||
return client, nil
|
||||
}
|
||||
|
||||
client, err := factory(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("factory failed: %v", err)
|
||||
}
|
||||
translator := &PiTranslator{client: client, clientFactory: factory}
|
||||
|
||||
got, err := translator.TranslateRaw(context.Background(), "Translate me", "en", "zh-CN")
|
||||
if err != nil {
|
||||
t.Fatalf("TranslateRaw returned error: %v", err)
|
||||
}
|
||||
if got != "translated" {
|
||||
t.Fatalf("unexpected translation %q", got)
|
||||
}
|
||||
if factoryCalls != 2 {
|
||||
t.Fatalf("expected factory to run twice, got %d", factoryCalls)
|
||||
}
|
||||
if len(clients) != 2 {
|
||||
t.Fatalf("expected 2 clients, got %d", len(clients))
|
||||
}
|
||||
if !clients[0].closed {
|
||||
t.Fatal("expected first client to close before retry")
|
||||
}
|
||||
if clients[1].closed {
|
||||
t.Fatal("expected replacement client to remain open")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPiTranslatorRestartsClientAfterCanceledStopReason(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
clients := []*fakePiPromptClient{}
|
||||
factoryCalls := 0
|
||||
factory := func(context.Context) (docsPiPromptClient, error) {
|
||||
factoryCalls++
|
||||
index := factoryCalls
|
||||
client := &fakePiPromptClient{
|
||||
prompt: func(context.Context, string) (string, error) {
|
||||
if index == 1 {
|
||||
return "", errors.New("pi error: stopReason=aborted; assistant=partial output")
|
||||
}
|
||||
return "translated", nil
|
||||
},
|
||||
}
|
||||
clients = append(clients, client)
|
||||
return client, nil
|
||||
func TestRunCodexExecPromptUsesOutputLastMessage(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
fakeCodex := filepath.Join(dir, "codex")
|
||||
if err := os.WriteFile(fakeCodex, []byte(`#!/bin/sh
|
||||
set -eu
|
||||
out=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
if [ "$1" = "--output-last-message" ]; then
|
||||
shift
|
||||
out="$1"
|
||||
fi
|
||||
shift || true
|
||||
done
|
||||
cat >/dev/null
|
||||
printf 'translated from codex\n' > "$out"
|
||||
`), 0o755); err != nil {
|
||||
t.Fatalf("write fake codex: %v", err)
|
||||
}
|
||||
t.Setenv(envDocsI18nCodexExecutable, fakeCodex)
|
||||
|
||||
client, err := factory(context.Background())
|
||||
got, err := runCodexExecPrompt(context.Background(), codexPromptRequest{
|
||||
SystemPrompt: "Translate.",
|
||||
Message: "Hello",
|
||||
Model: "gpt-5.5",
|
||||
Thinking: "high",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("factory failed: %v", err)
|
||||
t.Fatalf("runCodexExecPrompt returned error: %v", err)
|
||||
}
|
||||
translator := &PiTranslator{client: client, clientFactory: factory}
|
||||
|
||||
got, err := translator.TranslateRaw(context.Background(), "Translate me", "en", "zh-CN")
|
||||
if err != nil {
|
||||
t.Fatalf("TranslateRaw returned error: %v", err)
|
||||
}
|
||||
if got != "translated" {
|
||||
t.Fatalf("unexpected translation %q", got)
|
||||
}
|
||||
if factoryCalls != 2 {
|
||||
t.Fatalf("expected factory to run twice, got %d", factoryCalls)
|
||||
}
|
||||
if !clients[0].closed {
|
||||
t.Fatal("expected first client to close before retry")
|
||||
}
|
||||
if clients[1].closed {
|
||||
t.Fatal("expected replacement client to remain open")
|
||||
if got != "translated from codex" {
|
||||
t.Fatalf("unexpected output %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewCommandOutputFlattensAndTruncates(t *testing.T) {
|
||||
input := "line one\n\nline two\tline three " + strings.Repeat("x", 600)
|
||||
preview := previewCommandOutput(input, "")
|
||||
if strings.Contains(preview, "\n") {
|
||||
t.Fatalf("expected flattened whitespace, got %q", preview)
|
||||
}
|
||||
if !strings.HasPrefix(preview, "line one line two line three ") {
|
||||
t.Fatalf("unexpected preview prefix: %q", preview)
|
||||
}
|
||||
if !strings.HasSuffix(preview, "...") {
|
||||
t.Fatalf("expected truncation suffix, got %q", preview)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,27 +6,29 @@ import (
|
|||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
workflowVersion = 15
|
||||
docsI18nEngineName = "pi"
|
||||
docsI18nEngineName = "codex"
|
||||
envDocsI18nProvider = "OPENCLAW_DOCS_I18N_PROVIDER"
|
||||
envDocsI18nModel = "OPENCLAW_DOCS_I18N_MODEL"
|
||||
defaultOpenAIModel = "gpt-5.5"
|
||||
defaultAnthropicModel = "claude-opus-4-6"
|
||||
defaultFallbackProvider = "openai"
|
||||
defaultFallbackModelName = defaultOpenAIModel
|
||||
)
|
||||
|
||||
var translationTranscriptArtifactRE = regexp.MustCompile(`(?i)(?:\b(?:analysis|commentary|final|assistant|user)\s+to\s*=\s*(?:functions\.[a-z0-9_-]+|[a-z_]+)|\bto\s*=\s*(?:functions\.[a-z0-9_-]+|analysis|commentary|final)\b|\bfunctions\.[a-z0-9_-]+\b|/home/runner/work/|\.agents/skills/|\bforce_parallel\s*:|\bcode\s+omitted\b|\bomitted\s+reasoning\b|全民彩票|娱乐平台开户|娱乐平台|皇平台|彩票平台|一本道|毛片|高清视频免费|不卡免费播放)`)
|
||||
|
||||
func cacheNamespace() string {
|
||||
return fmt.Sprintf(
|
||||
"wf=%d|engine=%s|provider=%s|model=%s",
|
||||
workflowVersion,
|
||||
docsI18nEngineName,
|
||||
docsPiProvider(),
|
||||
docsPiModel(),
|
||||
docsI18nProvider(),
|
||||
docsI18nModel(),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -51,89 +53,18 @@ func normalizeText(text string) string {
|
|||
return strings.Join(strings.Fields(strings.TrimSpace(text)), " ")
|
||||
}
|
||||
|
||||
func docsPiProvider() string {
|
||||
if value := strings.TrimSpace(os.Getenv(envDocsI18nProvider)); value != "" {
|
||||
func docsI18nProvider() string {
|
||||
if value := strings.TrimSpace(os.Getenv(envDocsI18nProvider)); strings.EqualFold(value, "openai") {
|
||||
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 {
|
||||
func docsI18nModel() 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 docsPiProviderArg() string {
|
||||
provider := docsPiProvider()
|
||||
if provider == "" {
|
||||
return ""
|
||||
}
|
||||
if docsPiOmitProvider() {
|
||||
return ""
|
||||
}
|
||||
if strings.Contains(docsPiModel(), "/") {
|
||||
return ""
|
||||
}
|
||||
if hasDocsPiAgentDirOverride() {
|
||||
return ""
|
||||
}
|
||||
if !isBuiltInPiProvider(provider) {
|
||||
return ""
|
||||
}
|
||||
return provider
|
||||
}
|
||||
|
||||
func docsPiModelRef() string {
|
||||
model := docsPiModel()
|
||||
if model == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.Contains(model, "/") {
|
||||
return model
|
||||
}
|
||||
if docsPiOmitProvider() {
|
||||
provider := docsPiProvider()
|
||||
if provider == "" {
|
||||
return model
|
||||
}
|
||||
return provider + "/" + model
|
||||
}
|
||||
if docsPiProviderArg() != "" {
|
||||
return model
|
||||
}
|
||||
provider := docsPiProvider()
|
||||
if provider == "" {
|
||||
return model
|
||||
}
|
||||
return provider + "/" + model
|
||||
}
|
||||
|
||||
func isBuiltInPiProvider(provider string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(provider)) {
|
||||
case "anthropic", "openai":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func hasDocsPiAgentDirOverride() bool {
|
||||
return strings.TrimSpace(os.Getenv("PI_CODING_AGENT_DIR")) != ""
|
||||
return defaultFallbackModelName
|
||||
}
|
||||
|
||||
func segmentID(relPath, textHash string) string {
|
||||
|
|
@ -168,6 +99,21 @@ func isWhitespace(b byte) bool {
|
|||
}
|
||||
}
|
||||
|
||||
func validateNoTranslationTranscriptArtifacts(source, translated string) error {
|
||||
sourceLower := strings.ToLower(source)
|
||||
for _, match := range translationTranscriptArtifactRE.FindAllString(translated, -1) {
|
||||
match = strings.TrimSpace(match)
|
||||
if match == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(sourceLower, strings.ToLower(match)) {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("agent transcript artifact leaked into translation: %q", match)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fatal(err error) {
|
||||
if err == nil {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -2,49 +2,27 @@ package main
|
|||
|
||||
import "testing"
|
||||
|
||||
func TestDocsPiProviderPrefersExplicitOverride(t *testing.T) {
|
||||
func TestDocsI18nProviderUsesOpenAI(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)
|
||||
if got := docsI18nProvider(); got != "openai" {
|
||||
t.Fatalf("expected OpenAI provider, 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")
|
||||
func TestDocsI18nModelKeepsOpenAIDefaultAtGPT55(t *testing.T) {
|
||||
t.Setenv(envDocsI18nModel, "")
|
||||
|
||||
if got := docsPiModel(); got != defaultAnthropicModel {
|
||||
t.Fatalf("expected anthropic default model, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsPiModelKeepsOpenAIDefaultAtGPT54(t *testing.T) {
|
||||
t.Setenv(envDocsI18nProvider, "openai")
|
||||
t.Setenv(envDocsI18nModel, "")
|
||||
|
||||
if got := docsPiModel(); got != defaultOpenAIModel {
|
||||
if got := docsI18nModel(); got != defaultOpenAIModel {
|
||||
t.Fatalf("expected OpenAI default model %q, got %q", defaultOpenAIModel, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsPiModelPrefersExplicitOverride(t *testing.T) {
|
||||
t.Setenv(envDocsI18nProvider, "openai")
|
||||
t.Setenv(envDocsI18nModel, "gpt-5.2")
|
||||
func TestDocsI18nModelPrefersExplicitOverride(t *testing.T) {
|
||||
t.Setenv(envDocsI18nModel, "__test_model_override__")
|
||||
|
||||
if got := docsPiModel(); got != "gpt-5.2" {
|
||||
if got := docsI18nModel(); got != "__test_model_override__" {
|
||||
t.Fatalf("expected explicit model override, got %q", got)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue