Files
MyGoNavi/internal/ai/provider/codex_cli.go
Syngnat 6cd2835066 feat(ai): 支持 Codex 与 Claude 订阅登录
- 新增基于本机官方 CLI 登录状态的 Codex 与 Claude 订阅 Provider
- 隔离 API Key、工具与项目配置,完善登录校验和错误处理
- 补充设置界面、国际化及前后端回归测试

Fixes #651
2026-07-18 11:57:25 +08:00

506 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package provider
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
ai "GoNavi-Wails/internal/ai"
"GoNavi-Wails/internal/logger"
)
var codexLookPath = exec.LookPath
var codexCommandContext = exec.CommandContext
var codexEvalSymlinks = filepath.EvalSymlinks
var codexCLIChatGPTAuthCheck = CheckCodexCLIAuth
var codexCLIRequestTimeout = 120 * time.Second
const codexCLIMaxJSONLineBytes = 8 * 1024 * 1024
const codexCLILoginConfigOverride = `model_reasoning_effort="high"`
// Codex CLI is used as a credentialed model transport, not as a coding agent.
// Keep every optional capability off so database context cannot reach local or
// remote tools through user-installed apps, plugins, hooks, or browser helpers.
var codexCLIDisabledFeatures = []string{
"shell_tool",
"shell_snapshot",
"code_mode",
"code_mode_only",
"web_search_request",
"web_search_cached",
"hooks",
"request_permissions_tool",
"memories",
"chronicle",
"child_agents_md",
"multi_agent",
"multi_agent_v2",
"enable_fanout",
"apps",
"enable_mcp_apps",
"tool_suggest",
"plugins",
"plugin_hooks",
"in_app_browser",
"browser_use",
"browser_use_external",
"computer_use",
"remote_plugin",
"plugin_sharing",
"external_migration",
"image_generation",
"skill_mcp_dependency_install",
"mentions_v2",
"steer",
"default_mode_request_user_input",
"guardian_approval",
"goals",
"collaboration_modes",
"auth_elicitation",
"personality",
"artifact",
"fast_mode",
"realtime_conversation",
"remote_control",
"workspace_dependencies",
}
type codexCLICommand struct {
Path string
PrefixArgs []string
}
type codexCLIEvent struct {
Type string `json:"type"`
Message string `json:"message"`
Item codexCLIItem `json:"item"`
Error codexCLIError `json:"error"`
Usage codexCLIUsage `json:"usage"`
}
type codexCLIItem struct {
Type string `json:"type"`
Text string `json:"text"`
}
type codexCLIError struct {
Message string `json:"message"`
}
type codexCLIUsage struct {
InputTokens int `json:"input_tokens"`
CachedInputTokens int `json:"cached_input_tokens"`
OutputTokens int `json:"output_tokens"`
ReasoningOutputTokens int `json:"reasoning_output_tokens"`
}
type codexCLIResult struct {
Content string
Thinking string
Usage ai.TokenUsage
Completed bool
TerminalError string
LastError string
}
// CodexCLIProvider 通过官方 Codex CLI 复用本机 ChatGPT 订阅登录态。
// CLI 在隔离临时目录、只读 sandbox 且禁用 shell/web 的条件下运行。
type CodexCLIProvider struct {
config ai.ProviderConfig
}
func NewCodexCLIProvider(config ai.ProviderConfig) (Provider, error) {
if !strings.EqualFold(strings.TrimSpace(config.AuthMode), "local-cli") {
return nil, fmt.Errorf("Codex CLI provider requires local-cli subscription authentication")
}
return &CodexCLIProvider{config: config}, nil
}
func (p *CodexCLIProvider) Name() string {
return "CodexCLI"
}
func (p *CodexCLIProvider) Validate() error {
_, err := resolveCodexCLICommand(runtime.GOOS, runtime.GOARCH, codexLookPath, fileExists)
return err
}
// CheckCodexCLIAuth verifies that the official CLI is installed and has a
// usable local login. It deliberately does not send a model request.
func CheckCodexCLIAuth(ctx context.Context) error {
command, err := resolveCodexCLICommand(runtime.GOOS, runtime.GOARCH, codexLookPath, fileExists)
if err != nil {
return err
}
args := append(append([]string(nil), command.PrefixArgs...),
"login", "status", "-c", codexCLILoginConfigOverride,
)
cmd := codexCommandContext(ctx, command.Path, args...)
cmd.Env = buildCodexCLIEnv(cmd.Environ())
output, err := cmd.CombinedOutput()
detail := strings.TrimSpace(string(output))
if err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return ctxErr
}
if detail == "" {
detail = err.Error()
}
return fmt.Errorf("Codex CLI login check failed: %s; run codex login first", RedactAIUpstreamLogText(detail))
}
if !isCodexCLIChatGPTLoginStatus(detail) {
if detail == "" {
detail = "unknown login type"
}
return fmt.Errorf(
"Codex CLI is not logged in with a ChatGPT subscription (status: %s); run codex logout, then codex login and choose ChatGPT",
RedactAIUpstreamLogText(detail),
)
}
return nil
}
func isCodexCLIChatGPTLoginStatus(status string) bool {
normalized := strings.ToLower(strings.TrimSpace(status))
return strings.Contains(normalized, "logged in using chatgpt")
}
func (p *CodexCLIProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.ChatResponse, error) {
result, err := p.run(ctx, req)
if err != nil {
return nil, err
}
return &ai.ChatResponse{
Content: result.Content,
ReasoningContent: result.Thinking,
TokensUsed: result.Usage,
}, nil
}
func (p *CodexCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, callback func(ai.StreamChunk)) error {
result, err := p.run(ctx, req)
if err != nil {
if errors.Is(err, context.Canceled) {
return context.Canceled
}
callback(ai.StreamChunk{Error: err.Error(), Done: true})
return nil
}
if result.Thinking != "" {
callback(ai.StreamChunk{Thinking: result.Thinking})
}
if result.Content != "" {
callback(ai.StreamChunk{Content: result.Content})
}
callback(ai.StreamChunk{Done: true})
return nil
}
func (p *CodexCLIProvider) run(ctx context.Context, req ai.ChatRequest) (codexCLIResult, error) {
ctx, cancel := ensureClaudeCLITimeout(ctx, codexCLIRequestTimeout)
defer cancel()
if err := codexCLIChatGPTAuthCheck(ctx); err != nil {
return codexCLIResult{}, err
}
command, err := resolveCodexCLICommand(runtime.GOOS, runtime.GOARCH, codexLookPath, fileExists)
if err != nil {
return codexCLIResult{}, err
}
workDir, err := os.MkdirTemp("", "gonavi-codex-")
if err != nil {
return codexCLIResult{}, fmt.Errorf("create isolated Codex CLI workspace failed: %w", err)
}
defer func() {
if removeErr := os.RemoveAll(workDir); removeErr != nil {
logger.Warnf("CodexCLI 清理临时目录失败path=%s err=%v", workDir, removeErr)
}
}()
prompt := buildPrompt(req.Messages)
args := append(append([]string(nil), command.PrefixArgs...), buildCodexCLIArgs(p.config)...)
cmd := codexCommandContext(ctx, command.Path, args...)
cmd.Dir = workDir
cmd.Stdin = strings.NewReader(prompt)
cmd.Env = buildCodexCLIEnv(cmd.Environ())
requestLog := logAIUpstreamRequestStart(
p.Name(),
"CLI",
"codex://cli",
buildCodexCLIRequestLogBody(args, prompt, p.config, req),
)
var requestErr error
defer func() {
logAIUpstreamRequestFinish(requestLog, 0, requestErr)
}()
stdout, err := cmd.StdoutPipe()
if err != nil {
requestErr = fmt.Errorf("create Codex CLI stdout pipe failed: %w", err)
return codexCLIResult{}, requestErr
}
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Start(); err != nil {
requestErr = fmt.Errorf("start Codex CLI failed: %w", err)
return codexCLIResult{}, requestErr
}
if cmd.Process != nil {
logger.Infof("CodexCLI 请求进程已启动requestId=%s pid=%d", requestLog.id, cmd.Process.Pid)
}
result := codexCLIResult{}
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 64*1024), codexCLIMaxJSONLineBytes)
for scanner.Scan() {
line := bytes.TrimSpace(scanner.Bytes())
if len(line) == 0 {
continue
}
var event codexCLIEvent
if err := json.Unmarshal(line, &event); err != nil {
logger.Warnf("CodexCLI 忽略非 JSON 输出requestId=%s line=%s", requestLog.id, RedactAIUpstreamLogText(string(line)))
continue
}
consumeCodexCLIEvent(&result, event)
}
scanErr := scanner.Err()
waitErr := cmd.Wait()
if errors.Is(ctx.Err(), context.Canceled) && !errors.Is(ctx.Err(), context.DeadlineExceeded) {
requestErr = context.Canceled
return codexCLIResult{}, requestErr
}
if isClaudeCLITimeout(ctx, waitErr) {
requestErr = fmt.Errorf("Codex CLI timed out after %s; check the local Codex login and network connection", codexCLIRequestTimeout)
return codexCLIResult{}, requestErr
}
if scanErr != nil {
requestErr = fmt.Errorf("read Codex CLI JSON output failed: %w", scanErr)
return codexCLIResult{}, requestErr
}
if result.TerminalError != "" {
requestErr = fmt.Errorf("Codex CLI returned an error: %s", result.TerminalError)
return codexCLIResult{}, requestErr
}
if waitErr != nil {
detail := strings.TrimSpace(stderr.String())
if detail == "" {
detail = result.LastError
}
if detail == "" {
detail = waitErr.Error()
}
requestErr = fmt.Errorf("Codex CLI execution failed: %s", detail)
return codexCLIResult{}, requestErr
}
if !result.Completed {
detail := result.LastError
if detail == "" {
detail = strings.TrimSpace(stderr.String())
}
if detail == "" {
detail = "the command ended without a turn.completed event"
}
requestErr = fmt.Errorf("Codex CLI did not complete the request: %s", detail)
return codexCLIResult{}, requestErr
}
return result, nil
}
func buildCodexCLIArgs(config ai.ProviderConfig) []string {
args := []string{
"exec",
"--ignore-user-config",
"--ignore-rules",
"--ephemeral",
"--sandbox", "read-only",
"--skip-git-repo-check",
"-c", `web_search="disabled"`,
"-c", `approval_policy="never"`,
"-c", "project_doc_max_bytes=0",
"-c", "mcp_servers={}",
"-c", "skills.include_instructions=false",
"-c", "skills.bundled.enabled=false",
"--color", "never",
"--json",
}
for _, feature := range codexCLIDisabledFeatures {
args = append(args, "--disable", feature)
}
if model := strings.TrimSpace(config.Model); model != "" {
args = append(args, "-m", model)
}
return append(args, "-")
}
func buildCodexCLIEnv(baseEnv []string) []string {
return removeEnvKeys(baseEnv, "CODEX_API_KEY", "OPENAI_API_KEY", "OPENAI_BASE_URL")
}
func consumeCodexCLIEvent(result *codexCLIResult, event codexCLIEvent) {
switch event.Type {
case "item.completed":
switch event.Item.Type {
case "agent_message":
if text := strings.TrimSpace(event.Item.Text); text != "" {
// Codex 可能在内部循环中产生多条 agent_message最终一条才是用户可见答案。
result.Content = text
}
case "reasoning":
if text := strings.TrimSpace(event.Item.Text); text != "" {
result.Thinking = text
}
}
case "turn.completed":
result.Completed = true
result.TerminalError = ""
result.Usage = ai.TokenUsage{
PromptTokens: event.Usage.InputTokens,
// Codex reports reasoning_output_tokens as a breakdown of output_tokens,
// not an additional token bucket.
CompletionTokens: event.Usage.OutputTokens,
TotalTokens: event.Usage.InputTokens + event.Usage.OutputTokens,
}
case "turn.failed":
result.TerminalError = strings.TrimSpace(event.Error.Message)
if result.TerminalError == "" {
result.TerminalError = "turn failed"
}
case "error":
// 顶层 error 可能是 CLI 即将重试的瞬时错误,只有缺少成功终态时才作为兜底。
message := strings.TrimSpace(event.Message)
if message == "" {
message = strings.TrimSpace(event.Error.Message)
}
if message != "" {
result.LastError = message
}
}
}
func resolveCodexCLICommand(goos, goarch string, lookPath func(string) (string, error), exists func(string) bool) (codexCLICommand, error) {
if goos != "windows" {
path, err := lookPath("codex")
if err != nil {
return codexCLICommand{}, codexCLIInstallError()
}
for _, nativePath := range codexNPMNativeBinaryCandidates(path, goos, goarch) {
if exists(nativePath) {
return codexCLICommand{Path: nativePath}, nil
}
}
return codexCLICommand{Path: path}, nil
}
cmdPath, err := lookPath("codex.cmd")
if err == nil && exists(cmdPath) {
for _, nativePath := range codexNPMNativeBinaryCandidates(cmdPath, goos, goarch) {
if exists(nativePath) {
return codexCLICommand{Path: nativePath}, nil
}
}
}
if path, err := lookPath("codex.exe"); err == nil && exists(path) {
return codexCLICommand{Path: path}, nil
}
return codexCLICommand{}, codexCLIInstallError()
}
func codexNPMNativeBinaryCandidates(launcherPath, goos, goarch string) []string {
platformPackage, targetTriple, binaryName, ok := codexNPMPlatformTarget(goos, goarch)
if !ok {
return nil
}
packageRoots := []string{
filepath.Join(filepath.Dir(launcherPath), "node_modules", "@openai", "codex"),
filepath.Join(filepath.Dir(filepath.Dir(launcherPath)), "lib", "node_modules", "@openai", "codex"),
}
if resolved, err := codexEvalSymlinks(launcherPath); err == nil {
if strings.EqualFold(filepath.Base(resolved), "codex.js") && strings.EqualFold(filepath.Base(filepath.Dir(resolved)), "bin") {
packageRoots = append(packageRoots, filepath.Dir(filepath.Dir(resolved)))
}
}
seen := make(map[string]struct{})
candidates := make([]string, 0, len(packageRoots)*4)
for _, packageRoot := range packageRoots {
platformVendor := filepath.Join(packageRoot, "node_modules", "@openai", platformPackage, "vendor", targetTriple)
localVendor := filepath.Join(packageRoot, "vendor", targetTriple)
for _, candidate := range []string{
filepath.Join(platformVendor, "bin", binaryName),
filepath.Join(platformVendor, "codex", binaryName),
filepath.Join(localVendor, "bin", binaryName),
filepath.Join(localVendor, "codex", binaryName),
} {
if _, exists := seen[candidate]; exists {
continue
}
seen[candidate] = struct{}{}
candidates = append(candidates, candidate)
}
}
return candidates
}
func codexNPMPlatformTarget(goos, goarch string) (platformPackage, targetTriple, binaryName string, ok bool) {
arch := strings.ToLower(strings.TrimSpace(goarch))
switch strings.ToLower(strings.TrimSpace(goos)) {
case "windows":
switch arch {
case "amd64":
return "codex-win32-x64", "x86_64-pc-windows-msvc", "codex.exe", true
case "arm64":
return "codex-win32-arm64", "aarch64-pc-windows-msvc", "codex.exe", true
}
case "darwin":
switch arch {
case "amd64":
return "codex-darwin-x64", "x86_64-apple-darwin", "codex", true
case "arm64":
return "codex-darwin-arm64", "aarch64-apple-darwin", "codex", true
}
case "linux":
switch arch {
case "amd64":
return "codex-linux-x64", "x86_64-unknown-linux-musl", "codex", true
case "arm64":
return "codex-linux-arm64", "aarch64-unknown-linux-musl", "codex", true
}
}
return "", "", "", false
}
func codexCLIInstallError() error {
return fmt.Errorf("codex command was not found; install the official Codex CLI first: npm install -g @openai/codex")
}
func buildCodexCLIRequestLogBody(args []string, prompt string, config ai.ProviderConfig, req ai.ChatRequest) map[string]any {
return map[string]any{
"command": "codex",
"args": append([]string(nil), args...),
"prompt": prompt,
"model": strings.TrimSpace(config.Model),
"auth_mode": strings.TrimSpace(config.AuthMode),
"message_count": len(req.Messages),
"tool_count": len(req.Tools),
"tool_names": claudeCLIToolNamesForLog(req.Tools),
}
}