Ver código fonte

feat(codex): 支持切换标准与快速速度档位

在 Codex CLI 设置中持久化速度模式。

将 Fast 映射为 app-server priority tier,并覆盖普通生成和主 Agent 调用链。
darknessomi 1 mês atrás
pai
commit
4481869746

+ 6 - 1
src/components/settings/llm-wiki-model-settings.spec.ts

@@ -125,13 +125,14 @@ describe("QMAI model settings", () => {
 
     const codex = resolveConfig(
       preset("codex-cli"),
-      { localCliIsolation: true, codexCliTimeoutMinutes: 45 },
+      { localCliIsolation: true, codexCliTimeoutMinutes: 45, codexSpeedMode: "fast" },
       fallback,
     )
     expect(codex.provider).toBe("codex-cli")
     expect(codex.localCliIsolation).toBe(true)
     expect(codex.model).toBe("gpt-5.6-terra")
     expect(codex.codexCliTimeoutMinutes).toBe(45)
+    expect(codex.codexSpeedMode).toBe("fast")
 
     const codexPreset = preset("codex-cli")
     expect(codexPreset.defaultModel).toBe("gpt-5.6-terra")
@@ -143,6 +144,7 @@ describe("QMAI model settings", () => {
     const codexDefault = resolveConfig(preset("codex-cli"), {}, fallback)
     expect(codexDefault.model).toBe("gpt-5.6-terra")
     expect(codexDefault.codexCliTimeoutMinutes).toBe(40)
+    expect(codexDefault.codexSpeedMode).toBe("standard")
 
     const cursor = resolveConfig(
       preset("cursor-cli"),
@@ -169,6 +171,9 @@ describe("QMAI model settings", () => {
     expect(llm.longWritingContextHint).toContain("最大")
     expect(llm.longWritingContextHint).toContain("输出")
     expect(llm.longWritingContextDocs).toBe("参考文档")
+    expect(llm.codexSpeed).toBe("Codex 速度")
+    expect(llm.codexSpeedFast).toContain("1.5 倍")
+    expect(llm.codexSpeedFast).toContain("用量更多")
     expect(JSON.stringify(llm)).not.toContain("??")
   })
 })

+ 3 - 0
src/components/settings/preset-resolver.ts

@@ -7,6 +7,7 @@ import {
   normalizeUserLlmMaxOutputTokens,
 } from "@/lib/llm-context-size"
 import { resolveCodexCliTimeoutMinutes } from "@/lib/codex-cli-timeout"
+import { resolveCodexSpeedMode } from "@/lib/codex-cli-speed"
 
 /**
  * Build a full LlmConfig from a preset template + the user's saved
@@ -31,6 +32,7 @@ export function resolveConfig(
   const localCliIsolation = ov.localCliIsolation === true
   const functionCallingEnabled = ov.functionCallingEnabled !== false
   const codexCliTimeoutMinutes = resolveCodexCliTimeoutMinutes(ov.codexCliTimeoutMinutes)
+  const codexSpeedMode = resolveCodexSpeedMode(ov.codexSpeedMode)
 
   let config: LlmConfig
 
@@ -92,6 +94,7 @@ export function resolveConfig(
       reasoning,
       localCliIsolation,
       codexCliTimeoutMinutes: preset.provider === "codex-cli" ? codexCliTimeoutMinutes : undefined,
+      codexSpeedMode: preset.provider === "codex-cli" ? codexSpeedMode : undefined,
       functionCallingEnabled,
     }
   } else if (preset.provider === "cursor-cli") {

+ 7 - 0
src/components/settings/sections/llm-provider-section.spec.ts

@@ -37,4 +37,11 @@ describe("LLM provider model controls", () => {
     expect(source).toContain("r.installed && r.appServerReady === true && r.dynamicToolsReady === true")
     expect(source).toContain('invoke<DetectResult>("codex_cli_detect")')
   })
+
+  it("exposes the persisted Codex standard and fast speed switch", () => {
+    expect(source).toContain("resolveCodexSpeedMode(ov.codexSpeedMode)")
+    expect(source).toContain('role="switch"')
+    expect(source).toContain('codexSpeedMode: codexFastEnabled ? "standard" : "fast"')
+    expect(source).toContain('settings.sections.llm.codexSpeedFast')
+  })
 })

+ 50 - 1
src/components/settings/sections/llm-provider-section.tsx

@@ -26,6 +26,7 @@ import {
 } from "@/lib/llm-context-size"
 import { thinkingMinMaxTokens } from "@/lib/llm-providers"
 import { resolveCodexCliTimeoutMinutes } from "@/lib/codex-cli-timeout"
+import { resolveCodexSpeedMode } from "@/lib/codex-cli-speed"
 
 const MODEL_PARAM_DOCS_URL = "https://global.modelmesh.info/model"
 
@@ -222,6 +223,8 @@ function PresetRow({
   const reasoning = ov.reasoning ?? { mode: "auto" as const }
   const localCliIsolation = ov.localCliIsolation === true
   const codexCliTimeoutMinutes = resolveCodexCliTimeoutMinutes(ov.codexCliTimeoutMinutes)
+  const codexSpeedMode = resolveCodexSpeedMode(ov.codexSpeedMode)
+  const codexFastEnabled = codexSpeedMode === "fast"
   const showLocalCliIsolation = preset.provider === "claude-code"
   const isCursorCliProvider = preset.provider === "cursor-cli"
   const [testState, setTestState] = useState<ProviderTestState>({ kind: "idle" })
@@ -230,7 +233,13 @@ function PresetRow({
   const [isModelSelectionExpanded, setIsModelSelectionExpanded] = useState(false)
   const savedModelsTextareaRef = useRef<HTMLTextAreaElement>(null)
   const { modelTestState, runBatchTest, retryFailed } = useBatchModelTest(t)
-  const hasConfig = !!apiKey || !!ov.baseUrl || !!ov.model || !!ov.azureApiVersion || !!ov.azureModelFamily
+  const hasConfig =
+    !!apiKey ||
+    !!ov.baseUrl ||
+    !!ov.model ||
+    !!ov.azureApiVersion ||
+    !!ov.azureModelFamily ||
+    ov.codexSpeedMode === "fast"
   // Local CLI providers authenticate via their own existing login state
   // (inherited by the spawned subprocess), so no API key field is shown.
   // Ollama ditto for its local-only model. Cursor CLI uses cursor-api-proxy;
@@ -538,6 +547,46 @@ function PresetRow({
             </div>
           )}
 
+          {preset.provider === "codex-cli" && (
+            <div className="space-y-2 rounded-md border p-3">
+              <div className="flex items-start justify-between gap-3">
+                <div>
+                  <div className="text-sm font-medium">
+                    {t("settings.sections.llm.codexSpeed")}
+                  </div>
+                  <p className="mt-1 text-xs text-muted-foreground">
+                    {codexFastEnabled
+                      ? t("settings.sections.llm.codexSpeedFast")
+                      : t("settings.sections.llm.codexSpeedStandard")}
+                  </p>
+                </div>
+                <button
+                  type="button"
+                  role="switch"
+                  aria-checked={codexFastEnabled}
+                  aria-label={t("settings.sections.llm.codexSpeed")}
+                  onClick={() => onChange({
+                    codexSpeedMode: codexFastEnabled ? "standard" : "fast",
+                  })}
+                  className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border transition-colors ${
+                    codexFastEnabled
+                      ? "border-primary bg-primary"
+                      : "border-muted-foreground/30 bg-muted-foreground/20 hover:bg-muted-foreground/30"
+                  }`}
+                >
+                  <span
+                    className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm ring-1 ring-black/10 transition-transform ${
+                      codexFastEnabled ? "translate-x-4" : "translate-x-0.5"
+                    }`}
+                  />
+                </button>
+              </div>
+              <p className="text-xs text-muted-foreground">
+                {t("settings.sections.llm.codexSpeedHint")}
+              </p>
+            </div>
+          )}
+
           {preset.provider === "codex-cli" && (
             <div className="space-y-2 rounded-md border p-3">
               <Label>{t("settings.sections.llm.codexCliTimeout")}</Label>

+ 4 - 0
src/i18n/en.json

@@ -881,6 +881,10 @@
         "localCliIsolationHint": "When enabled, Claude CLI will try to ignore user rules and local config for more predictable tests and generation.",
         "localCliIsolationOn": "Isolation enabled: local rules and user config will be ignored where supported.",
         "localCliIsolationOff": "Isolation disabled: current CLI login and local config are used.",
+        "codexSpeed": "Codex speed",
+        "codexSpeedStandard": "Standard: default speed",
+        "codexSpeedFast": "Fast: 1.5x speed, increased usage",
+        "codexSpeedHint": "Fast applies only when the current Codex model reports support for the Fast tier.",
         "codexCliTimeout": "Codex CLI timeout",
         "codexCliTimeoutUnit": "minutes",
         "codexCliTimeoutHint": "Overall app-server timeout for longer generations. Defaults to 40 minutes; configurable from 1 to 240 minutes.",

+ 4 - 0
src/i18n/zh.json

@@ -587,6 +587,10 @@
         "localCliIsolationHint": "为 Claude CLI 使用隔离配置目录,避免读写用户全局 CLI 配置。",
         "localCliIsolationOn": "已隔离,不会使用全局 CLI 配置。",
         "localCliIsolationOff": "未隔离,将继承本机 CLI 登录状态。",
+        "codexSpeed": "Codex 速度",
+        "codexSpeedStandard": "标准:默认速度",
+        "codexSpeedFast": "快速:1.5 倍速度,用量更多",
+        "codexSpeedHint": "仅在当前 Codex 模型支持 Fast 档位时生效。",
         "codexCliTimeout": "Codex CLI 超时",
         "codexCliTimeoutUnit": "分钟",
         "codexCliTimeoutHint": "长文本生成的 app-server 总超时时间,默认 40 分钟,可设置 1-240 分钟。",

+ 24 - 0
src/lib/agent/codex-app-server-runner.spec.ts

@@ -182,6 +182,9 @@ describe("CodexAppServerRunner", () => {
       dynamicTools: [expect.objectContaining({ name: "read_outline" })],
     }))
     expect(threadStart?.[1].baseInstructions).toContain("## 任务契约")
+    expect(threadStart?.[1]).not.toHaveProperty("serviceTier")
+    const turnStart = appServerMock.call.mock.calls.find(([method]) => method === "turn/start")
+    expect(turnStart?.[1]).not.toHaveProperty("serviceTier")
     expect(tool.execute).toHaveBeenCalledWith(
       { path: "QM/outlines/总纲.md" }, undefined, expect.any(Object),
     )
@@ -196,6 +199,27 @@ describe("CodexAppServerRunner", () => {
     expect(cb.onError).not.toHaveBeenCalled()
   })
 
+  it("passes Fast mode to every app-server turn as the priority service tier", async () => {
+    appServerMock.onTurn = () => {
+      envelope("item/agentMessage/delta", { threadId: "thread-1", delta: "快速结果" })
+      envelope("turn/completed", { threadId: "thread-1", turn: { status: "completed" } })
+    }
+
+    const record = await new CodexAppServerRunner().run(
+      config([], { llmConfig: { ...llmConfig, codexSpeedMode: "fast" } }),
+      new ToolRegistry(),
+      messages,
+      callbacks(),
+    )
+
+    const threadStart = appServerMock.call.mock.calls.find(([method]) => method === "thread/start")
+    const turnStarts = appServerMock.call.mock.calls.filter(([method]) => method === "turn/start")
+    expect(threadStart?.[1]).toEqual(expect.objectContaining({ serviceTier: "priority" }))
+    expect(turnStarts).toHaveLength(1)
+    expect(turnStarts[0][1]).toEqual(expect.objectContaining({ serviceTier: "priority" }))
+    expect(record.finalText).toBe("快速结果")
+  })
+
   it("returns approval_required previews without executing confirmation tools", async () => {
     const tool: Tool = {
       name: "write_outline_node",

+ 4 - 0
src/lib/agent/codex-app-server-runner.ts

@@ -8,6 +8,7 @@ import {
 import { trimChatMessagesToTokenBudget } from "@/lib/chat-request-budget"
 import { getEffectiveMaxContextSize } from "@/lib/llm-providers"
 import { resolveCodexCliTimeoutMinutes } from "@/lib/codex-cli-timeout"
+import { codexAppServerServiceTier } from "@/lib/codex-cli-speed"
 import type { LlmUsage } from "@/lib/llm-usage"
 import { applyGlobalUserMemoryToMessages } from "@/lib/user-memory/request-integration"
 import type { ToolRegistry } from "./registry"
@@ -288,6 +289,7 @@ export class CodexAppServerRunner {
     }
 
     const timeoutMinutes = resolveCodexCliTimeoutMinutes(config.llmConfig.codexCliTimeoutMinutes)
+    const serviceTier = codexAppServerServiceTier(config.llmConfig.codexSpeedMode)
     const timeout = setTimeout(() => {
       failActiveTurn(new Error(`Codex app-server 超时(${timeoutMinutes} 分钟)`))
     }, timeoutMinutes * 60_000)
@@ -316,6 +318,7 @@ export class CodexAppServerRunner {
         ].join("\n"),
         dynamicTools: toDynamicTools(config),
         config: restrictedCodexConfig(),
+        ...(serviceTier ? { serviceTier } : {}),
       })
       if (started.instructionSources?.length) {
         throw new Error(`QMAI 禁止 Codex 加载本机或项目规则:${started.instructionSources.join(", ")}`)
@@ -484,6 +487,7 @@ export class CodexAppServerRunner {
           sandboxPolicy: { type: "readOnly", networkAccess: false },
           model: config.modelId?.trim() || config.llmConfig.model.trim() || null,
           effort: codexReasoningEffort(config.llmConfig),
+          ...(serviceTier ? { serviceTier } : {}),
         })
         activeTurnId = turn.turn.id
         if (terminalError) void client.interrupt(threadId, activeTurnId)

+ 18 - 0
src/lib/codex-cli-speed.spec.ts

@@ -0,0 +1,18 @@
+import { describe, expect, it } from "vitest"
+import {
+  codexAppServerServiceTier,
+  resolveCodexSpeedMode,
+} from "./codex-cli-speed"
+
+describe("Codex CLI speed mode", () => {
+  it("defaults unknown and missing values to standard mode", () => {
+    expect(resolveCodexSpeedMode(undefined)).toBe("standard")
+    expect(resolveCodexSpeedMode("priority")).toBe("standard")
+    expect(codexAppServerServiceTier(undefined)).toBeUndefined()
+  })
+
+  it("maps the user-facing fast mode to the app-server priority tier", () => {
+    expect(resolveCodexSpeedMode("fast")).toBe("fast")
+    expect(codexAppServerServiceTier("fast")).toBe("priority")
+  })
+})

+ 15 - 0
src/lib/codex-cli-speed.ts

@@ -0,0 +1,15 @@
+export type CodexSpeedMode = "standard" | "fast"
+
+export const DEFAULT_CODEX_SPEED_MODE: CodexSpeedMode = "standard"
+
+export function resolveCodexSpeedMode(value: unknown): CodexSpeedMode {
+  return value === "fast" ? "fast" : DEFAULT_CODEX_SPEED_MODE
+}
+
+/**
+ * Codex app-server exposes the Fast catalog tier as `priority`. Standard mode
+ * deliberately omits the field so QMAI remains compatible with older CLIs.
+ */
+export function codexAppServerServiceTier(value: unknown): "priority" | undefined {
+  return resolveCodexSpeedMode(value) === "fast" ? "priority" : undefined
+}

+ 35 - 0
src/lib/codex-cli-transport.spec.ts

@@ -132,6 +132,41 @@ describe("codex app-server transport", () => {
     expect(callbacks.onToken).toHaveBeenCalledWith("纯文本结果")
     expect(callbacks.onDone).toHaveBeenCalledOnce()
     expect(callbacks.onError).not.toHaveBeenCalled()
+    const starts = clientMock.call.mock.calls.filter(([method]) =>
+      method === "thread/start" || method === "turn/start"
+    )
+    expect(starts).toHaveLength(2)
+    expect(starts[0][1]).not.toHaveProperty("serviceTier")
+    expect(starts[1][1]).not.toHaveProperty("serviceTier")
+  })
+
+  it("passes Fast mode to both app-server starts as the priority service tier", async () => {
+    clientMock.call.mockImplementation(async (method: string) => {
+      if (method === "thread/start") {
+        return { thread: { id: "thread-fast" }, instructionSources: [] }
+      }
+      if (method === "turn/start") {
+        queueMicrotask(() => {
+          clientMock.handler?.onEnvelope?.({
+            method: "turn/completed",
+            params: { threadId: "thread-fast", turn: { status: "completed" } },
+          })
+        })
+        return { turn: { id: "turn-fast" } }
+      }
+      throw new Error(`unexpected method: ${method}`)
+    })
+
+    await streamCodexCli(
+      { ...config, codexSpeedMode: "fast" },
+      [{ role: "user", content: "测试快速档位" }],
+      { onToken: vi.fn(), onDone: vi.fn(), onError: vi.fn() },
+    )
+
+    const threadStart = clientMock.call.mock.calls.find(([method]) => method === "thread/start")
+    const turnStart = clientMock.call.mock.calls.find(([method]) => method === "turn/start")
+    expect(threadStart?.[1]).toEqual(expect.objectContaining({ serviceTier: "priority" }))
+    expect(turnStart?.[1]).toEqual(expect.objectContaining({ serviceTier: "priority" }))
   })
 
   it("fails closed when app-server reports inherited instruction sources", async () => {

+ 4 - 0
src/lib/codex-cli-transport.ts

@@ -1,5 +1,6 @@
 import type { LlmConfig } from "@/stores/wiki-store"
 import { resolveCodexCliTimeoutMinutes } from "@/lib/codex-cli-timeout"
+import { codexAppServerServiceTier } from "@/lib/codex-cli-speed"
 import type { ChatMessage, ContentBlock, RequestOverrides } from "./llm-providers"
 import type { StreamCallbacks } from "./llm-client"
 import { getCodexAppServerClient, type CodexAppServerEnvelope } from "./codex-app-server-client"
@@ -140,6 +141,7 @@ export async function streamCodexCli(
     rejectTurn = reject
   })
   const timeoutMinutes = resolveCodexCliTimeoutMinutes(config.codexCliTimeoutMinutes)
+  const serviceTier = codexAppServerServiceTier(config.codexSpeedMode)
   const timeoutMs = timeoutMinutes * 60_000
   const timeout = setTimeout(() => {
     if (!finished) {
@@ -169,6 +171,7 @@ export async function streamCodexCli(
       baseInstructions: systemPrompt || "You are QMAI's text generation model.",
       developerInstructions: "Only produce the requested text. Never use native tools, shell, file changes, MCP, plugins, skills, apps, browser, or subagents.",
       config: restrictedCodexConfig(),
+      ...(serviceTier ? { serviceTier } : {}),
     })
     if (started.instructionSources?.length) {
       throw new Error(`QMAI 禁止 Codex 加载本机或项目规则:${started.instructionSources.join(", ")}`)
@@ -239,6 +242,7 @@ export async function streamCodexCli(
       sandboxPolicy: { type: "readOnly", networkAccess: false },
       model: config.model.trim() || null,
       effort: codexReasoningEffort(config),
+      ...(serviceTier ? { serviceTier } : {}),
     })
     turnId = turn.turn.id
     if (finished) void client.interrupt(threadId, turnId)

+ 3 - 0
src/stores/wiki-store.ts

@@ -40,6 +40,7 @@ import {
   normalizeProviderConfigs,
   normalizeUserLlmConfig,
 } from "@/lib/llm-context-size"
+import type { CodexSpeedMode } from "@/lib/codex-cli-speed"
 
 const GRAPH_LABEL_MODE_KEY = "lk-graph-label-display-mode"
 const GRAPH_EDGE_COLOR_KEY = "lk-graph-edge-color"
@@ -156,6 +157,7 @@ interface LlmConfig {
   reasoning?: ReasoningConfig
   localCliIsolation?: boolean
   codexCliTimeoutMinutes?: number
+  codexSpeedMode?: CodexSpeedMode
   /** When false, Agent requests omit tools/tool_choice. Default/undefined = enabled. */
   functionCallingEnabled?: boolean
 }
@@ -456,6 +458,7 @@ export interface ProviderOverride {
   reasoning?: ReasoningConfig
   localCliIsolation?: boolean
   codexCliTimeoutMinutes?: number
+  codexSpeedMode?: CodexSpeedMode
   /** 是否在 AI 会话中显示该 provider 下的模型(仅用于自定义供应商)。默认 true。 */
   enabled?: boolean
   /** 已保存的模型列表(仅用于自定义供应商) */