diff --git a/docs/llm-configuration.md b/docs/llm-configuration.md index 220ec36..b106fb5 100644 --- a/docs/llm-configuration.md +++ b/docs/llm-configuration.md @@ -1,19 +1,28 @@ -# LLM Configuration and Usage +# 模型配置与使用 -本文总结 Workspace Agent 的 LLM 配置、连接测试、模型列表刷新、GitHub -Copilot 授权以及聊天运行时如何选择模型。 +本文总结 Workspace Agent 的模型配置、连接测试、模型列表刷新、ChatGPT +Plus/Pro Browser login、GitHub Copilot 授权以及聊天运行时如何选择模型。 ## 配置模型 -LLM 配置保存在本地 SQLite 的 `llm_configs` 表中。敏感字段会在主进程 +模型配置保存在本地 SQLite 的 `llm_configs` 表中。敏感字段会在主进程 写入前加密,renderer 只通过 preload 暴露的 IPC 能力创建、更新、测试和删除配置。 +设置页参考 pi-web 的信息架构,将配置按认证和部署方式分为三组: + +- 订阅登录:ChatGPT Plus/Pro、GitHub Copilot +- API Provider:OpenAI、Anthropic、DeepSeek、Kimi、MiniMax、Xiaomi MiMo +- 本地与自定义:Ollama、OpenAI Compatible endpoint + +每张配置卡会直接展示供应商、模型、启用状态、默认标记和最近连接状态,常用的 +测试、设为默认、编辑和删除操作无需先进入详情页。 + 主要字段如下: | 字段 | 说明 | | --- | --- | | `name` | UI 中展示的配置名称 | -| `provider` | 供应商类型,例如 `openai`、`custom`、`github-copilot` | +| `provider` | 供应商类型,例如 `openai`、`openai-codex`、`custom`、`github-copilot` | | `apiKey` | Provider API key 或运行时 session token,落库前加密 | | `authMetadata` | 仅主进程使用的加密授权元数据,例如 Copilot device flow token | | `baseUrl` | Provider 基础 URL,OpenAI Compatible / Ollama / Copilot 等需要 | @@ -36,17 +45,53 @@ LLM 配置保存在本地 SQLite 的 `llm_configs` 表中。敏感字段会在 | Provider | 用途 | 关键配置 | | --- | --- | --- | +| `openai-codex` | 使用 ChatGPT Plus/Pro 订阅访问 Codex 模型 | Browser login,无需手填 API key | | `openai` | OpenAI 官方 API | `apiKey`,可选 `baseUrl` | | `anthropic` | Anthropic Messages API | `apiKey`,可选 `baseUrl` | -| `deepseek` | DeepSeek OpenAI Compatible API | `apiKey`,可选 `baseUrl` | +| `deepseek` | DeepSeek 官方 OpenAI Compatible API | 只需 `apiKey`,默认 `https://api.deepseek.com` | +| `kimi` | Kimi 官方 OpenAI Compatible API | 只需 `apiKey`,默认 `https://api.moonshot.cn/v1` | | `ollama` | 本地 Ollama OpenAI Compatible API | `baseUrl`,通常是 `http://localhost:11434/v1` | | `minimax` | MiniMax OpenAI Compatible chat,以及图像/视频配置 | `apiKey`,可选 `baseUrl` | | `xiaomi` | Xiaomi MiMo reasoning 模型 | `apiKey`、`baseUrl` | | `custom` | 任意 OpenAI Compatible endpoint | `baseUrl`,可选 `apiKey` 和 `apiPath` | | `github-copilot` | GitHub Copilot chat endpoint | 通过 GitHub device flow 配置 | -`custom`、`ollama`、`minimax`、`github-copilot` 默认复用 OpenAI 适配器。 -`xiaomi` 使用专门适配器以保留 `reasoning_content`。 +`openai-codex` 强制使用 OpenAI Responses API;`custom`、`ollama`、`minimax`、 +`github-copilot` 默认复用 OpenAI 适配器。 +`deepseek` 和 `kimi` 使用能够保留 `reasoning_content` 的适配器;`xiaomi` +使用专门适配器处理多轮 reasoning 往返。 + +## DeepSeek 与 Kimi 官方配置 + +新增配置时选择 DeepSeek 或 Kimi,名称、官方 Base URL 和推荐模型会自动填充, +用户只需输入对应平台的 API key 即可保存和测试: + +| Provider | 官方 Base URL | 默认模型 | +| --- | --- | --- | +| DeepSeek | `https://api.deepseek.com` | `deepseek-v4-pro` | +| Kimi | `https://api.moonshot.cn/v1` | `kimi-k3` | + +两者测试连接成功后都会通过官方 `/models` 接口刷新模型目录。刷新到的可用模型 +会进入配置页和聊天区模型选择器,因此同一个 API key 下可以直接切换模型,而 +无需复制多份配置。内置静态模型列表只负责首次配置时的推荐值,官方返回的目录 +是后续可用性判断的依据。 + +## ChatGPT Plus/Pro Browser login + +`openai-codex` 使用带 PKCE 和 state 校验的浏览器 OAuth 流程: + +1. 在新增模型配置中选择 ChatGPT Plus/Pro,点击“浏览器登录”。 +2. 应用在 `127.0.0.1:1455` 启动一次性回调服务,并在系统浏览器打开 OpenAI + 授权页面。 +3. 用户完成登录后,浏览器回调把授权码交给主进程换取 access token 和 refresh + token;renderer 不接触 token。 +4. 主进程加密保存 refresh token、过期时间和 ChatGPT account ID,并把短期 + access token 写入既有加密凭据字段。 +5. access token 即将过期时自动刷新;请求返回 401 时强制刷新并重试一次。 + +Codex 请求发送到 `https://chatgpt.com/backend-api/codex/responses`,携带当前 +账户 ID,并固定使用无服务端存储的 Responses 请求。断开连接会清除本地保存的 +token 和授权元数据并停用该配置。 ## OpenAI Compatible 配置 @@ -140,7 +185,7 @@ Copilot 配置的运行时维护规则: ## 高级生成参数 -每个 LLM 配置可以设置一组可选的高级生成参数。所有字段留空时都会使用 +每个模型配置可以设置一组可选的高级生成参数。所有字段留空时都会使用 provider 或模型默认值: | 参数 | 范围 | 运行时行为 | @@ -148,7 +193,7 @@ provider 或模型默认值: | `temperature` | `0` 到 `2` | 传给 AgentLoop 的 `streamText` 调用 | | `topP` | `0` 到 `1` | 传给 AgentLoop 的 `streamText` 调用 | | `maxOutputTokens` | 正整数 | 限制单次 `streamText` 输出 token | -| `reasoningEffort` | `none` / `minimal` / `low` / `medium` / `high` / `xhigh` | 当模型目录未明确标记为不支持 reasoning 时,合并到 OpenAI provider options | +| `reasoningEffort` | `none` / `minimal` / `low` / `medium` / `high` / `xhigh` | 当模型目录未明确标记为不支持 reasoning 时,合并到对应 provider options;Kimi 的 `xhigh` 会映射为官方 `max` | 这些参数会随配置保存到本地 `llm_configs` 表。聊天主路径、自动化 headless 运行和子 Agent 运行都会复用同一套配置,避免主任务和委派任务的模型行为不一致。 @@ -160,7 +205,7 @@ provider 或模型默认值: 聊天发送统一从 `ai:executeTask` 进入主进程。运行时选择 LLM 的流程是: -1. 根据用户选择的 `llmConfigId` 找到配置;如果没有传入,则找默认配置。 +1. 根据用户选择的 `llmConfigId` 找到模型配置;如果没有传入,则找默认配置。 2. 如果用户显式选择了 `image` / `video` 配置,运行时先检查它是否健康: - `enabled !== false` - `lastCheckStatus === "success"` @@ -233,6 +278,8 @@ provider 的路径不是标准 `/v1/chat/completions` 时,再填写 `apiPath` | 配置增删改查、测试连接、模型刷新 IPC | `src/main/ipc/llm-config-handlers.ts` | | 连接测试请求构造和诊断 | `src/main/ipc/llm-config-connection.ts` | | OpenAI Compatible 模型发现 | `src/main/ipc/llm-config-models.ts` | +| ChatGPT Plus/Pro browser login | `src/main/ipc/openai-codex-auth.ts` | +| ChatGPT Plus/Pro token 维护 | `src/main/ipc/openai-codex-session.ts` | | GitHub Copilot device flow 和模型发现 | `src/main/ipc/github-copilot-auth.ts` | | GitHub Copilot session token 维护 | `src/main/ipc/github-copilot-session.ts` | | 运行时健康配置选择和模型创建 | `src/main/ipc/ai-models.ts` | @@ -240,6 +287,6 @@ provider 的路径不是标准 `/v1/chat/completions` 时,再填写 `apiPath` | 图像 / 视频媒体运行时 | `src/main/ipc/media-runtime.ts` | | Provider adapter 注册表 | `src/main/ai/adapters/index.ts` | | OpenAI / OpenAI Compatible adapter | `src/main/ai/adapters/openai.ts` | -| LLM 配置表和模型目录表 | `src/main/db/schema.ts`、`src/main/db/index.ts` | +| 模型配置表和模型目录表 | `src/main/db/schema.ts`、`src/main/db/index.ts` | | 设置页编辑弹窗 | `src/renderer/components/settings/LlmConfigEditModal.tsx` | | 聊天模型选择器 | `src/renderer/components/chat/ModelSelector.tsx` | diff --git a/src/main/ai/adapters/__tests__/deepseek.test.ts b/src/main/ai/adapters/__tests__/deepseek.test.ts new file mode 100644 index 0000000..9a33f00 --- /dev/null +++ b/src/main/ai/adapters/__tests__/deepseek.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; + +import { DeepSeekAdapter } from "../deepseek"; + +describe("DeepSeekAdapter provider options", () => { + it("passes reasoning effort to DeepSeek reasoning models", () => { + const adapter = new DeepSeekAdapter(); + + expect( + adapter.buildProviderOptions({ + provider: "deepseek", + apiKey: "sk-test", + model: "deepseek-v4-pro", + reasoningEffort: "high", + modelCapabilities: { + preferredApi: "chat_completions", + supportsReasoning: true, + supportsTools: true, + supportsVision: null, + }, + }), + ).toEqual({ + deepseek: { + reasoningEffort: "high", + }, + }); + }); + + it("maps the UI xhigh level to Kimi's official max level", () => { + const adapter = new DeepSeekAdapter(); + + expect( + adapter.buildProviderOptions({ + provider: "kimi", + apiKey: "sk-test", + baseUrl: "https://api.moonshot.cn/v1", + model: "kimi-k3", + reasoningEffort: "xhigh", + modelCapabilities: { + preferredApi: "chat_completions", + supportsReasoning: true, + supportsTools: true, + supportsVision: true, + }, + }), + ).toEqual({ + deepseek: { + reasoningEffort: "max", + }, + }); + }); + + it("omits reasoning effort when the selected model does not support it", () => { + const adapter = new DeepSeekAdapter(); + + expect( + adapter.buildProviderOptions({ + provider: "kimi", + apiKey: "sk-test", + model: "future-model", + reasoningEffort: "high", + modelCapabilities: { + preferredApi: "chat_completions", + supportsReasoning: false, + supportsTools: true, + supportsVision: null, + }, + }), + ).toEqual({}); + }); +}); diff --git a/src/main/ai/adapters/__tests__/openai.test.ts b/src/main/ai/adapters/__tests__/openai.test.ts index d56244e..3362a82 100644 --- a/src/main/ai/adapters/__tests__/openai.test.ts +++ b/src/main/ai/adapters/__tests__/openai.test.ts @@ -13,7 +13,11 @@ vi.mock("@ai-sdk/openai", () => ({ createOpenAI: openAiMock.createOpenAI, })); -import { buildGithubCopilotFetch, OpenAIAdapter } from "../openai"; +import { + buildGithubCopilotFetch, + buildOpenAICodexFetch, + OpenAIAdapter, +} from "../openai"; describe("OpenAIAdapter API selection", () => { it("uses Responses API when an OpenAI-compatible model catalog prefers responses", () => { @@ -236,3 +240,79 @@ describe("OpenAIAdapter GitHub Copilot fetch", () => { ); }); }); + +describe("OpenAIAdapter ChatGPT subscription fetch", () => { + it("adds Codex account headers and forces stateless Responses requests", async () => { + const providerFetch = vi.fn( + async () => + new Response( + 'data: {"type":"response.done","response":{"status":"completed"}}\n\n', + { + headers: { "Content-Type": "text/event-stream" }, + status: 200, + }, + ), + ); + const resolveApiKey = vi.fn(async () => "fresh-access-token"); + const codexFetch = buildOpenAICodexFetch( + providerFetch, + resolveApiKey, + "account-123", + ); + + const response = await codexFetch( + "https://chatgpt.com/backend-api/codex/responses", + { + body: JSON.stringify({ + model: "gpt-5.5", + store: true, + stream: true, + }), + headers: { + Authorization: "Bearer stale-token", + "Content-Type": "application/json", + }, + method: "POST", + }, + ); + + expect(resolveApiKey).toHaveBeenCalledWith(); + const request = providerFetch.mock.calls[0]; + const headers = request?.[1]?.headers as Headers; + expect(headers.get("Authorization")).toBe("Bearer fresh-access-token"); + expect(headers.get("chatgpt-account-id")).toBe("account-123"); + expect(headers.get("originator")).toBe("filework"); + expect(JSON.parse(String(request?.[1]?.body))).toEqual( + expect.objectContaining({ + include: ["reasoning.encrypted_content"], + store: false, + }), + ); + expect(await response.text()).toContain('"type":"response.completed"'); + }); + + it("refreshes once after an unauthorized Codex response", async () => { + const providerFetch = vi + .fn() + .mockResolvedValueOnce(new Response("expired", { status: 401 })) + .mockResolvedValueOnce(new Response("{}", { status: 200 })); + const resolveApiKey = vi + .fn() + .mockResolvedValueOnce("cached-access-token") + .mockResolvedValueOnce("refreshed-access-token"); + const codexFetch = buildOpenAICodexFetch( + providerFetch, + resolveApiKey, + "account-123", + ); + + const response = await codexFetch( + "https://chatgpt.com/backend-api/codex/responses", + { method: "POST" }, + ); + + expect(response.status).toBe(200); + expect(resolveApiKey).toHaveBeenNthCalledWith(1); + expect(resolveApiKey).toHaveBeenNthCalledWith(2, { forceRefresh: true }); + }); +}); diff --git a/src/main/ai/adapters/__tests__/resolve-adapter-name.test.ts b/src/main/ai/adapters/__tests__/resolve-adapter-name.test.ts index ba7d26e..a165b0c 100644 --- a/src/main/ai/adapters/__tests__/resolve-adapter-name.test.ts +++ b/src/main/ai/adapters/__tests__/resolve-adapter-name.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { createModelWithAdapter, resolveAdapterName } from "../index"; +import { + createModelWithAdapter, + getAdapter, + resolveAdapterName, +} from "../index"; describe("resolveAdapterName", () => { it("returns the provider unchanged for plain OpenAI configs", () => { @@ -67,3 +71,9 @@ describe("createModelWithAdapter (Xiaomi auto-routing)", () => { expect(adapter.name).toBe("xiaomi"); }); }); + +describe("Kimi adapter routing", () => { + it("uses the reasoning-compatible adapter for the official Kimi provider", () => { + expect(getAdapter("kimi").name).toBe("deepseek"); + }); +}); diff --git a/src/main/ai/adapters/base.ts b/src/main/ai/adapters/base.ts index f3924a5..895dbca 100644 --- a/src/main/ai/adapters/base.ts +++ b/src/main/ai/adapters/base.ts @@ -17,6 +17,7 @@ import type { LanguageModel } from "ai"; export interface ProviderConfig { provider: string; apiKey: string; + accountId?: string | null; resolveApiKey?: (options?: { forceRefresh?: boolean }) => Promise; baseUrl?: string | null; apiPath?: string | null; diff --git a/src/main/ai/adapters/deepseek.ts b/src/main/ai/adapters/deepseek.ts index 6fda00d..42476c9 100644 --- a/src/main/ai/adapters/deepseek.ts +++ b/src/main/ai/adapters/deepseek.ts @@ -8,9 +8,9 @@ import { getProviderFetch } from "../provider-fetch"; import { type CacheMetrics, NO_CACHE_METRICS, - NO_PROVIDER_OPTIONS, type ProviderAdapter, type ProviderConfig, + type ProviderOptionMap, } from "./base"; export class DeepSeekAdapter implements ProviderAdapter { @@ -30,8 +30,32 @@ export class DeepSeekAdapter implements ProviderAdapter { return false; } - buildProviderOptions() { - return NO_PROVIDER_OPTIONS; + buildProviderOptions(config?: ProviderConfig): ProviderOptionMap { + if ( + !config?.reasoningEffort || + config.reasoningEffort === "none" || + config.modelCapabilities?.supportsReasoning === false + ) { + return {}; + } + + const effort = + config.provider === "kimi" + ? config.reasoningEffort === "xhigh" || config.reasoningEffort === "max" + ? "max" + : config.reasoningEffort === "medium" || + config.reasoningEffort === "high" + ? "high" + : "low" + : config.reasoningEffort === "minimal" + ? "low" + : config.reasoningEffort; + + return { + deepseek: { + reasoningEffort: effort, + }, + }; } extractCacheMetrics( diff --git a/src/main/ai/adapters/index.ts b/src/main/ai/adapters/index.ts index 00eb219..408c740 100644 --- a/src/main/ai/adapters/index.ts +++ b/src/main/ai/adapters/index.ts @@ -27,6 +27,9 @@ const deepseekAdapter = new DeepSeekAdapter(); const adapters: Record = { anthropic: new AnthropicAdapter(), deepseek: deepseekAdapter, + // Kimi 的官方接口兼容 OpenAI Chat Completions,并通过 + // reasoning_content 返回思考过程;复用 DeepSeek 适配器可保留该字段。 + kimi: deepseekAdapter, openai: openaiAdapter, // "custom"、"ollama" 和 "minimax" 共用 OpenAI 适配器 —— 它们都暴露 // OpenAI 兼容的 /v1/chat/completions 端点。baseUrl 的解析 diff --git a/src/main/ai/adapters/openai.ts b/src/main/ai/adapters/openai.ts index 9851063..e1f0b46 100644 --- a/src/main/ai/adapters/openai.ts +++ b/src/main/ai/adapters/openai.ts @@ -21,6 +21,7 @@ import { resolveOpenAICompatibleBaseUrl } from "./openai-compatible-url"; type ResolveApiKey = NonNullable; const usesOpenAIChatCompletions = (config: ProviderConfig): boolean => { + if (config.provider === "openai-codex") return false; const isCustomEndpoint = config.provider === "custom" || (config.baseUrl != null && !config.baseUrl.includes("api.openai.com")); @@ -67,6 +68,119 @@ export function buildGithubCopilotFetch( }; } +function rewriteOpenAICodexRequestBody(body: BodyInit | null | undefined) { + if (typeof body !== "string") return body; + try { + const parsed = JSON.parse(body) as Record; + const include = Array.isArray(parsed.include) + ? parsed.include.filter( + (value): value is string => typeof value === "string", + ) + : []; + if (!include.includes("reasoning.encrypted_content")) { + include.push("reasoning.encrypted_content"); + } + return JSON.stringify({ + ...parsed, + include, + store: false, + }); + } catch { + return body; + } +} + +function normalizeOpenAICodexStream(response: Response): Response { + if (!response.body || !response.ok) return response; + const contentType = response.headers.get("content-type") ?? ""; + if (!contentType.includes("text/event-stream")) return response; + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + let pending = ""; + const body = new ReadableStream({ + async pull(controller) { + const { done, value } = await reader.read(); + if (done) { + if (pending) { + controller.enqueue( + encoder.encode( + pending.replace( + /("type"\s*:\s*)"response\.done"/g, + '$1"response.completed"', + ), + ), + ); + } + controller.close(); + return; + } + + pending += decoder.decode(value, { stream: true }); + const lastNewline = pending.lastIndexOf("\n"); + if (lastNewline < 0) return; + const complete = pending.slice(0, lastNewline + 1); + pending = pending.slice(lastNewline + 1); + controller.enqueue( + encoder.encode( + complete.replace( + /("type"\s*:\s*)"response\.done"/g, + '$1"response.completed"', + ), + ), + ); + }, + cancel(reason) { + return reader.cancel(reason); + }, + }); + + return new Response(body, { + headers: response.headers, + status: response.status, + statusText: response.statusText, + }); +} + +export function buildOpenAICodexFetch( + providerFetch: typeof globalThis.fetch, + resolveApiKey: ResolveApiKey | undefined, + accountId: string, +): typeof globalThis.fetch { + const send = async ( + input: RequestInfo | URL, + init: RequestInit | undefined, + options?: { forceRefresh?: boolean }, + ) => { + const headers = new Headers(init?.headers); + const apiKey = resolveApiKey + ? options + ? await resolveApiKey(options) + : await resolveApiKey() + : null; + if (apiKey) headers.set("Authorization", `Bearer ${apiKey}`); + headers.set("chatgpt-account-id", accountId); + headers.set("originator", "filework"); + headers.set("OpenAI-Beta", "responses=experimental"); + headers.set("Accept", "text/event-stream"); + headers.set("Content-Type", "application/json"); + return providerFetch(input, { + ...init, + body: rewriteOpenAICodexRequestBody(init?.body), + headers, + }); + }; + + return async (input: RequestInfo | URL, init?: RequestInit) => { + let response = await send(input, init); + if (response.status === 401 && resolveApiKey) { + response = await send(input, init, { forceRefresh: true }); + } + return normalizeOpenAICodexStream(response); + }; +} + export class OpenAIAdapter implements ProviderAdapter { readonly name = "openai"; @@ -82,7 +196,13 @@ export class OpenAIAdapter implements ProviderAdapter { providerFetch ?? globalThis.fetch, config.resolveApiKey, ) - : providerFetch; + : config.provider === "openai-codex" && config.accountId + ? buildOpenAICodexFetch( + providerFetch ?? globalThis.fetch, + config.resolveApiKey, + config.accountId, + ) + : providerFetch; const openai = createOpenAI({ apiKey: config.apiKey || "", baseURL: resolvedBaseUrl, @@ -114,6 +234,11 @@ export class OpenAIAdapter implements ProviderAdapter { return { openai: { parallelToolCalls: false, + ...(config?.provider === "openai-codex" && { + include: ["reasoning.encrypted_content"], + store: false, + textVerbosity: "low", + }), ...(shouldSendReasoningEffort && { reasoningEffort: config?.reasoningEffort, }), diff --git a/src/main/ai/error-classifier.ts b/src/main/ai/error-classifier.ts index b3ff8a2..18f6b7a 100644 --- a/src/main/ai/error-classifier.ts +++ b/src/main/ai/error-classifier.ts @@ -102,7 +102,7 @@ const ERROR_DEFAULTS: Record< shouldCompress: false, maxRetries: 0, backoffMs: 0, - userMessage: "API Key 无效或已过期,请在设置中检查该渠道配置", + userMessage: "API Key 无效或已过期,请在设置中检查该模型配置", recoveryActions: ["settings"], }, billing: { diff --git a/src/main/db/__tests__/automation-runs-migration.test.ts b/src/main/db/__tests__/automation-runs-migration.test.ts index 65f3ed2..b5cf4bb 100644 --- a/src/main/db/__tests__/automation-runs-migration.test.ts +++ b/src/main/db/__tests__/automation-runs-migration.test.ts @@ -14,6 +14,7 @@ const sqliteMock = vi.hoisted(() => { hasTriageStatus: false, migrated: false, widenedCopilotProvider: false, + widenedKimiProvider: false, }; class FakeDatabase { @@ -62,6 +63,12 @@ const sqliteMock = vi.hoisted(() => { ) { state.widenedCopilotProvider = true; } + if ( + sql.includes("CREATE TABLE llm_configs_new") && + sql.includes("'kimi'") + ) { + state.widenedKimiProvider = true; + } if ( sql.includes("ALTER TABLE automation_runs ADD COLUMN next_retry_at") ) { @@ -150,6 +157,7 @@ describe("automation run migrations", () => { sqliteMock.state.hasTriageStatus = false; sqliteMock.state.migrated = false; sqliteMock.state.widenedCopilotProvider = false; + sqliteMock.state.widenedKimiProvider = false; }); afterEach(() => { @@ -186,6 +194,14 @@ describe("automation run migrations", () => { expect(sqliteMock.state.widenedCopilotProvider).toBe(true); }); + it("widens old llm_configs provider checks for Kimi", async () => { + const { initDatabase } = await import("../index"); + + await expect(initDatabase()).resolves.toBeUndefined(); + + expect(sqliteMock.state.widenedKimiProvider).toBe(true); + }); + it("adds task runtime binding columns for existing databases", async () => { const { initDatabase } = await import("../index"); diff --git a/src/main/db/index.ts b/src/main/db/index.ts index 7c1e290..d67e37c 100644 --- a/src/main/db/index.ts +++ b/src/main/db/index.ts @@ -108,7 +108,7 @@ export const initDatabase = async () => { CREATE TABLE IF NOT EXISTS llm_configs ( id TEXT PRIMARY KEY, name TEXT NOT NULL, - provider TEXT NOT NULL CHECK(provider IN ('openai','anthropic','deepseek','ollama','custom','minimax','xiaomi','github-copilot')), + provider TEXT NOT NULL CHECK(provider IN ('openai','anthropic','deepseek','kimi','ollama','custom','minimax','xiaomi','github-copilot','openai-codex')), api_key TEXT, auth_metadata TEXT, base_url TEXT, @@ -844,6 +844,119 @@ export const initDatabase = async () => { ); } } + // 第四轮放宽:ChatGPT Plus/Pro 浏览器登录使用独立的 openai-codex + // provider,避免和 OpenAI API Key 配置混为一谈。 + const llmConfigsSqlV4 = ( + sqlite + .prepare( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='llm_configs'", + ) + .get() as { sql?: string } | undefined + )?.sql; + if (llmConfigsSqlV4 && !llmConfigsSqlV4.includes("openai-codex")) { + try { + sqlite.transaction(() => { + sqlite.exec(` + CREATE TABLE llm_configs_new ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + provider TEXT NOT NULL CHECK(provider IN ('openai','anthropic','deepseek','ollama','custom','minimax','xiaomi','github-copilot','openai-codex')), + api_key TEXT, + auth_metadata TEXT, + base_url TEXT, + api_path TEXT, + model TEXT NOT NULL, + temperature REAL, + top_p REAL, + max_output_tokens INTEGER, + reasoning_effort TEXT, + modality TEXT NOT NULL DEFAULT 'chat' CHECK(modality IN ('chat','image','video')), + is_default INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + last_checked_at TEXT, + last_check_status TEXT, + last_check_message TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + INSERT INTO llm_configs_new (id, name, provider, api_key, auth_metadata, base_url, api_path, model, temperature, top_p, max_output_tokens, reasoning_effort, modality, is_default, enabled, last_checked_at, last_check_status, last_check_message, created_at, updated_at) + SELECT id, name, provider, api_key, auth_metadata, base_url, api_path, model, + temperature, top_p, max_output_tokens, reasoning_effort, + COALESCE(modality, 'chat'), + is_default, + COALESCE(enabled, 1), + last_checked_at, last_check_status, last_check_message, + created_at, updated_at + FROM llm_configs; + DROP TABLE llm_configs; + ALTER TABLE llm_configs_new RENAME TO llm_configs; + `); + })(); + console.log( + "[db] widened llm_configs.provider CHECK to include openai-codex", + ); + } catch (err) { + console.error( + "[db] failed to widen llm_configs.provider CHECK; existing configs remain usable, new openai-codex provider rows will fail:", + err instanceof Error ? err.message : err, + ); + } + } + // 第五轮放宽:Kimi 官方 API 使用独立 provider,以便内置官方端点和模型发现。 + const llmConfigsSqlV5 = ( + sqlite + .prepare( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='llm_configs'", + ) + .get() as { sql?: string } | undefined + )?.sql; + if (llmConfigsSqlV5 && !llmConfigsSqlV5.includes("kimi")) { + try { + sqlite.transaction(() => { + sqlite.exec(` + CREATE TABLE llm_configs_new ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + provider TEXT NOT NULL CHECK(provider IN ('openai','anthropic','deepseek','kimi','ollama','custom','minimax','xiaomi','github-copilot','openai-codex')), + api_key TEXT, + auth_metadata TEXT, + base_url TEXT, + api_path TEXT, + model TEXT NOT NULL, + temperature REAL, + top_p REAL, + max_output_tokens INTEGER, + reasoning_effort TEXT, + modality TEXT NOT NULL DEFAULT 'chat' CHECK(modality IN ('chat','image','video')), + is_default INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + last_checked_at TEXT, + last_check_status TEXT, + last_check_message TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + INSERT INTO llm_configs_new (id, name, provider, api_key, auth_metadata, base_url, api_path, model, temperature, top_p, max_output_tokens, reasoning_effort, modality, is_default, enabled, last_checked_at, last_check_status, last_check_message, created_at, updated_at) + SELECT id, name, provider, api_key, auth_metadata, base_url, api_path, model, + temperature, top_p, max_output_tokens, reasoning_effort, + COALESCE(modality, 'chat'), + is_default, + COALESCE(enabled, 1), + last_checked_at, last_check_status, last_check_message, + created_at, updated_at + FROM llm_configs; + DROP TABLE llm_configs; + ALTER TABLE llm_configs_new RENAME TO llm_configs; + `); + })(); + console.log("[db] widened llm_configs.provider CHECK to include kimi"); + } catch (err) { + console.error( + "[db] failed to widen llm_configs.provider CHECK; existing configs remain usable, new kimi provider rows will fail:", + err instanceof Error ? err.message : err, + ); + } + } // 迁移:recent_workspaces 缺少 kind/metadata 列时补充 const recentCols = sqlite.pragma("table_info(recent_workspaces)") as { @@ -2513,11 +2626,13 @@ export type LlmProvider = | "openai" | "anthropic" | "deepseek" + | "kimi" | "ollama" | "custom" | "minimax" | "xiaomi" - | "github-copilot"; + | "github-copilot" + | "openai-codex"; export type LlmModality = "chat" | "image" | "video"; export type LlmCheckStatus = "success" | "error"; diff --git a/src/main/db/schema.ts b/src/main/db/schema.ts index 685cdaa..f6584a5 100644 --- a/src/main/db/schema.ts +++ b/src/main/db/schema.ts @@ -310,11 +310,13 @@ export const llmConfigs = sqliteTable("llm_configs", { "openai", "anthropic", "deepseek", + "kimi", "ollama", "custom", "minimax", "xiaomi", "github-copilot", + "openai-codex", ], }).notNull(), apiKey: text("api_key"), diff --git a/src/main/ipc/__tests__/ai-handlers-media-runtime.test.ts b/src/main/ipc/__tests__/ai-handlers-media-runtime.test.ts index 57ae5c0..f5b1681 100644 --- a/src/main/ipc/__tests__/ai-handlers-media-runtime.test.ts +++ b/src/main/ipc/__tests__/ai-handlers-media-runtime.test.ts @@ -187,6 +187,7 @@ vi.mock("../ai-models", () => ({ adapter: { extractCacheMetrics: vi.fn(() => ({})) }, generationOptions: {}, model: "chat-model", + modelCapabilities: null, modelLimits: { contextWindow: null, maxOutputTokens: null }, providerNativeCompaction: { enabled: false, @@ -354,6 +355,7 @@ describe("ai:executeTask media modality routing", () => { }, generationOptions: {}, model: "chat-model", + modelCapabilities: gpt55Config.modelCapabilities, modelLimits: { contextWindow: 1_050_000, maxOutputTokens: null }, modelId: "gpt-5.5", configId: "chat-cfg", @@ -426,6 +428,22 @@ describe("ai:executeTask media modality routing", () => { }; }; + it("forwards chat-level model and reasoning overrides to model resolution", async () => { + const aiModels = await import("../ai-models"); + + await runGpt55ChatTaskWithProviderCount(12_000, { + modelId: "gpt-5.6-terra", + reasoningEffort: "high", + }); + + expect( + vi.mocked(aiModels.getModelAndAdapterByConfigId), + ).toHaveBeenCalledWith("chat-cfg", { + modelId: "gpt-5.6-terra", + reasoningEffort: "high", + }); + }); + it("stores parent plus subagent token usage for chat tasks", async () => { agentLoopRunMock.mockImplementation(async function* () { yield { diff --git a/src/main/ipc/__tests__/ai-models.test.ts b/src/main/ipc/__tests__/ai-models.test.ts index d907b56..d7527ed 100644 --- a/src/main/ipc/__tests__/ai-models.test.ts +++ b/src/main/ipc/__tests__/ai-models.test.ts @@ -4,6 +4,7 @@ import type { LlmConfig } from "../../db"; const dbMock = vi.hoisted(() => ({ getLlmConfig: vi.fn(), getLlmConfigs: vi.fn(), + getLlmModelCatalog: vi.fn(), })); const adapterMock = vi.hoisted(() => ({ @@ -101,7 +102,7 @@ describe("ai-models LLM config selection", () => { ]); expect(() => selectAvailableChatLlmConfig("missing")).toThrow( - /没有可用的聊天 LLM 配置/, + /没有可用的聊天模型配置/, ); }); @@ -126,6 +127,68 @@ describe("ai-models LLM config selection", () => { ); }); + it("uses a catalog model and chat-level reasoning override for the request", () => { + const selected = makeConfig({ id: "selected" }); + dbMock.getLlmConfig.mockReturnValue(selected); + dbMock.getLlmConfigs.mockReturnValue([selected]); + dbMock.getLlmModelCatalog.mockReturnValue([ + { + configId: "selected", + modelId: "gpt-5.6-terra", + label: "GPT-5.6 Terra", + capabilities: { + preferredApi: "responses", + supportsReasoning: true, + supportsTools: true, + supportsVision: true, + }, + contextWindow: 400_000, + maxOutputTokens: 64_000, + fetchedAt: "2026-06-22T07:00:00.000Z", + createdAt: "2026-06-22T07:00:00.000Z", + updatedAt: "2026-06-22T07:00:00.000Z", + }, + ]); + adapterMock.createModelWithAdapter.mockReturnValue({ + model: "model", + adapter: { buildProviderOptions: () => ({}) }, + }); + + const result = getModelAndAdapterByConfigId("selected", { + modelId: "gpt-5.6-terra", + reasoningEffort: "high", + }); + + expect(result.modelId).toBe("gpt-5.6-terra"); + expect(result.modelLimits).toEqual({ + contextWindow: 400_000, + maxOutputTokens: 8192, + }); + expect(result.generationOptions.reasoningEffort).toBe("high"); + expect(adapterMock.createModelWithAdapter).toHaveBeenCalledWith( + expect.objectContaining({ + model: "gpt-5.6-terra", + reasoningEffort: "high", + modelCapabilities: expect.objectContaining({ + supportsReasoning: true, + }), + }), + ); + }); + + it("rejects a chat-level model that is not in the selected config catalog", () => { + const selected = makeConfig({ id: "selected" }); + dbMock.getLlmConfig.mockReturnValue(selected); + dbMock.getLlmConfigs.mockReturnValue([selected]); + dbMock.getLlmModelCatalog.mockReturnValue([]); + + expect(() => + getModelAndAdapterByConfigId("selected", { + modelId: "untrusted-model", + }), + ).toThrow(/不属于当前模型配置/); + }); + it("passes provider-native compaction trigger metadata into the adapter", () => { const selected = makeConfig({ id: "selected", @@ -189,6 +252,36 @@ describe("ai-models LLM config selection", () => { }); }); + it("uses Kimi's official endpoint and K3 context window when omitted from config", () => { + const selected = makeConfig({ + id: "selected", + provider: "kimi", + baseUrl: null, + model: "kimi-k3", + modelContextWindow: null, + modelMaxOutputTokens: null, + maxOutputTokens: null, + }); + dbMock.getLlmConfig.mockReturnValue(selected); + dbMock.getLlmConfigs.mockReturnValue([selected]); + adapterMock.createModelWithAdapter.mockReturnValue({ + model: "model", + adapter: { buildProviderOptions: () => ({}) }, + }); + + const result = getModelAndAdapterByConfigId("selected"); + + expect(adapterMock.createModelWithAdapter).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "kimi", + baseUrl: "https://api.moonshot.cn/v1", + model: "kimi-k3", + modelContextWindow: 1_000_000, + }), + ); + expect(result.modelLimits.contextWindow).toBe(1_000_000); + }); + it("returns advanced generation options from the selected config", () => { const selected = makeConfig({ id: "selected", diff --git a/src/main/ipc/__tests__/llm-config-connection.test.ts b/src/main/ipc/__tests__/llm-config-connection.test.ts index 732dcd2..3dfa60e 100644 --- a/src/main/ipc/__tests__/llm-config-connection.test.ts +++ b/src/main/ipc/__tests__/llm-config-connection.test.ts @@ -3,6 +3,44 @@ import { describe, expect, it, vi } from "vitest"; import { testLlmConfigConnection } from "../llm-config-connection"; describe("testLlmConfigConnection", () => { + it.each([ + [ + "deepseek", + "deepseek-v4-pro", + "https://api.deepseek.com/chat/completions", + ], + ["kimi", "kimi-k3", "https://api.moonshot.cn/v1/chat/completions"], + ] as const)("uses the official %s endpoint when only an API key and model are provided", async (provider, model, expectedUrl) => { + const fetchImpl = vi.fn( + async () => new Response("{}", { status: 200 }), + ); + + const result = await testLlmConfigConnection( + { + provider, + apiKey: "sk-official", + baseUrl: null, + apiPath: null, + model, + modality: "chat", + }, + fetchImpl, + ); + + expect(result).toMatchObject({ + status: "success", + diagnostics: { provider, model, url: expectedUrl }, + }); + expect(fetchImpl).toHaveBeenCalledWith( + expectedUrl, + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer sk-official", + }), + }), + ); + }); + it("posts a minimal OpenAI-compatible chat completion to the configured API path", async () => { const fetchImpl = vi.fn( async () => new Response("{}", { status: 200 }), @@ -112,6 +150,44 @@ describe("testLlmConfigConnection", () => { ); }); + it("uses the Codex Responses endpoint for a ChatGPT subscription", async () => { + const fetchImpl = vi.fn( + async () => new Response("{}", { status: 200 }), + ); + + const result = await testLlmConfigConnection( + { + provider: "openai-codex", + apiKey: "chatgpt-access-token", + accountId: "account-123", + baseUrl: "https://chatgpt.com/backend-api/codex", + apiPath: null, + model: "gpt-5.5", + modality: "chat", + }, + fetchImpl, + ); + const request = fetchImpl.mock.calls[0]?.[1] as RequestInit | undefined; + + expect(result.status).toBe("success"); + expect(fetchImpl).toHaveBeenCalledWith( + "https://chatgpt.com/backend-api/codex/responses", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + Authorization: "Bearer chatgpt-access-token", + "chatgpt-account-id": "account-123", + originator: "filework", + }), + }), + ); + expect(JSON.parse(String(request?.body))).toMatchObject({ + model: "gpt-5.5", + store: false, + stream: true, + }); + }); + it("posts a minimal OpenAI-compatible image generation request for image configs", async () => { const fetchImpl = vi.fn( async () => diff --git a/src/main/ipc/__tests__/llm-config-models.test.ts b/src/main/ipc/__tests__/llm-config-models.test.ts index da90bc1..b5b9613 100644 --- a/src/main/ipc/__tests__/llm-config-models.test.ts +++ b/src/main/ipc/__tests__/llm-config-models.test.ts @@ -23,6 +23,19 @@ describe("inferLlmModelCapabilities", () => { inferLlmModelCapabilities("deepseek-v4-flash").supportsReasoning, ).toBe(true); }); + + it("marks current Kimi models as reasoning and vision capable", () => { + expect(inferLlmModelCapabilities("kimi-k3")).toMatchObject({ + preferredApi: "chat_completions", + supportsReasoning: true, + supportsTools: true, + supportsVision: true, + }); + expect(inferLlmModelCapabilities("kimi-k2.7-code")).toMatchObject({ + supportsReasoning: true, + supportsTools: true, + }); + }); }); describe("fetchOpenAICompatibleModels", () => { diff --git a/src/main/ipc/__tests__/llm-config-validate.test.ts b/src/main/ipc/__tests__/llm-config-validate.test.ts index afd4748..5458273 100644 --- a/src/main/ipc/__tests__/llm-config-validate.test.ts +++ b/src/main/ipc/__tests__/llm-config-validate.test.ts @@ -1,6 +1,45 @@ import { describe, expect, it } from "vitest"; import { validateLlmConfigPayload } from "../llm-config-handlers"; +describe("validateLlmConfigPayload — ChatGPT subscription", () => { + it("accepts browser-authenticated configs without a manual API key", () => { + const err = validateLlmConfigPayload({ + name: "ChatGPT Plus", + provider: "openai-codex", + baseUrl: "https://chatgpt.com/backend-api/codex", + model: "gpt-5.5", + }); + + expect(err).toBeNull(); + }); +}); + +describe("validateLlmConfigPayload — official API providers", () => { + it.each([ + ["deepseek", "DeepSeek", "deepseek-v4-pro"], + ["kimi", "Kimi", "kimi-k3"], + ] as const)("accepts %s with an API key and official defaults", (provider, name, model) => { + expect( + validateLlmConfigPayload({ + name, + provider, + apiKey: "sk-test", + model, + }), + ).toBeNull(); + }); + + it("requires an API key for Kimi", () => { + expect( + validateLlmConfigPayload({ + name: "Kimi", + provider: "kimi", + model: "kimi-k3", + }), + ).toMatch(/apiKey/); + }); +}); + describe("validateLlmConfigPayload — xiaomi provider", () => { it("accepts a complete xiaomi config (apiKey + baseUrl + model)", () => { const err = validateLlmConfigPayload({ diff --git a/src/main/ipc/__tests__/openai-codex-auth.test.ts b/src/main/ipc/__tests__/openai-codex-auth.test.ts new file mode 100644 index 0000000..85efbd9 --- /dev/null +++ b/src/main/ipc/__tests__/openai-codex-auth.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + createOpenAICodexAuthorizationUrl, + extractOpenAICodexAccountId, + parseOpenAICodexAuthMetadata, + refreshOpenAICodexCredentials, + serializeOpenAICodexAuthMetadata, + shouldRefreshOpenAICodexCredentials, +} from "../openai-codex-auth"; + +describe("OpenAI Codex browser authentication", () => { + it("builds the PKCE browser login URL for ChatGPT subscriptions", () => { + const url = new URL( + createOpenAICodexAuthorizationUrl({ + challenge: "challenge-value", + state: "state-value", + }), + ); + + expect(url.origin).toBe("https://auth.openai.com"); + expect(url.pathname).toBe("/oauth/authorize"); + expect(url.searchParams.get("response_type")).toBe("code"); + expect(url.searchParams.get("redirect_uri")).toBe( + "http://localhost:1455/auth/callback", + ); + expect(url.searchParams.get("code_challenge")).toBe("challenge-value"); + expect(url.searchParams.get("code_challenge_method")).toBe("S256"); + expect(url.searchParams.get("state")).toBe("state-value"); + expect(url.searchParams.get("scope")).toContain("offline_access"); + }); + + it("round-trips encrypted auth metadata without losing refresh data", () => { + const metadata = { + version: 1 as const, + accessTokenExpiresAt: "2026-08-01T12:00:00.000Z", + accountId: "account-123", + refreshToken: "refresh-token", + }; + + expect( + parseOpenAICodexAuthMetadata(serializeOpenAICodexAuthMetadata(metadata)), + ).toEqual(metadata); + }); + + it("extracts the ChatGPT account id from an OAuth id token", () => { + const encode = (value: object) => + Buffer.from(JSON.stringify(value)).toString("base64url"); + const idToken = `${encode({ alg: "none" })}.${encode({ + "https://api.openai.com/auth": { + chatgpt_account_id: "account-from-id-token", + }, + })}.signature`; + + expect(extractOpenAICodexAccountId(idToken)).toBe("account-from-id-token"); + }); + + it("refreshes credentials before the access token expires", async () => { + expect( + shouldRefreshOpenAICodexCredentials( + { accessTokenExpiresAt: "2026-08-01T12:04:00.000Z" }, + Date.parse("2026-08-01T12:00:00.000Z"), + ), + ).toBe(true); + + const fetchImpl = vi.fn(async () => + Response.json({ + access_token: "new-access-token", + refresh_token: "new-refresh-token", + expires_in: 3600, + }), + ); + + const refreshed = await refreshOpenAICodexCredentials( + "old-refresh-token", + fetchImpl, + Date.parse("2026-08-01T12:00:00.000Z"), + ); + + expect(refreshed).toEqual({ + accessToken: "new-access-token", + accessTokenExpiresAt: "2026-08-01T13:00:00.000Z", + refreshToken: "new-refresh-token", + }); + const body = fetchImpl.mock.calls[0]?.[1]?.body as URLSearchParams; + expect(body.get("grant_type")).toBe("refresh_token"); + expect(body.get("refresh_token")).toBe("old-refresh-token"); + }); +}); diff --git a/src/main/ipc/__tests__/openai-codex-session.test.ts b/src/main/ipc/__tests__/openai-codex-session.test.ts new file mode 100644 index 0000000..d5dbcdf --- /dev/null +++ b/src/main/ipc/__tests__/openai-codex-session.test.ts @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const dbMock = vi.hoisted(() => ({ + getLlmConfig: vi.fn(), + getLlmConfigAuthMetadata: vi.fn(), + updateLlmConfigRuntimeAuth: vi.fn(), +})); + +const authMock = vi.hoisted(() => ({ + refreshOpenAICodexCredentials: vi.fn(), +})); + +vi.mock("../../db", () => dbMock); +vi.mock("../openai-codex-auth", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + refreshOpenAICodexCredentials: authMock.refreshOpenAICodexCredentials, + }; +}); + +import { getFreshOpenAICodexCredentials } from "../openai-codex-session"; + +describe("ChatGPT subscription credential maintenance", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-08-01T12:00:00.000Z")); + dbMock.getLlmConfig.mockReturnValue({ + id: "codex-1", + provider: "openai-codex", + apiKey: "cached-access-token", + }); + }); + + it("reuses a valid access token and account id", async () => { + dbMock.getLlmConfigAuthMetadata.mockReturnValue( + JSON.stringify({ + version: 1, + accessTokenExpiresAt: "2026-08-01T13:00:00.000Z", + accountId: "account-123", + refreshToken: "refresh-token", + }), + ); + + await expect( + getFreshOpenAICodexCredentials({ configId: "codex-1" }), + ).resolves.toEqual({ + accessToken: "cached-access-token", + accountId: "account-123", + }); + expect(authMock.refreshOpenAICodexCredentials).not.toHaveBeenCalled(); + }); + + it("refreshes and persists an expiring access token", async () => { + dbMock.getLlmConfigAuthMetadata.mockReturnValue( + JSON.stringify({ + version: 1, + accessTokenExpiresAt: "2026-08-01T12:03:00.000Z", + accountId: "account-123", + refreshToken: "old-refresh-token", + }), + ); + authMock.refreshOpenAICodexCredentials.mockResolvedValue({ + accessToken: "fresh-access-token", + accessTokenExpiresAt: "2026-08-01T13:00:00.000Z", + refreshToken: "fresh-refresh-token", + }); + + await expect( + getFreshOpenAICodexCredentials({ configId: "codex-1" }), + ).resolves.toEqual({ + accessToken: "fresh-access-token", + accountId: "account-123", + }); + expect(dbMock.updateLlmConfigRuntimeAuth).toHaveBeenCalledWith("codex-1", { + apiKey: "fresh-access-token", + authMetadata: expect.stringContaining("fresh-refresh-token"), + }); + }); + + it("deduplicates concurrent refreshes for a rotating refresh token", async () => { + dbMock.getLlmConfigAuthMetadata.mockReturnValue( + JSON.stringify({ + version: 1, + accessTokenExpiresAt: "2026-08-01T12:03:00.000Z", + accountId: "account-123", + refreshToken: "single-use-refresh-token", + }), + ); + authMock.refreshOpenAICodexCredentials.mockResolvedValue({ + accessToken: "fresh-access-token", + accessTokenExpiresAt: "2026-08-01T13:00:00.000Z", + refreshToken: "rotated-refresh-token", + }); + + await expect( + Promise.all([ + getFreshOpenAICodexCredentials({ configId: "codex-1" }), + getFreshOpenAICodexCredentials({ configId: "codex-1" }), + ]), + ).resolves.toEqual([ + { + accessToken: "fresh-access-token", + accountId: "account-123", + }, + { + accessToken: "fresh-access-token", + accountId: "account-123", + }, + ]); + expect(authMock.refreshOpenAICodexCredentials).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/main/ipc/ai-handlers.ts b/src/main/ipc/ai-handlers.ts index 8caf2b4..2311df1 100644 --- a/src/main/ipc/ai-handlers.ts +++ b/src/main/ipc/ai-handlers.ts @@ -80,6 +80,7 @@ import { getLlmConfig, getSetting, getTaskSummary, + type LlmReasoningEffort, listContextMemoryChunks, listSkillTrust, type SkillTrustRow, @@ -283,6 +284,8 @@ const handleTaskExecutionInner = async ( chatPermissionMode?: ChatPermissionMode; contextInputTokens?: number; llmConfigId?: string; + modelId?: string; + reasoningEffort?: LlmReasoningEffort; history?: Array<{ id?: string; role: string; @@ -404,7 +407,7 @@ const handleTaskExecutionInner = async ( const resolvedLlmConfigId = requestedLlmConfig.id; if (!isAvailableLlmConfig(requestedLlmConfig)) { throw new Error( - `此 ${mediaModality} LLM 配置不可用,请先在设置中启用并测试连接成功。`, + `此 ${mediaModality} 模型配置不可用,请先在设置中启用并测试连接成功。`, ); } @@ -541,23 +544,31 @@ const handleTaskExecutionInner = async ( adapter, generationOptions, modelLimits, + modelCapabilities, + modelId, providerOptions, providerNativeCompaction, - } = getModelAndAdapterByConfigId(resolvedLlmConfigId); + } = getModelAndAdapterByConfigId(resolvedLlmConfigId, { + modelId: + llmSelection.fallbackFromConfigId === null + ? payload.modelId + : undefined, + reasoningEffort: payload.reasoningEffort, + }); const providerManagesContextCompression = providerNativeCompaction.enabled; const contextWindow = modelLimits.contextWindow; const maxOutputTokens = modelLimits.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS; const tokenBudget = llmConfig ? getTokenBudget({ - modelId: llmConfig.model, + modelId, contextWindow, maxOutputTokens, }) : undefined; const compressionTriggerBudget = llmConfig ? getCompressionTriggerBudget({ - modelId: llmConfig.model, + modelId, contextWindow, maxOutputTokens, }) @@ -652,7 +663,7 @@ const handleTaskExecutionInner = async ( timestamp: new Date().toISOString(), detail: { provider: llmConfig?.provider ?? null, - modelId: llmConfig?.model ?? null, + modelId, configId: resolvedLlmConfigId, fallbackFromConfigId: llmSelection.fallbackFromConfigId, providerNativeCompaction, @@ -823,7 +834,7 @@ const handleTaskExecutionInner = async ( ? undefined : llmConfig ? getCompressionTargetBudget({ - modelId: llmConfig.model, + modelId, contextWindow, maxOutputTokens, }) @@ -1043,7 +1054,7 @@ const handleTaskExecutionInner = async ( skill, skillArgs, isExplicitSkillCommand, - modelName: llmConfig?.model, + modelName: modelId, isGitWorkspace, workspaceMemory, enableMemoryTools, @@ -1067,7 +1078,7 @@ const handleTaskExecutionInner = async ( sender, taskId: id, allowedTools, - modelName: llmConfig?.model, + modelName: modelId, isGitWorkspace, // 主 agent 路径:开放 spawnSubagent 委派能力。子 agent 路径 //(fork-skill-runner)不传 enableSubagent → 无法递归委派。 @@ -1141,7 +1152,7 @@ const handleTaskExecutionInner = async ( tokenBudget, llmConfig ? getCompressionTargetBudget({ - modelId: llmConfig.model, + modelId, contextWindow, maxOutputTokens, }) @@ -1154,7 +1165,7 @@ const handleTaskExecutionInner = async ( supportsOpenAIResponsesInputTokenCount({ apiKey: llmConfig.apiKey || "", baseUrl: llmConfig.baseUrl, - modelCapabilities: llmConfig.modelCapabilities, + modelCapabilities, provider: llmConfig.provider, }); let providerCountUnavailable = false; @@ -1199,8 +1210,8 @@ const handleTaskExecutionInner = async ( { apiKey: llmConfig.apiKey || "", baseUrl: llmConfig.baseUrl, - model: llmConfig.model, - modelCapabilities: llmConfig.modelCapabilities, + model: modelId, + modelCapabilities, provider: llmConfig.provider, }, messages, @@ -1561,7 +1572,7 @@ const handleTaskExecutionInner = async ( status: "completed", result: fullText, completedAt: new Date().toISOString(), - modelId: llmConfig?.model ?? null, + modelId, provider: llmConfig?.provider ?? null, inputTokens: combinedUsage.inputTokens, outputTokens: combinedUsage.outputTokens, diff --git a/src/main/ipc/ai-models.ts b/src/main/ipc/ai-models.ts index c43d14a..af76578 100644 --- a/src/main/ipc/ai-models.ts +++ b/src/main/ipc/ai-models.ts @@ -17,8 +17,17 @@ import { import { getProviderNativeCompaction } from "../ai/adapters/native-compaction"; import { classifyError } from "../ai/error-classifier"; import { getCompressionTriggerBudget } from "../ai/token-budget"; -import { getLlmConfig, getLlmConfigs, type LlmConfig } from "../db"; +import { + getLlmConfig, + getLlmConfigAuthMetadata, + getLlmConfigs, + getLlmModelCatalog, + type LlmConfig, + type LlmReasoningEffort, +} from "../db"; import { getFreshGithubCopilotSessionToken } from "./github-copilot-session"; +import { parseOpenAICodexAuthMetadata } from "./openai-codex-auth"; +import { getFreshOpenAICodexCredentials } from "./openai-codex-session"; export interface LlmConfigSelection { config: LlmConfig; @@ -32,6 +41,20 @@ export interface LlmGenerationOptions { topP?: number; } +export interface LlmRuntimeOverrides { + modelId?: string; + reasoningEffort?: LlmReasoningEffort; +} + +const RUNTIME_REASONING_EFFORTS = new Set([ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", +]); + export function resolveLlmModelLimits( config: Pick< LlmConfig, @@ -90,28 +113,62 @@ export function selectAvailableChatLlmConfig( }; } - throw new Error("没有可用的聊天 LLM 配置,请先在设置中启用并测试连接成功。"); + throw new Error("没有可用的聊天模型配置,请先在设置中启用并测试连接成功。"); } /** * 根据配置 ID 同时获取 model 及其 provider 适配器。 * 适配器提供 provider 专属的选项与元数据提取能力。 */ -export const getModelAndAdapterByConfigId = (configId?: string) => { +export const getModelAndAdapterByConfigId = ( + configId?: string, + overrides: LlmRuntimeOverrides = {}, +) => { const { config } = selectAvailableChatLlmConfig(configId); + if ( + overrides.reasoningEffort !== undefined && + !RUNTIME_REASONING_EFFORTS.has(overrides.reasoningEffort) + ) { + throw new Error("无效的推理强度,请重新选择。"); + } const { provider, apiKey, baseUrl, apiPath, - model: modelId, temperature, topP, maxOutputTokens, - reasoningEffort, } = config; - const resolvedLimits = resolveLlmModelLimits(config); + const requestedModelId = overrides.modelId?.trim() || config.model; + const catalogModel = + requestedModelId === config.model + ? null + : getLlmModelCatalog(config.id).find( + (entry) => entry.modelId === requestedModelId, + ); + if (requestedModelId !== config.model && !catalogModel) { + throw new Error( + `模型 ${requestedModelId} 不属于当前模型配置,请重新选择可用模型。`, + ); + } + + const modelId = requestedModelId; + const modelCapabilities = + catalogModel?.capabilities ?? config.modelCapabilities; + const reasoningEffort = + modelCapabilities?.supportsReasoning === false + ? null + : (overrides.reasoningEffort ?? config.reasoningEffort); + const resolvedLimits = resolveLlmModelLimits({ + ...config, + model: modelId, + modelContextWindow: + catalogModel?.contextWindow ?? config.modelContextWindow, + modelMaxOutputTokens: + catalogModel?.maxOutputTokens ?? config.modelMaxOutputTokens, + }); const resolvedMaxOutputTokens = resolvedLimits.maxOutputTokens; const compressionTriggerBudget = getCompressionTriggerBudget({ modelId, @@ -131,17 +188,22 @@ export const getModelAndAdapterByConfigId = (configId?: string) => { throw new Error(`API Key 未配置,请在设置中填写 ${provider} 的 API Key`); } - // 未设置 baseUrl 时,MiniMax 使用按区域默认的 OpenAI 兼容端点。 - // 默认走中国大陆;用户可在 LLM 配置表单中覆盖 - // (例如改为 api.minimax.io)。 - const resolvedBaseUrl = - provider === "minimax" && !baseUrl - ? "https://api.minimaxi.com/v1" - : baseUrl; + // 官方渠道即使是从旧版配置升级而来、没有保存 baseUrl,也能直接使用。 + const defaultProviderBaseUrls: Partial> = { + deepseek: "https://api.deepseek.com", + kimi: "https://api.moonshot.cn/v1", + minimax: "https://api.minimaxi.com/v1", + }; + const resolvedBaseUrl = baseUrl || defaultProviderBaseUrls[provider] || null; const providerConfig: ProviderConfig = { provider, apiKey: apiKey || "", + accountId: + provider === "openai-codex" + ? parseOpenAICodexAuthMetadata(getLlmConfigAuthMetadata(config.id)) + ?.accountId + : undefined, resolveApiKey: provider === "github-copilot" ? async (options?: { forceRefresh?: boolean }) => { @@ -151,7 +213,15 @@ export const getModelAndAdapterByConfigId = (configId?: string) => { }); return token.apiToken; } - : undefined, + : provider === "openai-codex" + ? async (options?: { forceRefresh?: boolean }) => { + const credentials = await getFreshOpenAICodexCredentials({ + configId: config.id, + forceRefresh: options?.forceRefresh, + }); + return credentials.accessToken; + } + : undefined, baseUrl: resolvedBaseUrl, apiPath, model: modelId, @@ -159,7 +229,7 @@ export const getModelAndAdapterByConfigId = (configId?: string) => { compressionTriggerBudget, maxOutputTokens: resolvedMaxOutputTokens, modelContextWindow: resolvedLimits.contextWindow, - modelCapabilities: config.modelCapabilities, + modelCapabilities, }; const modelWithAdapter = createModelWithAdapter(providerConfig); const generationOptions: LlmGenerationOptions = { @@ -179,6 +249,7 @@ export const getModelAndAdapterByConfigId = (configId?: string) => { ) as ProviderOptions, providerNativeCompaction: getProviderNativeCompaction(providerConfig), modelId, + modelCapabilities, configId: config.id, }; }; diff --git a/src/main/ipc/llm-config-connection.ts b/src/main/ipc/llm-config-connection.ts index 240f31f..5d8a0fb 100644 --- a/src/main/ipc/llm-config-connection.ts +++ b/src/main/ipc/llm-config-connection.ts @@ -27,7 +27,7 @@ export interface LlmConnectionDiagnostics { type LlmConnectionConfig = Pick< LlmConfig, "apiKey" | "apiPath" | "baseUrl" | "modality" | "model" | "provider" ->; +> & { accountId?: string | null }; type FetchLike = ( input: string, @@ -37,6 +37,7 @@ type FetchLike = ( const DEFAULT_OPENAI_BASE_URLS: Partial> = { openai: "https://api.openai.com/v1", deepseek: "https://api.deepseek.com", + kimi: "https://api.moonshot.cn/v1", minimax: "https://api.minimaxi.com/v1", ollama: "http://localhost:11434/v1", }; @@ -122,6 +123,44 @@ function buildOpenAICompatibleRequest(config: LlmConnectionConfig): { }; } +function resolveOpenAICodexResponsesUrl(baseUrl: string | null): string { + const normalized = ( + baseUrl?.trim() || "https://chatgpt.com/backend-api/codex" + ).replace(/\/+$/, ""); + if (normalized.endsWith("/codex/responses")) return normalized; + if (normalized.endsWith("/codex")) return `${normalized}/responses`; + return `${normalized}/codex/responses`; +} + +function buildOpenAICodexRequest(config: LlmConnectionConfig): { + body: Record; + headers: Record; + url: string; +} { + if (!config.accountId) { + throw new Error("ChatGPT account id is required"); + } + return { + url: resolveOpenAICodexResponsesUrl(config.baseUrl), + headers: { + Accept: "text/event-stream", + Authorization: `Bearer ${config.apiKey || ""}`, + "Content-Type": "application/json", + "OpenAI-Beta": "responses=experimental", + "chatgpt-account-id": config.accountId, + originator: "filework", + }, + body: { + model: config.model, + store: false, + stream: true, + instructions: "Reply with OK.", + input: [{ role: "user", content: "ping" }], + max_output_tokens: 8, + }, + }; +} + function buildOpenAICompatibleImageRequest(config: LlmConnectionConfig): { body: Record; headers: Record; @@ -234,6 +273,9 @@ function buildConnectionRequest(config: LlmConnectionConfig) { if (config.provider === "anthropic") { return buildAnthropicRequest(config); } + if (config.provider === "openai-codex") { + return buildOpenAICodexRequest(config); + } return buildOpenAICompatibleRequest(config); } diff --git a/src/main/ipc/llm-config-handlers.ts b/src/main/ipc/llm-config-handlers.ts index 39e4332..76a6ab7 100644 --- a/src/main/ipc/llm-config-handlers.ts +++ b/src/main/ipc/llm-config-handlers.ts @@ -26,9 +26,38 @@ import { fetchOpenAICompatibleModels, type LlmModelOption, } from "./llm-config-models"; +import { + cancelOpenAICodexBrowserFlow, + completeOpenAICodexBrowserFlow, + getOpenAICodexModelOptions, + serializeOpenAICodexAuthMetadata, + startOpenAICodexBrowserFlow, +} from "./openai-codex-auth"; +import { + getFreshOpenAICodexCredentials, + type OpenAICodexSessionCredentials, +} from "./openai-codex-session"; type Provider = LlmConfig["provider"]; const GITHUB_COPILOT_DEFAULT_MODEL = "gpt-5.5"; +const OPENAI_CODEX_DEFAULT_BASE_URL = "https://chatgpt.com/backend-api/codex"; +const OPENAI_CODEX_DEFAULT_MODEL = "gpt-5.5"; +const OPENAI_COMPATIBLE_DISCOVERY_PROVIDERS = new Set([ + "custom", + "deepseek", + "kimi", +]); +const OFFICIAL_PROVIDER_BASE_URLS: Partial> = { + deepseek: "https://api.deepseek.com", + kimi: "https://api.moonshot.cn/v1", +}; + +function resolveModelDiscoveryBaseUrl( + provider: Provider, + baseUrl: string | null | undefined, +): string | null { + return baseUrl?.trim() || OFFICIAL_PROVIDER_BASE_URLS[provider] || null; +} interface CreatePayload { name?: string; @@ -70,6 +99,13 @@ interface CompleteGithubCopilotPayload { configId?: string; } +interface CompleteOpenAICodexPayload { + authSessionId: string; + name?: string; + model?: string; + configId?: string; +} + interface ListModelsDraftPayload { provider?: Provider; apiKey?: string | null; @@ -205,13 +241,18 @@ async function refreshModelCatalogForConfig( return; } - if (config.provider === "custom") { + if (OPENAI_COMPATIBLE_DISCOVERY_PROVIDERS.has(config.provider)) { const models = await fetchOpenAICompatibleModels({ apiKey: config.apiKey, apiPath: config.apiPath, - baseUrl: config.baseUrl, + baseUrl: resolveModelDiscoveryBaseUrl(config.provider, config.baseUrl), }); cacheLlmModelCatalog(config.id, models); + return; + } + + if (config.provider === "openai-codex") { + cacheLlmModelCatalog(config.id, getOpenAICodexModelOptions()); } } @@ -248,11 +289,13 @@ export function validateLlmConfigPayload(data: CreatePayload): string | null { "openai", "anthropic", "deepseek", + "kimi", "ollama", "custom", "minimax", "xiaomi", "github-copilot", + "openai-codex", ]; if (!validProviders.includes(data.provider)) { return `Invalid provider: ${data.provider}`; @@ -262,7 +305,7 @@ export function validateLlmConfigPayload(data: CreatePayload): string | null { // (chat / image / video 共用同一个 key)。 // 小米 MiMo 同样需要小米开放平台提供的 apiKey。 if ( - ["openai", "anthropic", "deepseek", "minimax", "xiaomi"].includes( + ["openai", "anthropic", "deepseek", "kimi", "minimax", "xiaomi"].includes( data.provider, ) ) { @@ -346,6 +389,36 @@ export function disconnectGithubCopilotConfig(id: string): LlmConfigResult { } } +export function disconnectOpenAICodexConfig(id: string): LlmConfigResult { + try { + const config = getLlmConfig(id); + if (!config) { + return { error: "Selected model configuration does not exist" }; + } + if (config.provider !== "openai-codex") { + return { + error: "Selected model configuration is not ChatGPT Plus/Pro", + }; + } + + updateLlmConfig(id, { + apiKey: "", + authMetadata: null, + enabled: false, + lastCheckedAt: null, + lastCheckStatus: null, + lastCheckMessage: "ChatGPT disconnected", + }); + return ( + getLlmConfig(id) ?? { + error: "Selected model configuration does not exist", + } + ); + } catch (err) { + return { error: err instanceof Error ? err.message : String(err) }; + } +} + export async function completeGithubCopilotConnection( payload: CompleteGithubCopilotPayload, ): Promise { @@ -415,6 +488,82 @@ export async function completeGithubCopilotConnection( } } +export async function completeOpenAICodexConnection( + payload: CompleteOpenAICodexPayload, +): Promise { + try { + const existing = payload.configId ? getLlmConfig(payload.configId) : null; + if (payload.configId && !existing) { + return { error: "Selected model configuration does not exist" }; + } + if (existing && existing.provider !== "openai-codex") { + return { + error: "Selected model configuration is not ChatGPT Plus/Pro", + }; + } + + const credentials = await completeOpenAICodexBrowserFlow( + payload.authSessionId, + ); + const authMetadata = serializeOpenAICodexAuthMetadata({ + version: 1, + accessTokenExpiresAt: credentials.accessTokenExpiresAt, + accountId: credentials.accountId, + refreshToken: credentials.refreshToken, + }); + const models = getOpenAICodexModelOptions(); + const selectedModel = payload.model?.trim(); + const model = + (selectedModel && + models.some((candidate) => candidate.value === selectedModel) + ? selectedModel + : null) ?? OPENAI_CODEX_DEFAULT_MODEL; + const now = new Date().toISOString(); + + let config: LlmConfig; + if (existing) { + updateLlmConfig(existing.id, { + name: payload.name?.trim() || existing.name, + apiKey: credentials.accessToken, + authMetadata, + baseUrl: OPENAI_CODEX_DEFAULT_BASE_URL, + apiPath: null, + model, + modality: "chat", + enabled: true, + lastCheckedAt: now, + lastCheckStatus: "success", + lastCheckMessage: "ChatGPT Plus/Pro connected", + }); + const updated = getLlmConfig(existing.id); + if (!updated) { + return { error: "Selected model configuration does not exist" }; + } + config = updated; + } else { + config = createLlmConfig({ + name: payload.name?.trim() || "ChatGPT Plus/Pro", + provider: "openai-codex", + apiKey: credentials.accessToken, + authMetadata, + baseUrl: OPENAI_CODEX_DEFAULT_BASE_URL, + apiPath: null, + model, + modality: "chat", + enabled: true, + isDefault: false, + lastCheckedAt: now, + lastCheckStatus: "success", + lastCheckMessage: "ChatGPT Plus/Pro connected", + }); + } + cacheLlmModelCatalog(config.id, models); + return getLlmConfig(config.id) ?? config; + } catch (err) { + return { error: err instanceof Error ? err.message : String(err) }; + } +} + export async function testLlmConfigById(id: string): Promise { const config = getLlmConfig(id); if (!config) { @@ -422,10 +571,15 @@ export async function testLlmConfigById(id: string): Promise { } let copilotToken: GithubCopilotSessionToken | undefined; + let codexCredentials: OpenAICodexSessionCredentials | undefined; if (config.provider === "github-copilot") { copilotToken = await getFreshGithubCopilotSessionToken({ configId: config.id, }); + } else if (config.provider === "openai-codex") { + codexCredentials = await getFreshOpenAICodexCredentials({ + configId: config.id, + }); } const configForTest = copilotToken @@ -434,7 +588,13 @@ export async function testLlmConfigById(id: string): Promise { apiKey: copilotToken.apiToken, baseUrl: copilotToken.baseUrl, } - : config; + : codexCredentials + ? { + ...config, + apiKey: codexCredentials.accessToken, + accountId: codexCredentials.accountId, + } + : config; const result = await testLlmConfigConnection(configForTest); updateLlmConfig(config.id, { @@ -488,7 +648,12 @@ export async function listLlmModelsForConfig( if (config.provider === "github-copilot") { return listGithubCopilotModelsForConfig(id); } - if (config.provider !== "custom") { + if (config.provider === "openai-codex") { + const models = getOpenAICodexModelOptions(); + cacheLlmModelCatalog(config.id, models); + return models; + } + if (!OPENAI_COMPATIBLE_DISCOVERY_PROVIDERS.has(config.provider)) { return { error: "Selected LLM configuration does not support model discovery", }; @@ -497,7 +662,7 @@ export async function listLlmModelsForConfig( const models = await fetchOpenAICompatibleModels({ apiKey: config.apiKey, apiPath: config.apiPath, - baseUrl: config.baseUrl, + baseUrl: resolveModelDiscoveryBaseUrl(config.provider, config.baseUrl), }); cacheLlmModelCatalog(config.id, models); return models; @@ -514,13 +679,19 @@ export async function listLlmModels( if (!draft) { return { error: "Model discovery requires an id or draft config" }; } - if (draft.provider !== "custom") { + if ( + !draft.provider || + !OPENAI_COMPATIBLE_DISCOVERY_PROVIDERS.has(draft.provider) + ) { return { error: "Draft model discovery only supports OpenAI-compatible configs", }; } - const baseUrl = draft.baseUrl?.trim(); + if (draft.provider !== "custom" && !draft.apiKey?.trim()) { + return { error: "apiKey is required for model discovery" }; + } + const baseUrl = resolveModelDiscoveryBaseUrl(draft.provider, draft.baseUrl); if (!baseUrl) { return { error: "baseUrl is required for model discovery" }; } @@ -643,6 +814,14 @@ export const registerLlmConfigHandlers = () => { } }); + ipcMain.handle("llm-config:openai-codex:start", async () => { + try { + return await startOpenAICodexBrowserFlow(); + } catch (err) { + return { error: err instanceof Error ? err.message : String(err) }; + } + }); + ipcMain.handle( "llm-config:copilot:models", async (_event, payload: { id: string }) => { @@ -671,12 +850,32 @@ export const registerLlmConfigHandlers = () => { completeGithubCopilotConnection(payload), ); + ipcMain.handle( + "llm-config:openai-codex:complete", + async (_event, payload: CompleteOpenAICodexPayload) => + completeOpenAICodexConnection(payload), + ); + + ipcMain.handle( + "llm-config:openai-codex:cancel", + async (_event, payload: { authSessionId: string }) => { + cancelOpenAICodexBrowserFlow(payload.authSessionId); + return { success: true }; + }, + ); + ipcMain.handle( "llm-config:copilot:disconnect", async (_event, payload: { id: string }) => disconnectGithubCopilotConfig(payload.id), ); + ipcMain.handle( + "llm-config:openai-codex:disconnect", + async (_event, payload: { id: string }) => + disconnectOpenAICodexConfig(payload.id), + ); + ipcMain.handle( "llm-config:delete", async (_event, payload: { id: string }) => { diff --git a/src/main/ipc/llm-config-models.ts b/src/main/ipc/llm-config-models.ts index 5fb3bd6..24a1efe 100644 --- a/src/main/ipc/llm-config-models.ts +++ b/src/main/ipc/llm-config-models.ts @@ -67,9 +67,15 @@ export function inferLlmModelCapabilities( const isGpt5 = lowerId.includes("gpt-5"); const isMini = lowerId.includes("mini"); const isDeepSeekV4 = lowerId.includes("deepseek-v4-"); + const isKimi = lowerId.startsWith("kimi-"); + const isKimiVision = + lowerId.startsWith("kimi-k3") || + lowerId.startsWith("kimi-k2.6") || + lowerId.startsWith("kimi-k2.5"); const isReasoningModel = (isGpt5 && !isMini) || isDeepSeekV4 || + isKimi || lowerId.includes("o1") || lowerId.includes("o3") || lowerId.includes("o4") || @@ -82,7 +88,8 @@ export function inferLlmModelCapabilities( supportsVision: lowerId.includes("vision") || lowerId.includes("gpt-4o") || - lowerId.includes("gemini") + lowerId.includes("gemini") || + isKimiVision ? true : null, }; diff --git a/src/main/ipc/openai-codex-auth.ts b/src/main/ipc/openai-codex-auth.ts new file mode 100644 index 0000000..04ec607 --- /dev/null +++ b/src/main/ipc/openai-codex-auth.ts @@ -0,0 +1,486 @@ +import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { createServer, type Server } from "node:http"; +import { getProviderFetch } from "../ai/provider-fetch"; + +const OPENAI_CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; +const OPENAI_AUTH_BASE_URL = "https://auth.openai.com"; +const OPENAI_AUTHORIZE_URL = `${OPENAI_AUTH_BASE_URL}/oauth/authorize`; +const OPENAI_TOKEN_URL = `${OPENAI_AUTH_BASE_URL}/oauth/token`; +const OPENAI_CODEX_REDIRECT_URI = "http://localhost:1455/auth/callback"; +const OPENAI_CODEX_SCOPE = "openid profile email offline_access"; +const OPENAI_CODEX_ACCOUNT_CLAIM = "https://api.openai.com/auth"; +const OPENAI_CODEX_LOGIN_TIMEOUT_MS = 10 * 60 * 1000; + +type FetchLike = typeof fetch; + +export interface OpenAICodexAuthMetadata { + version: 1; + accessTokenExpiresAt: string; + accountId: string; + refreshToken: string; +} + +export interface OpenAICodexCredentials { + accessToken: string; + accessTokenExpiresAt: string; + accountId: string; + refreshToken: string; +} + +export interface OpenAICodexBrowserFlowStart { + authSessionId: string; + authorizationUrl: string; + redirectUri: string; +} + +export interface OpenAICodexModelOption { + capabilities: { + preferredApi: "responses"; + supportsReasoning: true; + supportsTools: true; + supportsVision: boolean; + }; + contextWindow: number; + label: string; + maxOutputTokens: number; + value: string; +} + +interface PendingBrowserFlow { + cancel: () => void; + credentials: Promise; +} + +interface OpenAICodexTokenResponse { + accessToken: string; + accessTokenExpiresAt: string; + idToken?: string; + refreshToken: string; +} + +const pendingBrowserFlows = new Map(); + +const OPENAI_CODEX_MODELS: OpenAICodexModelOption[] = [ + { + value: "gpt-5.6-sol", + label: "GPT-5.6 Sol", + capabilities: { + preferredApi: "responses", + supportsReasoning: true, + supportsTools: true, + supportsVision: true, + }, + contextWindow: 272_000, + maxOutputTokens: 128_000, + }, + { + value: "gpt-5.6-terra", + label: "GPT-5.6 Terra", + capabilities: { + preferredApi: "responses", + supportsReasoning: true, + supportsTools: true, + supportsVision: true, + }, + contextWindow: 272_000, + maxOutputTokens: 128_000, + }, + { + value: "gpt-5.6-luna", + label: "GPT-5.6 Luna", + capabilities: { + preferredApi: "responses", + supportsReasoning: true, + supportsTools: true, + supportsVision: true, + }, + contextWindow: 272_000, + maxOutputTokens: 128_000, + }, + { + value: "gpt-5.5", + label: "GPT-5.5", + capabilities: { + preferredApi: "responses", + supportsReasoning: true, + supportsTools: true, + supportsVision: true, + }, + contextWindow: 272_000, + maxOutputTokens: 128_000, + }, + { + value: "gpt-5.4", + label: "GPT-5.4", + capabilities: { + preferredApi: "responses", + supportsReasoning: true, + supportsTools: true, + supportsVision: true, + }, + contextWindow: 272_000, + maxOutputTokens: 128_000, + }, + { + value: "gpt-5.4-mini", + label: "GPT-5.4 mini", + capabilities: { + preferredApi: "responses", + supportsReasoning: true, + supportsTools: true, + supportsVision: true, + }, + contextWindow: 272_000, + maxOutputTokens: 128_000, + }, + { + value: "gpt-5.3-codex-spark", + label: "GPT-5.3 Codex Spark", + capabilities: { + preferredApi: "responses", + supportsReasoning: true, + supportsTools: true, + supportsVision: false, + }, + contextWindow: 128_000, + maxOutputTokens: 128_000, + }, +]; + +export const getOpenAICodexModelOptions = (): OpenAICodexModelOption[] => + OPENAI_CODEX_MODELS.map((model) => ({ + ...model, + capabilities: { ...model.capabilities }, + })); + +function base64Url(value: Buffer): string { + return value + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); +} + +function createPkce(): { challenge: string; verifier: string } { + const verifier = base64Url(randomBytes(32)); + const challenge = base64Url(createHash("sha256").update(verifier).digest()); + return { challenge, verifier }; +} + +export function createOpenAICodexAuthorizationUrl(input: { + challenge: string; + state: string; +}): string { + const url = new URL(OPENAI_AUTHORIZE_URL); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", OPENAI_CODEX_CLIENT_ID); + url.searchParams.set("redirect_uri", OPENAI_CODEX_REDIRECT_URI); + url.searchParams.set("scope", OPENAI_CODEX_SCOPE); + url.searchParams.set("code_challenge", input.challenge); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("state", input.state); + url.searchParams.set("id_token_add_organizations", "true"); + url.searchParams.set("codex_cli_simplified_flow", "true"); + url.searchParams.set("originator", "filework"); + return url.toString(); +} + +function decodeJwtPayload(token: string): Record | null { + try { + const payload = token.split(".")[1]; + if (!payload) return null; + return JSON.parse( + Buffer.from(payload, "base64url").toString("utf8"), + ) as Record; + } catch { + return null; + } +} + +export function extractOpenAICodexAccountId(token: string): string { + const payload = decodeJwtPayload(token); + const auth = payload?.[OPENAI_CODEX_ACCOUNT_CLAIM]; + const accountId = + typeof auth === "object" && + auth !== null && + "chatgpt_account_id" in auth && + typeof auth.chatgpt_account_id === "string" + ? auth.chatgpt_account_id + : null; + if (!accountId) { + throw new Error("ChatGPT 登录凭据缺少账户 ID,请重新登录"); + } + return accountId; +} + +async function readTokenResponse( + response: Response, + operation: "exchange" | "refresh", + nowMs = Date.now(), +): Promise { + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new Error( + `ChatGPT token ${operation} failed (${response.status}): ${ + body || response.statusText + }`, + ); + } + const json = (await response.json()) as { + access_token?: string; + expires_in?: number; + id_token?: string; + refresh_token?: string; + }; + if ( + !json.access_token || + !json.refresh_token || + typeof json.expires_in !== "number" + ) { + throw new Error(`ChatGPT token ${operation} response is incomplete`); + } + return { + accessToken: json.access_token, + accessTokenExpiresAt: new Date( + nowMs + json.expires_in * 1000, + ).toISOString(), + ...(json.id_token ? { idToken: json.id_token } : {}), + refreshToken: json.refresh_token, + }; +} + +async function exchangeOpenAICodexAuthorizationCode( + input: { code: string; verifier: string }, + fetchImpl: FetchLike, +): Promise { + const response = await fetchImpl(OPENAI_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: OPENAI_CODEX_CLIENT_ID, + code: input.code, + code_verifier: input.verifier, + redirect_uri: OPENAI_CODEX_REDIRECT_URI, + }), + }); + const token = await readTokenResponse(response, "exchange"); + return { + ...token, + accountId: extractOpenAICodexAccountId(token.idToken ?? token.accessToken), + }; +} + +export async function refreshOpenAICodexCredentials( + refreshToken: string, + fetchImpl: FetchLike = getProviderFetch() as FetchLike, + nowMs = Date.now(), +): Promise> { + const response = await fetchImpl(OPENAI_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: OPENAI_CODEX_CLIENT_ID, + }), + }); + const { idToken: _idToken, ...credentials } = await readTokenResponse( + response, + "refresh", + nowMs, + ); + return credentials; +} + +export function serializeOpenAICodexAuthMetadata( + metadata: OpenAICodexAuthMetadata, +): string { + return JSON.stringify(metadata); +} + +export function parseOpenAICodexAuthMetadata( + value: string | null | undefined, +): OpenAICodexAuthMetadata | null { + if (!value?.trim()) return null; + try { + const parsed = JSON.parse(value) as Partial; + if ( + parsed.version !== 1 || + typeof parsed.accessTokenExpiresAt !== "string" || + typeof parsed.accountId !== "string" || + !parsed.accountId || + typeof parsed.refreshToken !== "string" || + !parsed.refreshToken + ) { + return null; + } + return { + version: 1, + accessTokenExpiresAt: parsed.accessTokenExpiresAt, + accountId: parsed.accountId, + refreshToken: parsed.refreshToken, + }; + } catch { + return null; + } +} + +export function shouldRefreshOpenAICodexCredentials( + metadata: Pick, + nowMs = Date.now(), +): boolean { + const expiresAtMs = Date.parse(metadata.accessTokenExpiresAt); + return !Number.isFinite(expiresAtMs) || expiresAtMs - nowMs <= 5 * 60 * 1000; +} + +function callbackPage(title: string, message: string): string { + return `${title}

${title}

${message}

`; +} + +function closeServer(server: Server): void { + try { + server.close(); + } catch { + // The server may already be closed after a completed callback. + } +} + +export async function startOpenAICodexBrowserFlow( + fetchImpl: FetchLike = getProviderFetch() as FetchLike, +): Promise { + const { challenge, verifier } = createPkce(); + const state = base64Url(randomBytes(24)); + const authSessionId = randomUUID(); + const authorizationUrl = createOpenAICodexAuthorizationUrl({ + challenge, + state, + }); + + let server: Server; + let timeout: NodeJS.Timeout; + let rejectFlow: (error: Error) => void = () => {}; + const credentials = new Promise((resolve, reject) => { + rejectFlow = reject; + server = createServer(async (request, response) => { + let terminalCallback = false; + try { + const url = new URL(request.url || "/", "http://localhost"); + if (url.pathname !== "/auth/callback") { + response.writeHead(404, { + "Content-Type": "text/html; charset=utf-8", + }); + response.end( + callbackPage("Page not found", "Return to Workspace Agent."), + ); + return; + } + terminalCallback = true; + if (url.searchParams.get("state") !== state) { + response.writeHead(400, { + "Content-Type": "text/html; charset=utf-8", + }); + response.end( + callbackPage("Login failed", "OAuth state did not match."), + ); + reject(new Error("ChatGPT 登录校验失败,请重新发起登录")); + return; + } + const code = url.searchParams.get("code"); + if (!code) { + response.writeHead(400, { + "Content-Type": "text/html; charset=utf-8", + }); + response.end( + callbackPage("Login failed", "Authorization code is missing."), + ); + reject(new Error("ChatGPT 登录回调缺少授权码")); + return; + } + const result = await exchangeOpenAICodexAuthorizationCode( + { code, verifier }, + fetchImpl, + ); + response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + response.end( + callbackPage( + "Workspace Agent connected", + "You can close this browser tab and return to model settings.", + ), + ); + resolve(result); + } catch (error) { + terminalCallback = true; + response.writeHead(500, { "Content-Type": "text/html; charset=utf-8" }); + response.end( + callbackPage( + "Login failed", + "Return to Workspace Agent and try again.", + ), + ); + reject(error instanceof Error ? error : new Error(String(error))); + } finally { + if (terminalCallback) { + clearTimeout(timeout); + closeServer(server); + } + } + }); + }); + // Completion is user-driven. Avoid an unhandled rejection if a timed-out or + // cancelled dialog no longer has a renderer waiting on this promise. + void credentials.catch(() => {}); + + await new Promise((resolve, reject) => { + server.listen(1455, "127.0.0.1", resolve); + server.once("error", reject); + }).catch((error) => { + closeServer(server); + throw new Error( + `无法启动 ChatGPT 登录回调(localhost:1455):${ + error instanceof Error ? error.message : String(error) + }`, + ); + }); + + timeout = setTimeout(() => { + closeServer(server); + rejectFlow(new Error("ChatGPT 登录已超时,请重新登录")); + }, OPENAI_CODEX_LOGIN_TIMEOUT_MS); + + pendingBrowserFlows.set(authSessionId, { + cancel: () => { + clearTimeout(timeout); + closeServer(server); + rejectFlow(new Error("ChatGPT 登录已取消")); + }, + credentials, + }); + + return { + authSessionId, + authorizationUrl, + redirectUri: OPENAI_CODEX_REDIRECT_URI, + }; +} + +export async function completeOpenAICodexBrowserFlow( + authSessionId: string, +): Promise { + const flow = pendingBrowserFlows.get(authSessionId); + if (!flow) { + throw new Error("ChatGPT 登录会话不存在或已过期"); + } + try { + return await flow.credentials; + } finally { + pendingBrowserFlows.delete(authSessionId); + } +} + +export function cancelOpenAICodexBrowserFlow(authSessionId: string): void { + const flow = pendingBrowserFlows.get(authSessionId); + if (!flow) return; + pendingBrowserFlows.delete(authSessionId); + flow.cancel(); +} diff --git a/src/main/ipc/openai-codex-session.ts b/src/main/ipc/openai-codex-session.ts new file mode 100644 index 0000000..bbb20cc --- /dev/null +++ b/src/main/ipc/openai-codex-session.ts @@ -0,0 +1,87 @@ +import { + getLlmConfig, + getLlmConfigAuthMetadata, + updateLlmConfigRuntimeAuth, +} from "../db"; +import { + parseOpenAICodexAuthMetadata, + refreshOpenAICodexCredentials, + serializeOpenAICodexAuthMetadata, + shouldRefreshOpenAICodexCredentials, +} from "./openai-codex-auth"; + +const RECONNECT_MESSAGE = + "ChatGPT authorization expired. Please disconnect and sign in again."; +const pendingRefreshes = new Map< + string, + Promise +>(); + +export interface OpenAICodexSessionCredentials { + accessToken: string; + accountId: string; +} + +export async function getFreshOpenAICodexCredentials(input: { + configId: string; + forceRefresh?: boolean; +}): Promise { + const inFlight = pendingRefreshes.get(input.configId); + if (inFlight) return inFlight; + + const resolveCredentials = + async (): Promise => { + const config = getLlmConfig(input.configId); + if (!config) { + throw new Error("Selected model configuration does not exist"); + } + if (config.provider !== "openai-codex") { + throw new Error("Selected model configuration is not ChatGPT Plus/Pro"); + } + + const metadata = parseOpenAICodexAuthMetadata( + getLlmConfigAuthMetadata(input.configId), + ); + if (!metadata || !config.apiKey) { + throw new Error(RECONNECT_MESSAGE); + } + + if ( + !input.forceRefresh && + !shouldRefreshOpenAICodexCredentials(metadata) + ) { + return { + accessToken: config.apiKey, + accountId: metadata.accountId, + }; + } + + const refreshed = await refreshOpenAICodexCredentials( + metadata.refreshToken, + ); + const authMetadata = serializeOpenAICodexAuthMetadata({ + version: 1, + accessTokenExpiresAt: refreshed.accessTokenExpiresAt, + accountId: metadata.accountId, + refreshToken: refreshed.refreshToken, + }); + updateLlmConfigRuntimeAuth(input.configId, { + apiKey: refreshed.accessToken, + authMetadata, + }); + return { + accessToken: refreshed.accessToken, + accountId: metadata.accountId, + }; + }; + + const refresh = resolveCredentials(); + pendingRefreshes.set(input.configId, refresh); + try { + return await refresh; + } finally { + if (pendingRefreshes.get(input.configId) === refresh) { + pendingRefreshes.delete(input.configId); + } + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index cbd3eec..9a3d6ea 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -115,7 +115,7 @@ type LlmModelDiscoveryRequest = | { id: string } | { draft: { - provider: "custom"; + provider: "custom" | "deepseek" | "kimi"; apiKey?: string | null; baseUrl: string; apiPath?: string | null; @@ -231,6 +231,10 @@ const api = { /** 最近一次 provider input tokens,用于对齐 Codex 风格 context 压缩触发。 */ contextInputTokens?: number; llmConfigId?: string; + /** 聊天输入栏选择的会话级模型覆盖。 */ + modelId?: string; + /** 聊天输入栏选择的会话级推理强度;缺省时使用模型配置默认值。 */ + reasoningEffort?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh"; history?: Array<{ id?: string; role: "user" | "assistant"; @@ -1211,11 +1215,13 @@ const api = { | "openai" | "anthropic" | "deepseek" + | "kimi" | "ollama" | "custom" | "minimax" | "xiaomi" - | "github-copilot"; + | "github-copilot" + | "openai-codex"; apiKey?: string; baseUrl?: string; apiPath?: string; @@ -1243,11 +1249,13 @@ const api = { | "openai" | "anthropic" | "deepseek" + | "kimi" | "ollama" | "custom" | "minimax" | "xiaomi" - | "github-copilot"; + | "github-copilot" + | "openai-codex"; apiKey?: string; baseUrl?: string; apiPath?: string | null; @@ -1271,6 +1279,16 @@ const api = { test: (id: string) => ipcRenderer.invoke("llm-config:test", { id }), startGithubCopilotAuth: () => ipcRenderer.invoke("llm-config:copilot:start"), + startOpenAICodexAuth: () => + ipcRenderer.invoke("llm-config:openai-codex:start"), + completeOpenAICodexAuth: (data: { + authSessionId: string; + name?: string; + model?: string; + configId?: string; + }) => ipcRenderer.invoke("llm-config:openai-codex:complete", data), + cancelOpenAICodexAuth: (authSessionId: string) => + ipcRenderer.invoke("llm-config:openai-codex:cancel", { authSessionId }), completeGithubCopilotAuth: (data: { deviceCode: string; name?: string; @@ -1317,6 +1335,8 @@ const api = { ), disconnectGithubCopilot: (id: string) => ipcRenderer.invoke("llm-config:copilot:disconnect", { id }), + disconnectOpenAICodex: (id: string) => + ipcRenderer.invoke("llm-config:openai-codex:disconnect", { id }), delete: (id: string) => ipcRenderer.invoke("llm-config:delete", { id }), }, diff --git a/src/renderer/components/chat/ChatPanel.tsx b/src/renderer/components/chat/ChatPanel.tsx index 43d9d4d..920e247 100644 --- a/src/renderer/components/chat/ChatPanel.tsx +++ b/src/renderer/components/chat/ChatPanel.tsx @@ -170,14 +170,17 @@ type LlmConfigContextInfo = { const getInitialContextUsageFromConfig = ( config: LlmConfigContextInfo | null | undefined, options: { + selectedModelId?: string | null; usageModelId?: string | null; } = {}, ): ContextUsage | null => { if (config?.modality === "image" || config?.modality === "video") return null; const contextWindow = + getContextWindowForModelId(options.selectedModelId) ?? readPositiveNumber(config?.modelContextWindow) ?? getContextWindowForModelId(config?.model) ?? - getContextWindowForModelId(options.usageModelId); + getContextWindowForModelId(options.usageModelId) ?? + null; if (contextWindow == null) return null; return { contextWindow, @@ -913,6 +916,7 @@ export const ChatPanel = ({ if (cancelled || contextBudgetTraceReceivedRef.current) return; const initialUsage = getInitialContextUsageFromConfig(config, { + selectedModelId: chat.selectedLlmModelId, usageModelId: lastUsageModelId, }); if (!initialUsage) return; @@ -942,7 +946,12 @@ export const ChatPanel = ({ return () => { cancelled = true; }; - }, [chat.activeSessionId, chat.lastUsage?.modelId, chat.selectedLlmConfigId]); + }, [ + chat.activeSessionId, + chat.lastUsage?.modelId, + chat.selectedLlmConfigId, + chat.selectedLlmModelId, + ]); useEffect(() => { const persistedTokens = sumUsageInputTokens(chat.messages); @@ -2082,7 +2091,10 @@ export const ChatPanel = ({ />
diff --git a/src/renderer/components/chat/ModelSelector.tsx b/src/renderer/components/chat/ModelSelector.tsx index e142da5..b92bcc3 100644 --- a/src/renderer/components/chat/ModelSelector.tsx +++ b/src/renderer/components/chat/ModelSelector.tsx @@ -1,6 +1,7 @@ -import { Sparkles } from "lucide-react"; +import { BrainCircuit, Sparkles } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { getDisplayLlmConfigModality } from "../../../shared/llm-modalities"; +import { useI18nContext } from "../../i18n/i18n-react"; import { Select, SelectContent, @@ -12,6 +13,14 @@ import { } from "../ui/select"; type Modality = "chat" | "image" | "video"; +export type ChatReasoningEffort = + | "auto" + | "none" + | "minimal" + | "low" + | "medium" + | "high" + | "xhigh"; interface LlmConfig { id: string; @@ -31,6 +40,29 @@ interface LlmConfig { } | null; } +export interface LlmModelDiscoveryOption { + value: string; + label: string; + capabilities?: { + preferredApi?: "chat_completions" | "responses" | null; + supportsReasoning?: boolean | null; + supportsTools?: boolean | null; + supportsVision?: boolean | null; + }; +} + +export interface AvailableLlmModel { + value: string; + configId: string; + configName: string; + modelId: string; + label: string; + provider: string; + modality: Modality; + isDefault: boolean; + supportsReasoning: boolean | null; +} + export interface SelectableLlmConfig { enabled?: boolean; lastCheckStatus?: "success" | "error" | null; @@ -39,10 +71,12 @@ export interface SelectableLlmConfig { interface ModelSelectorProps { selectedConfigId: string | null; - onSelect: (configId: string) => void; + selectedModelId: string | null; + reasoningEffort: ChatReasoningEffort; + onSelect: (configId: string, modelId: string) => void; + onReasoningEffortChange: (effort: ChatReasoningEffort) => void; } -const MODALITY_ORDER: Modality[] = ["chat", "image", "video"]; const MODALITY_LABELS: Record = { chat: "Chat", image: "Image", @@ -86,16 +120,155 @@ export function resolveDisplayedLlmConfig( ); } +export function getModelSelectionValue( + configId: string, + modelId: string, +): string { + return JSON.stringify([configId, modelId]); +} + +export function parseModelSelectionValue( + value: string, +): { configId: string; modelId: string } | null { + try { + const parsed = JSON.parse(value); + if ( + !Array.isArray(parsed) || + parsed.length !== 2 || + typeof parsed[0] !== "string" || + typeof parsed[1] !== "string" + ) { + return null; + } + return { configId: parsed[0], modelId: parsed[1] }; + } catch { + return null; + } +} + +export function buildAvailableLlmModels( + configs: LlmConfig[], + catalogsByConfigId: Record, +): AvailableLlmModel[] { + return getSelectableLlmConfigs(configs).flatMap((config) => { + const modality = getDisplayLlmConfigModality(config); + const discovered = + modality === "chat" ? catalogsByConfigId[config.id] : undefined; + const models = + discovered && discovered.length > 0 + ? [...discovered] + : [{ value: config.model, label: config.model }]; + + if (!models.some((model) => model.value === config.model)) { + models.unshift({ value: config.model, label: config.model }); + } + + return models.map((model) => ({ + value: getModelSelectionValue(config.id, model.value), + configId: config.id, + configName: config.name, + modelId: model.value, + label: model.label, + provider: config.provider, + modality, + isDefault: config.isDefault === true, + supportsReasoning: + model.capabilities?.supportsReasoning ?? + config.modelCapabilities?.supportsReasoning ?? + null, + })); + }); +} + +export function resolveDisplayedLlmModel( + models: AvailableLlmModel[], + selectedConfigId: string | null, + selectedModelId: string | null, +): AvailableLlmModel | null { + if (models.length === 0) return null; + + const exact = + selectedConfigId && selectedModelId + ? models.find( + (model) => + model.configId === selectedConfigId && + model.modelId === selectedModelId, + ) + : null; + if (exact) return exact; + + const selectedConfigModel = selectedConfigId + ? models.find((model) => model.configId === selectedConfigId) + : null; + if (selectedConfigModel) return selectedConfigModel; + + return ( + models.find((model) => model.isDefault && model.modality === "chat") ?? + models.find((model) => model.modality === "chat") ?? + models[0] + ); +} + +const DISCOVERABLE_PROVIDERS = new Set([ + "custom", + "deepseek", + "github-copilot", + "kimi", + "openai-codex", +]); + +const REASONING_OPTIONS: Exclude[] = [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", +]; + export const ModelSelector = ({ selectedConfigId, + selectedModelId, + reasoningEffort, onSelect, + onReasoningEffortChange, }: ModelSelectorProps) => { + const { LL } = useI18nContext(); const [configs, setConfigs] = useState([]); + const [catalogs, setCatalogs] = useState< + Record + >({}); const [open, setOpen] = useState(false); const loadConfigs = useCallback(() => { - window.filework.llmConfig.list().then((result) => { - if (!("error" in result)) setConfigs(result as LlmConfig[]); + window.filework.llmConfig.list().then(async (result) => { + if (!Array.isArray(result)) return; + const nextConfigs = result as LlmConfig[]; + setConfigs(nextConfigs); + + const discoverableConfigs = getSelectableLlmConfigs(nextConfigs).filter( + (config) => + getDisplayLlmConfigModality(config) === "chat" && + DISCOVERABLE_PROVIDERS.has(config.provider), + ); + const entries = await Promise.all( + discoverableConfigs.map(async (config) => { + try { + const models = await window.filework.llmConfig.listModels({ + id: config.id, + }); + return [ + config.id, + Array.isArray(models) + ? (models as LlmModelDiscoveryOption[]) + : [], + ] as const; + } catch { + return [config.id, []] as const; + } + }), + ); + setCatalogs(Object.fromEntries(entries)); }); }, []); @@ -103,94 +276,164 @@ export const ModelSelector = ({ loadConfigs(); }, [loadConfigs]); + const availableModels = useMemo( + () => buildAvailableLlmModels(configs, catalogs), + [catalogs, configs], + ); const grouped = useMemo(() => { - const selectableConfigs = getSelectableLlmConfigs(configs); - const groups: Record = { - chat: [], - image: [], - video: [], - }; - for (const c of selectableConfigs) { - groups[getDisplayLlmConfigModality(c)].push(c); + const groups = new Map< + string, + { key: string; label: string; items: AvailableLlmModel[] } + >(); + for (const model of availableModels) { + const key = + model.modality === "chat" + ? `chat:${model.provider}:${model.configId}` + : model.modality; + const existing = groups.get(key); + if (existing) { + existing.items.push(model); + } else { + groups.set(key, { + key, + label: + model.modality === "chat" + ? model.provider.toUpperCase() + : MODALITY_LABELS[model.modality], + items: [model], + }); + } } - return MODALITY_ORDER.filter((m) => groups[m].length > 0).map((m) => ({ - modality: m, - items: groups[m], - })); - }, [configs]); + return Array.from(groups.values()); + }, [availableModels]); + const selected = resolveDisplayedLlmModel( + availableModels, + selectedConfigId, + selectedModelId, + ); - const selectableConfigs = getSelectableLlmConfigs(configs); + useEffect(() => { + if ( + !selected || + (selected.configId === selectedConfigId && + selected.modelId === selectedModelId) + ) { + return; + } + onSelect(selected.configId, selected.modelId); + }, [onSelect, selected, selectedConfigId, selectedModelId]); - if (selectableConfigs.length <= 1) return null; - - const selected = resolveDisplayedLlmConfig(configs, selectedConfigId); - const selectedModality = selected - ? getDisplayLlmConfigModality(selected) - : "chat"; - const hasMultipleModalities = grouped.length > 1; - - const getModelCapabilityHint = (config: LlmConfig): string | null => { - const capabilities = config.modelCapabilities; - if (!capabilities) return null; - const parts: string[] = []; - if (capabilities.preferredApi === "responses") parts.push("Responses"); - if (capabilities.supportsReasoning) parts.push("Reasoning"); - if (capabilities.supportsTools) parts.push("Tools"); - if (capabilities.supportsVision) parts.push("Vision"); - return parts.length > 0 ? parts.join(" · ") : null; + const reasoningLabels: Record = { + auto: LL.chat_reasoningAuto(), + none: LL.llmConfig_reasoningEffortNone(), + minimal: LL.llmConfig_reasoningEffortMinimal(), + low: LL.llmConfig_reasoningEffortLow(), + medium: LL.llmConfig_reasoningEffortMedium(), + high: LL.llmConfig_reasoningEffortHigh(), + xhigh: LL.llmConfig_reasoningEffortXHigh(), }; + if (availableModels.length === 0) return null; + return ( - { + const next = parseModelSelectionValue(value); + if (next) onSelect(next.configId, next.modelId); + }} + open={open} + onOpenChange={(nextOpen) => { + if (nextOpen) loadConfigs(); + setOpen(nextOpen); + }} + > + + + + + + {selected?.label} + + {selected?.modality !== "chat" && ( + + {selected + ? MODALITY_LABELS[selected.modality] + : MODALITY_LABELS.chat} + + )} - )} - - - - - {grouped.map((group) => ( - - {hasMultipleModalities && ( - {MODALITY_LABELS[group.modality]} - )} - {group.items.map((c) => ( - -
- -
-
{c.name}
-
- {c.provider} · {c.model} - {getModelCapabilityHint(c) - ? ` · ${getModelCapabilityHint(c)}` - : ""} + + + + {grouped.map((group) => ( + + + {group.label} + + {group.items.map((model) => ( + +
+ + {model.label} + + {model.modality !== "chat" && ( + + {MODALITY_LABELS[model.modality]} + + )}
-
-
- + + ))} + ))} - - ))} - - + + + )} + {selected?.modality === "chat" && + selected.supportsReasoning !== false && ( + + )} + ); }; diff --git a/src/renderer/components/chat/__tests__/ModelSelector.test.ts b/src/renderer/components/chat/__tests__/ModelSelector.test.ts index 760ef0c..00ef190 100644 --- a/src/renderer/components/chat/__tests__/ModelSelector.test.ts +++ b/src/renderer/components/chat/__tests__/ModelSelector.test.ts @@ -1,9 +1,12 @@ import { describe, expect, it } from "vitest"; import { + buildAvailableLlmModels, getDisplayLlmConfigModality, + getModelSelectionValue, getSelectableLlmConfigs, resolveDisplayedLlmConfig, + resolveDisplayedLlmModel, } from "../ModelSelector"; describe("getSelectableLlmConfigs", () => { @@ -159,3 +162,94 @@ describe("getDisplayLlmConfigModality", () => { ).toBe("chat"); }); }); + +describe("chat model choices", () => { + const codexConfig = { + id: "codex-account", + name: "ChatGPT Plus", + provider: "openai-codex", + model: "gpt-5.6-sol", + modality: "chat" as const, + enabled: true, + isDefault: true, + lastCheckStatus: "success" as const, + modelCapabilities: { + preferredApi: "responses" as const, + supportsReasoning: true, + supportsTools: true, + supportsVision: true, + }, + }; + + it("expands one connected provider config into its available models", () => { + const choices = buildAvailableLlmModels([codexConfig], { + "codex-account": [ + { + value: "gpt-5.6-sol", + label: "GPT-5.6 Sol", + capabilities: { + preferredApi: "responses", + supportsReasoning: true, + supportsTools: true, + supportsVision: true, + }, + }, + { + value: "gpt-5.6-terra", + label: "GPT-5.6 Terra", + capabilities: { + preferredApi: "responses", + supportsReasoning: true, + supportsTools: true, + supportsVision: true, + }, + }, + ], + }); + + expect(choices).toEqual([ + expect.objectContaining({ + value: getModelSelectionValue("codex-account", "gpt-5.6-sol"), + configId: "codex-account", + modelId: "gpt-5.6-sol", + label: "GPT-5.6 Sol", + provider: "openai-codex", + supportsReasoning: true, + }), + expect.objectContaining({ + value: getModelSelectionValue("codex-account", "gpt-5.6-terra"), + configId: "codex-account", + modelId: "gpt-5.6-terra", + label: "GPT-5.6 Terra", + provider: "openai-codex", + supportsReasoning: true, + }), + ]); + }); + + it("keeps the selected provider model instead of collapsing to the config default", () => { + const choices = buildAvailableLlmModels([codexConfig], { + "codex-account": [ + { value: "gpt-5.6-sol", label: "GPT-5.6 Sol" }, + { value: "gpt-5.6-terra", label: "GPT-5.6 Terra" }, + ], + }); + + expect( + resolveDisplayedLlmModel(choices, "codex-account", "gpt-5.6-terra") + ?.modelId, + ).toBe("gpt-5.6-terra"); + }); + + it("falls back to the configured model when discovery has no result", () => { + const choices = buildAvailableLlmModels([codexConfig], {}); + + expect(choices).toEqual([ + expect.objectContaining({ + configId: "codex-account", + modelId: "gpt-5.6-sol", + label: "gpt-5.6-sol", + }), + ]); + }); +}); diff --git a/src/renderer/components/chat/__tests__/useChatSession.test.tsx b/src/renderer/components/chat/__tests__/useChatSession.test.tsx index 0146e6b..cdb8312 100644 --- a/src/renderer/components/chat/__tests__/useChatSession.test.tsx +++ b/src/renderer/components/chat/__tests__/useChatSession.test.tsx @@ -398,6 +398,59 @@ describe("useChatSession", () => { expect(filework.media.createVideoJob).not.toHaveBeenCalled(); }); + it("sends the chat-selected model and reasoning effort as request overrides", async () => { + filework.llmConfig.list.mockResolvedValue([ + { + enabled: true, + id: "codex-account", + lastCheckStatus: "success", + modality: "chat", + modelAvailable: true, + }, + ]); + filework.createChatSession.mockResolvedValue({ + id: "session-model-override", + workspacePath: "/workspace", + title: "新对话", + createdAt: "2026-07-31T10:00:00.000Z", + updatedAt: "2026-07-31T10:00:00.000Z", + }); + + const Harness = () => { + latest = useChatSession("/workspace"); + return null; + }; + + await act(async () => { + root?.render(); + }); + await act(async () => { + latest?.setSelectedLlmModel("codex-account", "gpt-5.6-terra"); + latest?.setChatReasoningEffort("high"); + }); + await act(async () => { + await latest?.handleSubmit({ text: "分析这个问题" }); + }); + await flushAnimationFrame(); + + expect(filework.executeTask).toHaveBeenCalledWith( + expect.objectContaining({ + llmConfigId: "codex-account", + modelId: "gpt-5.6-terra", + prompt: "分析这个问题", + reasoningEffort: "high", + }), + ); + expect(localStorageMock.setItem).toHaveBeenCalledWith( + "filework-selected-llm-model", + "gpt-5.6-terra", + ); + expect(localStorageMock.setItem).toHaveBeenCalledWith( + "filework-chat-reasoning-effort", + "high", + ); + }); + it("defers task execution until after the submit UI has updated", async () => { filework.getChatSessions.mockResolvedValue([ { diff --git a/src/renderer/components/chat/useChatSession.ts b/src/renderer/components/chat/useChatSession.ts index a3a2122..cc6ddb0 100644 --- a/src/renderer/components/chat/useChatSession.ts +++ b/src/renderer/components/chat/useChatSession.ts @@ -7,7 +7,10 @@ import { import { useI18nContext } from "../../i18n/i18n-react"; import type { ApprovalState } from "../ai-elements/confirmation"; import { truncateTitle } from "./helpers"; -import { isSelectableLlmConfig } from "./ModelSelector"; +import { + type ChatReasoningEffort, + isSelectableLlmConfig, +} from "./ModelSelector"; import { clearSessionRunState, clearSessionUnreadState, @@ -112,6 +115,24 @@ const scheduleAfterPaint = (callback: () => void) => { }; const CHAT_PERMISSION_STORAGE_KEY = "filework-chat-permission-mode"; +const CHAT_MODEL_STORAGE_KEY = "filework-selected-llm-model"; +const CHAT_REASONING_STORAGE_KEY = "filework-chat-reasoning-effort"; +const CHAT_REASONING_EFFORTS = new Set([ + "auto", + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", +]); + +const resolveStoredChatReasoningEffort = ( + value: string | null, +): ChatReasoningEffort => + value && CHAT_REASONING_EFFORTS.has(value as ChatReasoningEffort) + ? (value as ChatReasoningEffort) + : "auto"; const buildAutomationChatPrompt = ( run: AutomationRunRecordForChat, @@ -156,6 +177,15 @@ export function useChatSession( const [selectedLlmConfigId, setSelectedLlmConfigId] = useState( () => localStorage.getItem("filework-selected-llm-config") || null, ); + const [selectedLlmModelId, setSelectedLlmModelId] = useState( + () => localStorage.getItem(CHAT_MODEL_STORAGE_KEY) || null, + ); + const [chatReasoningEffort, setChatReasoningEffortState] = + useState(() => + resolveStoredChatReasoningEffort( + localStorage.getItem(CHAT_REASONING_STORAGE_KEY), + ), + ); const [chatPermissionMode, setChatPermissionModeState] = useState(() => resolveChatPermissionMode( @@ -194,7 +224,9 @@ export function useChatSession( ) ) { setSelectedLlmConfigId(null); + setSelectedLlmModelId(null); localStorage.removeItem("filework-selected-llm-config"); + localStorage.removeItem(CHAT_MODEL_STORAGE_KEY); } }, ) @@ -537,6 +569,9 @@ export function useChatSession( chatPermissionMode: chatPermissionModeRef.current, contextInputTokens, llmConfigId: selectedLlmConfigId || undefined, + modelId: selectedLlmModelId || undefined, + reasoningEffort: + chatReasoningEffort === "auto" ? undefined : chatReasoningEffort, history, }) .catch((error: unknown) => { @@ -979,11 +1014,27 @@ export function useChatSession( ); const setSelectedLlmConfigIdStable = useCallback((id: string | null) => { setSelectedLlmConfigId(id); + setSelectedLlmModelId(null); if (id) { localStorage.setItem("filework-selected-llm-config", id); } else { localStorage.removeItem("filework-selected-llm-config"); } + localStorage.removeItem(CHAT_MODEL_STORAGE_KEY); + }, []); + const setSelectedLlmModel = useCallback( + (configId: string, modelId: string) => { + setSelectedLlmConfigId(configId); + setSelectedLlmModelId(modelId); + localStorage.setItem("filework-selected-llm-config", configId); + localStorage.setItem(CHAT_MODEL_STORAGE_KEY, modelId); + }, + [], + ); + const setChatReasoningEffort = useCallback((effort: ChatReasoningEffort) => { + const next = resolveStoredChatReasoningEffort(effort); + setChatReasoningEffortState(next); + localStorage.setItem(CHAT_REASONING_STORAGE_KEY, next); }, []); const setChatPermissionMode = useCallback((mode: ChatPermissionMode) => { const next = resolveChatPermissionMode(mode); @@ -1009,7 +1060,11 @@ export function useChatSession( chatPermissionMode, setChatPermissionMode, selectedLlmConfigId, + selectedLlmModelId, + setSelectedLlmModel, setSelectedLlmConfigId: setSelectedLlmConfigIdStable, + chatReasoningEffort, + setChatReasoningEffort, retryInfo: stream.retryInfo, lastUsage: crud.lastUsage, lastError: crud.lastError, diff --git a/src/renderer/components/settings/LlmConfigEditModal.tsx b/src/renderer/components/settings/LlmConfigEditModal.tsx index 1f20236..1bacc7d 100644 --- a/src/renderer/components/settings/LlmConfigEditModal.tsx +++ b/src/renderer/components/settings/LlmConfigEditModal.tsx @@ -2,6 +2,7 @@ import { CheckCircle2, Copy, ExternalLink, + Globe2, Loader2, RefreshCw, } from "lucide-react"; @@ -18,11 +19,13 @@ export type Provider = | "openai" | "anthropic" | "deepseek" + | "kimi" | "ollama" | "custom" | "minimax" | "xiaomi" - | "github-copilot"; + | "github-copilot" + | "openai-codex"; export type Modality = "chat" | "image" | "video"; @@ -39,6 +42,7 @@ export interface LlmConfig { maxOutputTokens: number | null; reasoningEffort: ReasoningEffort | null; modality: Modality; + isDefault: boolean; enabled: boolean; lastCheckedAt: string | null; lastCheckStatus: "success" | "error" | null; @@ -76,9 +80,11 @@ const EMPTY_FORM: FormData = { }; const PROVIDERS: { value: Provider; label: string }[] = [ + { value: "openai-codex", label: "ChatGPT Plus/Pro" }, { value: "openai", label: "OpenAI" }, { value: "anthropic", label: "Anthropic" }, { value: "deepseek", label: "DeepSeek" }, + { value: "kimi", label: "Kimi" }, { value: "minimax", label: "MiniMax" }, { value: "xiaomi", label: "Xiaomi MiMo" }, { value: "github-copilot", label: "GitHub Copilot" }, @@ -104,6 +110,30 @@ const REASONING_EFFORTS: Array<"" | ReasoningEffort> = [ "xhigh", ]; +const OPENAI_CODEX_MODEL_OPTIONS: LlmModelOption[] = [ + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + "gpt-5.5", + "gpt-5.4", + "gpt-5.4-mini", + "gpt-5.3-codex-spark", +].map((value) => ({ + value, + label: value + .split("-") + .map((part) => (part === "gpt" ? "GPT" : part)) + .join(" "), + capabilities: { + preferredApi: "responses", + supportsReasoning: true, + supportsTools: true, + supportsVision: value !== "gpt-5.3-codex-spark", + }, + contextWindow: value === "gpt-5.3-codex-spark" ? 128_000 : 272_000, + maxOutputTokens: 128_000, +})); + function formatOptionalNumber(value: number | null | undefined): string { return value === null || value === undefined ? "" : String(value); } @@ -126,6 +156,109 @@ export interface LlmModelOption { maxOutputTokens?: number | null; } +const DISCOVERABLE_MODEL_PROVIDERS = new Set([ + "custom", + "deepseek", + "github-copilot", + "kimi", + "openai-codex", +]); + +type LlmProviderDefaults = Pick< + FormData, + "apiPath" | "baseUrl" | "modality" | "model" | "name" +>; + +const OFFICIAL_PROVIDER_DEFAULTS: Partial< + Record +> = { + deepseek: { + name: "DeepSeek", + baseUrl: "https://api.deepseek.com", + apiPath: "", + model: "deepseek-v4-pro", + modality: "chat", + }, + kimi: { + name: "Kimi", + baseUrl: "https://api.moonshot.cn/v1", + apiPath: "", + model: "kimi-k3", + modality: "chat", + }, +}; + +export function getLlmProviderDefaults( + provider: Provider, +): LlmProviderDefaults | null { + return OFFICIAL_PROVIDER_DEFAULTS[provider] ?? null; +} + +const DEEPSEEK_MODEL_OPTIONS: LlmModelOption[] = [ + "deepseek-v4-pro", + "deepseek-v4-flash", +].map((value) => ({ + value, + label: value, + capabilities: { + preferredApi: "chat_completions", + supportsReasoning: true, + supportsTools: true, + supportsVision: null, + }, + contextWindow: 1_000_000, + maxOutputTokens: 384_000, +})); + +const KIMI_MODEL_OPTIONS: LlmModelOption[] = [ + { + value: "kimi-k3", + label: "Kimi K3", + capabilities: { + preferredApi: "chat_completions", + supportsReasoning: true, + supportsTools: true, + supportsVision: true, + }, + contextWindow: 1_000_000, + }, + ...["kimi-k2.7-code", "kimi-k2.7-code-highspeed"].map((value) => ({ + value, + label: value, + capabilities: { + preferredApi: "chat_completions" as const, + supportsReasoning: true, + supportsTools: true, + supportsVision: null, + }, + contextWindow: 256_000, + })), + { + value: "kimi-k2.6", + label: "Kimi K2.6", + capabilities: { + preferredApi: "chat_completions", + supportsReasoning: true, + supportsTools: true, + supportsVision: true, + }, + contextWindow: 256_000, + }, +]; + +function getInitialLlmModelOptions(provider: Provider): LlmModelOption[] { + switch (provider) { + case "deepseek": + return DEEPSEEK_MODEL_OPTIONS; + case "kimi": + return KIMI_MODEL_OPTIONS; + case "openai-codex": + return OPENAI_CODEX_MODEL_OPTIONS; + default: + return []; + } +} + function formatCompactTokenCount(value: number): string { if (value >= 1000 && value % 1000 === 0) { return `${value / 1000}k`; @@ -161,7 +294,9 @@ export function getVisibleLlmModelOptions( refreshedOptions: LlmModelOption[], currentModel: string, ): LlmModelOption[] { - if (provider !== "github-copilot" && provider !== "custom") return []; + if (!DISCOVERABLE_MODEL_PROVIDERS.has(provider)) { + return []; + } const currentValue = currentModel.trim(); if (!currentValue) return refreshedOptions; if (refreshedOptions.some((option) => option.value === currentValue)) { @@ -180,7 +315,9 @@ export function getLlmSelectedModelAvailability( refreshedOptions: LlmModelOption[], currentModel: string, ): LlmSelectedModelAvailability { - if (provider !== "github-copilot" && provider !== "custom") return "unknown"; + if (!DISCOVERABLE_MODEL_PROVIDERS.has(provider)) { + return "unknown"; + } if (refreshedOptions.length === 0) return "unknown"; const currentValue = currentModel.trim(); @@ -200,7 +337,9 @@ export function getLlmReasoningEffortAvailability( refreshedOptions: LlmModelOption[], currentModel: string, ): LlmReasoningEffortAvailability { - if (provider !== "github-copilot" && provider !== "custom") return "unknown"; + if (!DISCOVERABLE_MODEL_PROVIDERS.has(provider)) { + return "unknown"; + } if (refreshedOptions.length === 0) return "unknown"; const currentValue = currentModel.trim(); @@ -218,7 +357,7 @@ export type LlmModelRefreshRequest = | { id: string } | { draft: { - provider: "custom"; + provider: "custom" | "deepseek" | "kimi"; apiKey?: string | null; baseUrl: string; apiPath?: string | null; @@ -238,11 +377,19 @@ export function getLlmModelRefreshRequest({ return copilotConnected && editingId ? { id: editingId } : null; } - if (form.provider !== "custom") return null; + if ( + form.provider !== "custom" && + form.provider !== "deepseek" && + form.provider !== "kimi" + ) { + return null; + } if (editingId) return { id: editingId }; - const baseUrl = form.baseUrl.trim(); + const defaults = getLlmProviderDefaults(form.provider); + const baseUrl = form.baseUrl.trim() || defaults?.baseUrl || ""; if (!baseUrl) return null; + if (form.provider !== "custom" && !form.apiKey.trim()) return null; const apiPath = form.apiPath.trim(); if ( @@ -255,7 +402,7 @@ export function getLlmModelRefreshRequest({ return { draft: { - provider: "custom", + provider: form.provider, apiKey: form.apiKey.trim() || null, baseUrl, apiPath: apiPath || null, @@ -279,6 +426,13 @@ export function shouldShowGithubCopilotDisconnect( return provider === "github-copilot" && isEditing && isConnected; } +export function shouldShowOpenAICodexAuthFlow( + provider: Provider, + isConnected: boolean, +): boolean { + return provider === "openai-codex" && !isConnected; +} + export type GithubCopilotAuthStepState = "current" | "done" | "locked"; export function getGithubCopilotAuthStepStates( @@ -308,6 +462,7 @@ export function getLlmProviderFieldPolicy( "openai", "anthropic", "deepseek", + "kimi", "minimax", "xiaomi", ].includes(provider); @@ -318,8 +473,7 @@ export function getLlmProviderFieldPolicy( "github-copilot", ].includes(provider); const showBaseUrl = - requireBaseUrl || - ["openai", "anthropic", "deepseek", "minimax"].includes(provider); + requireBaseUrl || ["openai", "anthropic", "minimax"].includes(provider); const showApiPath = provider === "custom" || provider === "github-copilot"; const supportedModalities = getSupportedLlmModalitiesForProvider( provider, @@ -328,19 +482,23 @@ export function getLlmProviderFieldPolicy( const baseUrlPlaceholder = provider === "ollama" ? "http://localhost:11434" - : provider === "minimax" - ? "https://api.minimaxi.com/v1" - : provider === "xiaomi" - ? "https://example.xiaomi.com/v1" - : provider === "github-copilot" - ? "https://api.githubcopilot.com" - : provider === "custom" - ? "https://api.example.com/v1" - : ""; + : provider === "deepseek" + ? "https://api.deepseek.com" + : provider === "kimi" + ? "https://api.moonshot.cn/v1" + : provider === "minimax" + ? "https://api.minimaxi.com/v1" + : provider === "xiaomi" + ? "https://example.xiaomi.com/v1" + : provider === "github-copilot" + ? "https://api.githubcopilot.com" + : provider === "custom" + ? "https://api.example.com/v1" + : ""; return { showApiKey: - provider === "github-copilot" + provider === "github-copilot" || provider === "openai-codex" ? false : requireApiKey || provider === "custom", requireApiKey, @@ -358,6 +516,14 @@ const GITHUB_COPILOT_DEFAULT_BASE_URL = "https://api.githubcopilot.com"; const GITHUB_COPILOT_DEFAULT_API_PATH = "/chat/completions"; const GITHUB_COPILOT_DEFAULT_MODEL = "gpt-5.5"; const GITHUB_DEVICE_LOGIN_URL = "https://github.com/login/device"; +const OPENAI_CODEX_DEFAULT_BASE_URL = "https://chatgpt.com/backend-api/codex"; +const OPENAI_CODEX_DEFAULT_MODEL = "gpt-5.5"; + +interface OpenAICodexAuthStart { + authSessionId: string; + authorizationUrl: string; + redirectUri: string; +} interface GithubCopilotAuthStart { deviceCode: string; @@ -441,6 +607,13 @@ export const LlmConfigEditModal = ({ const [copilotConnected, setCopilotConnected] = useState(false); const [copilotCopied, setCopilotCopied] = useState(false); const [copilotError, setCopilotError] = useState(null); + const [openAICodexAuth, setOpenAICodexAuth] = + useState(null); + const [openAICodexBusy, setOpenAICodexBusy] = useState< + "start" | "open" | "complete" | "disconnect" | null + >(null); + const [openAICodexConnected, setOpenAICodexConnected] = useState(false); + const [openAICodexError, setOpenAICodexError] = useState(null); const [refreshedModelOptions, setRefreshedModelOptions] = useState< LlmModelOption[] >([]); @@ -460,7 +633,7 @@ export const LlmConfigEditModal = ({ } setRefreshedModelOptions(result); setForm((prev) => - (prev.provider === "github-copilot" || prev.provider === "custom") && + DISCOVERABLE_MODEL_PROVIDERS.has(prev.provider) && !prev.model.trim() && result[0] ? { ...prev, model: result[0].value } @@ -503,7 +676,15 @@ export const LlmConfigEditModal = ({ ); setCopilotCopied(false); setCopilotError(null); - setRefreshedModelOptions([]); + setOpenAICodexAuth(null); + setOpenAICodexBusy(null); + setOpenAICodexConnected( + editing?.provider === "openai-codex" && Boolean(editing.apiKey), + ); + setOpenAICodexError(null); + setRefreshedModelOptions( + editing ? getInitialLlmModelOptions(editing.provider) : [], + ); setModelRefreshBusy(false); setModelRefreshError(null); }, [open, editing]); @@ -512,6 +693,9 @@ export const LlmConfigEditModal = ({ open && editing && (editing.provider === "custom" || + editing.provider === "deepseek" || + editing.provider === "kimi" || + editing.provider === "openai-codex" || (editing.provider === "github-copilot" && copilotConnected)) ? editing.id : null; @@ -611,6 +795,23 @@ export const LlmConfigEditModal = ({ modality: "chat", }); + const setOpenAICodexDefaults = (nextForm: FormData): FormData => ({ + ...nextForm, + name: nextForm.name || "ChatGPT Plus/Pro", + baseUrl: OPENAI_CODEX_DEFAULT_BASE_URL, + apiPath: "", + model: OPENAI_CODEX_DEFAULT_MODEL, + modality: "chat", + }); + + const setOfficialProviderDefaults = ( + nextForm: FormData, + provider: Provider, + ): FormData => { + const defaults = getLlmProviderDefaults(provider); + return defaults ? { ...nextForm, ...defaults } : nextForm; + }; + const copyCopilotCode = async (code: string) => { setCopilotBusy("copy"); setCopilotError(null); @@ -719,6 +920,90 @@ export const LlmConfigEditModal = ({ } }; + const handleStartOpenAICodexAuth = async () => { + setOpenAICodexBusy("start"); + setOpenAICodexError(null); + try { + const result = (await window.filework.llmConfig.startOpenAICodexAuth()) as + | OpenAICodexAuthStart + | { error: string }; + if ("error" in result) { + setOpenAICodexError(result.error); + return; + } + setOpenAICodexAuth(result); + await window.filework.openExternal(result.authorizationUrl); + } catch (error) { + setOpenAICodexError( + error instanceof Error ? error.message : String(error), + ); + } finally { + setOpenAICodexBusy(null); + } + }; + + const handleOpenOpenAICodexAuth = async () => { + if (!openAICodexAuth) return; + setOpenAICodexBusy("open"); + setOpenAICodexError(null); + try { + await window.filework.openExternal(openAICodexAuth.authorizationUrl); + } catch (error) { + setOpenAICodexError( + error instanceof Error ? error.message : String(error), + ); + } finally { + setOpenAICodexBusy(null); + } + }; + + const handleCompleteOpenAICodexAuth = async () => { + if (!openAICodexAuth) return; + setOpenAICodexBusy("complete"); + setOpenAICodexError(null); + try { + const result = (await window.filework.llmConfig.completeOpenAICodexAuth({ + authSessionId: openAICodexAuth.authSessionId, + name: form.name || "ChatGPT Plus/Pro", + model: form.model || OPENAI_CODEX_DEFAULT_MODEL, + configId: editing && !openAICodexConnected ? editing.id : undefined, + })) as LlmConfig | { error: string }; + if ("error" in result) { + setOpenAICodexError(result.error); + return; + } + onSaved(); + onClose(); + } finally { + setOpenAICodexBusy(null); + } + }; + + const handleDisconnectOpenAICodex = async () => { + if (!editing) return; + setOpenAICodexBusy("disconnect"); + setOpenAICodexError(null); + try { + const result = (await window.filework.llmConfig.disconnectOpenAICodex( + editing.id, + )) as LlmConfig | { error: string }; + if ("error" in result) { + setOpenAICodexError(result.error); + return; + } + setOpenAICodexConnected(false); + setOpenAICodexAuth(null); + setForm((prev) => ({ ...prev, apiKey: "", enabled: false })); + onSaved(); + } catch (error) { + setOpenAICodexError( + error instanceof Error ? error.message : String(error), + ); + } finally { + setOpenAICodexBusy(null); + } + }; + const inputCls = "w-full rounded-lg border border-border bg-background px-2 py-1.5 text-sm text-foreground focus:border-primary focus:outline-none"; const nameInputId = "llm-config-name"; @@ -737,7 +1022,11 @@ export const LlmConfigEditModal = ({ const fieldPolicy = getLlmProviderFieldPolicy(form.provider); const selectedModelValue = form.model || - (form.provider === "github-copilot" ? GITHUB_COPILOT_DEFAULT_MODEL : ""); + (form.provider === "github-copilot" + ? GITHUB_COPILOT_DEFAULT_MODEL + : form.provider === "openai-codex" + ? OPENAI_CODEX_DEFAULT_MODEL + : getLlmProviderDefaults(form.provider)?.model || ""); const visibleModelOptions = getVisibleLlmModelOptions( form.provider, refreshedModelOptions, @@ -769,6 +1058,14 @@ export const LlmConfigEditModal = ({ Boolean(editing), copilotConnected, ); + const showOpenAICodexAuthFlow = shouldShowOpenAICodexAuthFlow( + form.provider, + openAICodexConnected, + ); + const showOpenAICodexDisconnect = + form.provider === "openai-codex" && + Boolean(editing) && + openAICodexConnected; const copilotStepStates = getGithubCopilotAuthStepStates( Boolean(copilotAuth), ); @@ -800,7 +1097,14 @@ export const LlmConfigEditModal = ({ { - if (!nextOpen) onClose(); + if (!nextOpen) { + if (openAICodexAuth) { + void window.filework.llmConfig.cancelOpenAICodexAuth( + openAICodexAuth.authSessionId, + ); + } + onClose(); + } }} > @@ -852,13 +1156,24 @@ export const LlmConfigEditModal = ({ setForm( next === "github-copilot" ? setGithubCopilotDefaults(nextForm) - : nextForm, + : next === "openai-codex" + ? setOpenAICodexDefaults(nextForm) + : setOfficialProviderDefaults(nextForm, next), ); + if (openAICodexAuth) { + void window.filework.llmConfig.cancelOpenAICodexAuth( + openAICodexAuth.authSessionId, + ); + } setCopilotAuth(null); setCopilotConnected(false); setCopilotCopied(false); setCopilotError(null); - setRefreshedModelOptions([]); + setOpenAICodexAuth(null); + setOpenAICodexBusy(null); + setOpenAICodexConnected(false); + setOpenAICodexError(null); + setRefreshedModelOptions(getInitialLlmModelOptions(next)); setModelRefreshBusy(false); setModelRefreshError(null); }} @@ -999,7 +1314,8 @@ export const LlmConfigEditModal = ({ )}
- {form.provider === "github-copilot" ? ( + {form.provider === "github-copilot" || + form.provider === "openai-codex" ? ( - void handleSetEnabled(c.id, e.currentTarget.checked) - } - className="size-3 accent-primary" - /> - {LL.llmConfig_enabled()} - - - - + {configs.length === 0 && ( +
+ +
+ {LL.llmConfig_emptyTitle()} +
+

+ {LL.llmConfig_emptyDescription()} +

+
+ )} + + {groupMeta.map((group) => { + const groupConfigs = configs.filter( + (config) => getLlmConfigGroup(config.provider) === group.id, + ); + if (groupConfigs.length === 0) return null; + const GroupIcon = group.icon; + return ( +
+
+ +
+

+ {group.title} +

+

+ {group.description} +

- ); - })} -
+ +
+ {groupConfigs.map((config) => { + const statusTitle = buildLlmConfigStatusTooltip( + config, + LL, + locale, + ); + const modalityBadge = getLlmConfigModalityBadge(config); + const isReady = + config.enabled && config.lastCheckStatus === "success"; + const isTesting = testingId === config.id; + return ( +
+
+
+ {getProviderMonogram(config.provider)} +
+
+
+ + {config.isDefault && ( + + {LL.llmConfig_default()} + + )} + {modalityBadge && ( + + {modalityBadge} + + )} +
+
+ {getProviderLabel(config.provider)} +
+
+ +
+ +
+
+
+ {LL.llmConfig_model()} +
+
+ {config.model} +
+
+
+ {isReady + ? LL.llmConfig_ready() + : config.lastCheckStatus === "error" + ? LL.llmConfig_needsAttention() + : LL.llmConfig_statusUnchecked()} +
+
+ +
+ + +
+ {!config.isDefault && config.enabled && ( + + )} + + + +
+
+
+ ); + })} +
+ + ); + })} { + it("makes DeepSeek and Kimi official configs ready after entering an API key", () => { + expect(getLlmProviderDefaults("deepseek")).toMatchObject({ + name: "DeepSeek", + baseUrl: "https://api.deepseek.com", + model: "deepseek-v4-pro", + modality: "chat", + }); + expect(getLlmProviderDefaults("kimi")).toMatchObject({ + name: "Kimi", + baseUrl: "https://api.moonshot.cn/v1", + model: "kimi-k3", + modality: "chat", + }); + expect(getLlmProviderFieldPolicy("kimi")).toEqual({ + showApiKey: true, + requireApiKey: true, + showBaseUrl: false, + requireBaseUrl: false, + showApiPath: false, + supportedModalities: ["chat"], + supportsImageVideo: false, + baseUrlPlaceholder: "https://api.moonshot.cn/v1", + apiPathPlaceholder: "/chat/completions", + }); + }); + it("shows optional API key and required Base URL for OpenAI-compatible custom providers", () => { expect(getLlmProviderFieldPolicy("custom")).toEqual({ showApiKey: true, @@ -41,6 +69,32 @@ describe("getLlmProviderFieldPolicy", () => { }); }); + it("uses browser authentication for ChatGPT Plus and Pro", () => { + expect(getLlmProviderFieldPolicy("openai-codex")).toEqual({ + showApiKey: false, + requireApiKey: false, + showBaseUrl: false, + requireBaseUrl: false, + showApiPath: false, + supportedModalities: ["chat"], + supportsImageVideo: false, + baseUrlPlaceholder: "", + apiPathPlaceholder: "/chat/completions", + }); + expect( + getVisibleLlmModelOptions( + "openai-codex", + [ + { value: "gpt-5.5", label: "GPT-5.5" }, + { value: "gpt-5.6-sol", label: "GPT-5.6 Sol" }, + ], + "gpt-5.5", + ), + ).toHaveLength(2); + expect(shouldShowOpenAICodexAuthFlow("openai-codex", false)).toBe(true); + expect(shouldShowOpenAICodexAuthFlow("openai-codex", true)).toBe(false); + }); + it("uses refreshed GitHub Copilot model options from the API", () => { const refreshedModels = [ { value: "gpt-5.5", label: "GPT-5.5" }, @@ -59,6 +113,12 @@ describe("getLlmProviderFieldPolicy", () => { expect( getVisibleLlmModelOptions("openai", refreshedModels, "gpt-5.5"), ).toEqual([]); + expect( + getVisibleLlmModelOptions("deepseek", refreshedModels, "gpt-5.5"), + ).toEqual(refreshedModels); + expect( + getVisibleLlmModelOptions("kimi", refreshedModels, "gpt-5.5"), + ).toEqual(refreshedModels); }); it("adds compact capability hints to refreshed model labels", () => { @@ -178,6 +238,26 @@ describe("getLlmProviderFieldPolicy", () => { }, }), ).toBeNull(); + + expect( + getLlmModelRefreshRequest({ + copilotConnected: false, + editingId: null, + form: { + apiKey: "sk-kimi", + apiPath: "", + baseUrl: "https://api.moonshot.cn/v1", + provider: "kimi", + }, + }), + ).toEqual({ + draft: { + apiKey: "sk-kimi", + apiPath: null, + baseUrl: "https://api.moonshot.cn/v1", + provider: "kimi", + }, + }); }); it("keeps Copilot model refresh tied to an existing connected config", () => { diff --git a/src/renderer/components/settings/__tests__/LlmConfigPanel.test.tsx b/src/renderer/components/settings/__tests__/LlmConfigPanel.test.tsx index a050699..4b748cb 100644 --- a/src/renderer/components/settings/__tests__/LlmConfigPanel.test.tsx +++ b/src/renderer/components/settings/__tests__/LlmConfigPanel.test.tsx @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; import { TooltipProvider } from "../../ui/tooltip"; import { buildLlmConfigStatusTooltip, + getLlmConfigGroup, getLlmConfigModalityBadge, LlmConfigStatusIndicator, } from "../LlmConfigPanel"; @@ -73,3 +74,13 @@ describe("getLlmConfigModalityBadge", () => { ).toBeNull(); }); }); + +describe("getLlmConfigGroup", () => { + it("separates browser subscriptions from API and local model sources", () => { + expect(getLlmConfigGroup("openai-codex")).toBe("subscription"); + expect(getLlmConfigGroup("github-copilot")).toBe("subscription"); + expect(getLlmConfigGroup("openai")).toBe("api"); + expect(getLlmConfigGroup("ollama")).toBe("local"); + expect(getLlmConfigGroup("custom")).toBe("local"); + }); +}); diff --git a/src/renderer/i18n/en/index.ts b/src/renderer/i18n/en/index.ts index 7c9ed93..95d97d4 100644 --- a/src/renderer/i18n/en/index.ts +++ b/src/renderer/i18n/en/index.ts @@ -253,9 +253,26 @@ const en: BaseTranslation = { task_failed: "Failed", task_undo: "Undo", - // LLM Configuration - llmConfig_title: "LLM Channel Configuration", - llmConfig_add: "Add Configuration", + // Model Configuration + llmConfig_title: "Model Configuration", + llmConfig_description: + "Connect subscriptions, API providers, and local runtimes. Each model keeps its own connection state and generation settings.", + llmConfig_add: "Add model", + llmConfig_groupSubscriptions: "Subscriptions", + llmConfig_groupSubscriptionsDescription: + "Browser or device login using an existing AI subscription.", + llmConfig_groupApi: "API providers", + llmConfig_groupApiDescription: "Hosted models authenticated with an API key.", + llmConfig_groupLocal: "Local & custom", + llmConfig_groupLocalDescription: + "Ollama and OpenAI-compatible endpoints you control.", + llmConfig_summaryConfigured: "{count:number} configured", + llmConfig_summaryReady: "{count:number} ready", + llmConfig_emptyTitle: "No models configured", + llmConfig_emptyDescription: + "Add a subscription, API provider, or local runtime to start working.", + llmConfig_ready: "Ready", + llmConfig_needsAttention: "Needs attention", llmConfig_edit: "Edit", llmConfig_delete: "Delete", llmConfig_name: "Display Name", @@ -278,6 +295,7 @@ const en: BaseTranslation = { llmConfig_reasoningEffortXHigh: "Extra high", llmConfig_reasoningEffortUnsupported: "The selected model does not support reasoning effort.", + chat_reasoningAuto: "Auto", llmConfig_temperatureInvalid: "Temperature must be between 0 and 2", llmConfig_topPInvalid: "Top P must be between 0 and 1", llmConfig_maxOutputTokensInvalid: @@ -300,8 +318,8 @@ const en: BaseTranslation = { llmConfig_deleteLastError: "At least one default configuration must be kept", llmConfig_validationRequired: "This field is required", llmConfig_authError: - "API Key is invalid or expired, please check the channel configuration in settings", - llmConfig_notFound: "Selected LLM configuration does not exist", + "Authentication is invalid or expired. Check the model configuration in settings.", + llmConfig_notFound: "Selected model configuration does not exist", llmConfig_save: "Save", llmConfig_cancel: "Cancel", llmConfig_copilotTitle: "GitHub Copilot Authorization", @@ -326,6 +344,24 @@ const en: BaseTranslation = { llmConfig_copilotConnectedDescription: "Disconnect to clear the saved Copilot token. You can authorize this channel again afterwards.", llmConfig_copilotDisconnect: "Disconnect", + llmConfig_chatgptTitle: "ChatGPT Plus/Pro", + llmConfig_chatgptDescription: + "Sign in through your browser to use eligible Codex models from your ChatGPT subscription. Tokens are encrypted locally.", + llmConfig_chatgptBrowserLogin: "Browser login", + llmConfig_chatgptBrowserLoginHint: + "Open the official OpenAI sign-in page and choose your ChatGPT workspace.", + llmConfig_chatgptOpenBrowser: "Sign in", + llmConfig_chatgptComplete: "Complete connection", + llmConfig_chatgptCompleteHint: + "After the browser confirms success, return here and save the connection.", + llmConfig_chatgptWaitingHint: + "Start browser login first. Workspace Agent listens only on localhost for the callback.", + llmConfig_chatgptReopen: "Reopen", + llmConfig_chatgptConnect: "Finish", + llmConfig_chatgptConnectedTitle: "ChatGPT subscription connected", + llmConfig_chatgptConnectedDescription: + "Workspace Agent refreshes the encrypted access token automatically. Disconnect to remove the saved login.", + llmConfig_chatgptDisconnect: "Disconnect", // MCP Configuration mcpConfig_title: "MCP Servers", diff --git a/src/renderer/i18n/i18n-types.ts b/src/renderer/i18n/i18n-types.ts index 22a7290..a13b9ba 100644 --- a/src/renderer/i18n/i18n-types.ts +++ b/src/renderer/i18n/i18n-types.ts @@ -926,13 +926,67 @@ type RootTranslation = { */ task_undo: string /** - * L​L​M​ ​C​h​a​n​n​e​l​ ​C​o​n​f​i​g​u​r​a​t​i​o​n + * M​o​d​e​l​ ​C​o​n​f​i​g​u​r​a​t​i​o​n */ llmConfig_title: string /** - * A​d​d​ ​C​o​n​f​i​g​u​r​a​t​i​o​n + * C​o​n​n​e​c​t​ ​s​u​b​s​c​r​i​p​t​i​o​n​s​,​ ​A​P​I​ ​p​r​o​v​i​d​e​r​s​,​ ​a​n​d​ ​l​o​c​a​l​ ​r​u​n​t​i​m​e​s​.​ ​E​a​c​h​ ​m​o​d​e​l​ ​k​e​e​p​s​ ​i​t​s​ ​o​w​n​ ​c​o​n​n​e​c​t​i​o​n​ ​s​t​a​t​e​ ​a​n​d​ ​g​e​n​e​r​a​t​i​o​n​ ​s​e​t​t​i​n​g​s​. + */ + llmConfig_description: string + /** + * A​d​d​ ​m​o​d​e​l */ llmConfig_add: string + /** + * S​u​b​s​c​r​i​p​t​i​o​n​s + */ + llmConfig_groupSubscriptions: string + /** + * B​r​o​w​s​e​r​ ​o​r​ ​d​e​v​i​c​e​ ​l​o​g​i​n​ ​u​s​i​n​g​ ​a​n​ ​e​x​i​s​t​i​n​g​ ​A​I​ ​s​u​b​s​c​r​i​p​t​i​o​n​. + */ + llmConfig_groupSubscriptionsDescription: string + /** + * A​P​I​ ​p​r​o​v​i​d​e​r​s + */ + llmConfig_groupApi: string + /** + * H​o​s​t​e​d​ ​m​o​d​e​l​s​ ​a​u​t​h​e​n​t​i​c​a​t​e​d​ ​w​i​t​h​ ​a​n​ ​A​P​I​ ​k​e​y​. + */ + llmConfig_groupApiDescription: string + /** + * L​o​c​a​l​ ​&​ ​c​u​s​t​o​m + */ + llmConfig_groupLocal: string + /** + * O​l​l​a​m​a​ ​a​n​d​ ​O​p​e​n​A​I​-​c​o​m​p​a​t​i​b​l​e​ ​e​n​d​p​o​i​n​t​s​ ​y​o​u​ ​c​o​n​t​r​o​l​. + */ + llmConfig_groupLocalDescription: string + /** + * {​c​o​u​n​t​}​ ​c​o​n​f​i​g​u​r​e​d + * @param {number} count + */ + llmConfig_summaryConfigured: RequiredParams<'count'> + /** + * {​c​o​u​n​t​}​ ​r​e​a​d​y + * @param {number} count + */ + llmConfig_summaryReady: RequiredParams<'count'> + /** + * N​o​ ​m​o​d​e​l​s​ ​c​o​n​f​i​g​u​r​e​d + */ + llmConfig_emptyTitle: string + /** + * A​d​d​ ​a​ ​s​u​b​s​c​r​i​p​t​i​o​n​,​ ​A​P​I​ ​p​r​o​v​i​d​e​r​,​ ​o​r​ ​l​o​c​a​l​ ​r​u​n​t​i​m​e​ ​t​o​ ​s​t​a​r​t​ ​w​o​r​k​i​n​g​. + */ + llmConfig_emptyDescription: string + /** + * R​e​a​d​y + */ + llmConfig_ready: string + /** + * N​e​e​d​s​ ​a​t​t​e​n​t​i​o​n + */ + llmConfig_needsAttention: string /** * E​d​i​t */ @@ -1017,6 +1071,10 @@ type RootTranslation = { * T​h​e​ ​s​e​l​e​c​t​e​d​ ​m​o​d​e​l​ ​d​o​e​s​ ​n​o​t​ ​s​u​p​p​o​r​t​ ​r​e​a​s​o​n​i​n​g​ ​e​f​f​o​r​t​. */ llmConfig_reasoningEffortUnsupported: string + /** + * A​u​t​o + */ + chat_reasoningAuto: string /** * T​e​m​p​e​r​a​t​u​r​e​ ​m​u​s​t​ ​b​e​ ​b​e​t​w​e​e​n​ ​0​ ​a​n​d​ ​2 */ @@ -1087,11 +1145,11 @@ type RootTranslation = { */ llmConfig_validationRequired: string /** - * A​P​I​ ​K​e​y​ ​i​s​ ​i​n​v​a​l​i​d​ ​o​r​ ​e​x​p​i​r​e​d​,​ ​p​l​e​a​s​e​ ​c​h​e​c​k​ ​t​h​e​ ​c​h​a​n​n​e​l​ ​c​o​n​f​i​g​u​r​a​t​i​o​n​ ​i​n​ ​s​e​t​t​i​n​g​s + * A​u​t​h​e​n​t​i​c​a​t​i​o​n​ ​i​s​ ​i​n​v​a​l​i​d​ ​o​r​ ​e​x​p​i​r​e​d​.​ ​C​h​e​c​k​ ​t​h​e​ ​m​o​d​e​l​ ​c​o​n​f​i​g​u​r​a​t​i​o​n​ ​i​n​ ​s​e​t​t​i​n​g​s​. */ llmConfig_authError: string /** - * S​e​l​e​c​t​e​d​ ​L​L​M​ ​c​o​n​f​i​g​u​r​a​t​i​o​n​ ​d​o​e​s​ ​n​o​t​ ​e​x​i​s​t + * S​e​l​e​c​t​e​d​ ​m​o​d​e​l​ ​c​o​n​f​i​g​u​r​a​t​i​o​n​ ​d​o​e​s​ ​n​o​t​ ​e​x​i​s​t */ llmConfig_notFound: string /** @@ -1174,6 +1232,58 @@ type RootTranslation = { * D​i​s​c​o​n​n​e​c​t */ llmConfig_copilotDisconnect: string + /** + * C​h​a​t​G​P​T​ ​P​l​u​s​/​P​r​o + */ + llmConfig_chatgptTitle: string + /** + * S​i​g​n​ ​i​n​ ​t​h​r​o​u​g​h​ ​y​o​u​r​ ​b​r​o​w​s​e​r​ ​t​o​ ​u​s​e​ ​e​l​i​g​i​b​l​e​ ​C​o​d​e​x​ ​m​o​d​e​l​s​ ​f​r​o​m​ ​y​o​u​r​ ​C​h​a​t​G​P​T​ ​s​u​b​s​c​r​i​p​t​i​o​n​.​ ​T​o​k​e​n​s​ ​a​r​e​ ​e​n​c​r​y​p​t​e​d​ ​l​o​c​a​l​l​y​. + */ + llmConfig_chatgptDescription: string + /** + * B​r​o​w​s​e​r​ ​l​o​g​i​n + */ + llmConfig_chatgptBrowserLogin: string + /** + * O​p​e​n​ ​t​h​e​ ​o​f​f​i​c​i​a​l​ ​O​p​e​n​A​I​ ​s​i​g​n​-​i​n​ ​p​a​g​e​ ​a​n​d​ ​c​h​o​o​s​e​ ​y​o​u​r​ ​C​h​a​t​G​P​T​ ​w​o​r​k​s​p​a​c​e​. + */ + llmConfig_chatgptBrowserLoginHint: string + /** + * S​i​g​n​ ​i​n + */ + llmConfig_chatgptOpenBrowser: string + /** + * C​o​m​p​l​e​t​e​ ​c​o​n​n​e​c​t​i​o​n + */ + llmConfig_chatgptComplete: string + /** + * A​f​t​e​r​ ​t​h​e​ ​b​r​o​w​s​e​r​ ​c​o​n​f​i​r​m​s​ ​s​u​c​c​e​s​s​,​ ​r​e​t​u​r​n​ ​h​e​r​e​ ​a​n​d​ ​s​a​v​e​ ​t​h​e​ ​c​o​n​n​e​c​t​i​o​n​. + */ + llmConfig_chatgptCompleteHint: string + /** + * S​t​a​r​t​ ​b​r​o​w​s​e​r​ ​l​o​g​i​n​ ​f​i​r​s​t​.​ ​W​o​r​k​s​p​a​c​e​ ​A​g​e​n​t​ ​l​i​s​t​e​n​s​ ​o​n​l​y​ ​o​n​ ​l​o​c​a​l​h​o​s​t​ ​f​o​r​ ​t​h​e​ ​c​a​l​l​b​a​c​k​. + */ + llmConfig_chatgptWaitingHint: string + /** + * R​e​o​p​e​n + */ + llmConfig_chatgptReopen: string + /** + * F​i​n​i​s​h + */ + llmConfig_chatgptConnect: string + /** + * C​h​a​t​G​P​T​ ​s​u​b​s​c​r​i​p​t​i​o​n​ ​c​o​n​n​e​c​t​e​d + */ + llmConfig_chatgptConnectedTitle: string + /** + * W​o​r​k​s​p​a​c​e​ ​A​g​e​n​t​ ​r​e​f​r​e​s​h​e​s​ ​t​h​e​ ​e​n​c​r​y​p​t​e​d​ ​a​c​c​e​s​s​ ​t​o​k​e​n​ ​a​u​t​o​m​a​t​i​c​a​l​l​y​.​ ​D​i​s​c​o​n​n​e​c​t​ ​t​o​ ​r​e​m​o​v​e​ ​t​h​e​ ​s​a​v​e​d​ ​l​o​g​i​n​. + */ + llmConfig_chatgptConnectedDescription: string + /** + * D​i​s​c​o​n​n​e​c​t + */ + llmConfig_chatgptDisconnect: string /** * M​C​P​ ​S​e​r​v​e​r​s */ @@ -2531,7 +2641,7 @@ type RootTranslation = { */ preview_emptySheet: string /** - * S​e​l​e​c​t​ ​a​n​ ​e​l​e​m​e​n​t​ ​i​n​ ​a​ ​s​l​i​d​e​ ​t​o​ ​a​d​d​ ​i​t​ ​t​o​ ​t​h​e​ ​l​o​c​a​l​ ​e​d​i​t​i​n​g​ ​p​r​o​m​p​t + * S​e​l​e​c​t​ ​a​ ​s​l​i​d​e​ ​e​l​e​m​e​n​t​ ​t​o​ ​d​e​s​c​r​i​b​e​ ​a​ ​n​e​a​r​b​y​ ​e​d​i​t */ preview_selectPptxElement: string /** @@ -4115,13 +4225,65 @@ export type TranslationFunctions = { */ task_undo: () => LocalizedString /** - * LLM Channel Configuration + * Model Configuration */ llmConfig_title: () => LocalizedString /** - * Add Configuration + * Connect subscriptions, API providers, and local runtimes. Each model keeps its own connection state and generation settings. + */ + llmConfig_description: () => LocalizedString + /** + * Add model */ llmConfig_add: () => LocalizedString + /** + * Subscriptions + */ + llmConfig_groupSubscriptions: () => LocalizedString + /** + * Browser or device login using an existing AI subscription. + */ + llmConfig_groupSubscriptionsDescription: () => LocalizedString + /** + * API providers + */ + llmConfig_groupApi: () => LocalizedString + /** + * Hosted models authenticated with an API key. + */ + llmConfig_groupApiDescription: () => LocalizedString + /** + * Local & custom + */ + llmConfig_groupLocal: () => LocalizedString + /** + * Ollama and OpenAI-compatible endpoints you control. + */ + llmConfig_groupLocalDescription: () => LocalizedString + /** + * {count} configured + */ + llmConfig_summaryConfigured: (arg: { count: number }) => LocalizedString + /** + * {count} ready + */ + llmConfig_summaryReady: (arg: { count: number }) => LocalizedString + /** + * No models configured + */ + llmConfig_emptyTitle: () => LocalizedString + /** + * Add a subscription, API provider, or local runtime to start working. + */ + llmConfig_emptyDescription: () => LocalizedString + /** + * Ready + */ + llmConfig_ready: () => LocalizedString + /** + * Needs attention + */ + llmConfig_needsAttention: () => LocalizedString /** * Edit */ @@ -4206,6 +4368,10 @@ export type TranslationFunctions = { * The selected model does not support reasoning effort. */ llmConfig_reasoningEffortUnsupported: () => LocalizedString + /** + * Auto + */ + chat_reasoningAuto: () => LocalizedString /** * Temperature must be between 0 and 2 */ @@ -4275,11 +4441,11 @@ export type TranslationFunctions = { */ llmConfig_validationRequired: () => LocalizedString /** - * API Key is invalid or expired, please check the channel configuration in settings + * Authentication is invalid or expired. Check the model configuration in settings. */ llmConfig_authError: () => LocalizedString /** - * Selected LLM configuration does not exist + * Selected model configuration does not exist */ llmConfig_notFound: () => LocalizedString /** @@ -4362,6 +4528,58 @@ export type TranslationFunctions = { * Disconnect */ llmConfig_copilotDisconnect: () => LocalizedString + /** + * ChatGPT Plus/Pro + */ + llmConfig_chatgptTitle: () => LocalizedString + /** + * Sign in through your browser to use eligible Codex models from your ChatGPT subscription. Tokens are encrypted locally. + */ + llmConfig_chatgptDescription: () => LocalizedString + /** + * Browser login + */ + llmConfig_chatgptBrowserLogin: () => LocalizedString + /** + * Open the official OpenAI sign-in page and choose your ChatGPT workspace. + */ + llmConfig_chatgptBrowserLoginHint: () => LocalizedString + /** + * Sign in + */ + llmConfig_chatgptOpenBrowser: () => LocalizedString + /** + * Complete connection + */ + llmConfig_chatgptComplete: () => LocalizedString + /** + * After the browser confirms success, return here and save the connection. + */ + llmConfig_chatgptCompleteHint: () => LocalizedString + /** + * Start browser login first. Workspace Agent listens only on localhost for the callback. + */ + llmConfig_chatgptWaitingHint: () => LocalizedString + /** + * Reopen + */ + llmConfig_chatgptReopen: () => LocalizedString + /** + * Finish + */ + llmConfig_chatgptConnect: () => LocalizedString + /** + * ChatGPT subscription connected + */ + llmConfig_chatgptConnectedTitle: () => LocalizedString + /** + * Workspace Agent refreshes the encrypted access token automatically. Disconnect to remove the saved login. + */ + llmConfig_chatgptConnectedDescription: () => LocalizedString + /** + * Disconnect + */ + llmConfig_chatgptDisconnect: () => LocalizedString /** * MCP Servers */ @@ -5663,7 +5881,7 @@ export type TranslationFunctions = { */ preview_emptySheet: () => LocalizedString /** - * Select an element in a slide to add it to the local editing prompt + * Select a slide element to describe a nearby edit */ preview_selectPptxElement: () => LocalizedString /** diff --git a/src/renderer/i18n/ja/index.ts b/src/renderer/i18n/ja/index.ts index 43bc90e..16cad18 100644 --- a/src/renderer/i18n/ja/index.ts +++ b/src/renderer/i18n/ja/index.ts @@ -244,8 +244,25 @@ const ja: Translation = { task_failed: "失敗", task_undo: "元に戻す", - llmConfig_title: "LLMチャネル設定", - llmConfig_add: "設定を追加", + llmConfig_title: "モデル設定", + llmConfig_description: + "サブスクリプション、APIプロバイダー、ローカルランタイムを一元管理します。接続状態と生成設定はモデルごとに保存されます。", + llmConfig_add: "モデルを追加", + llmConfig_groupSubscriptions: "サブスクリプション", + llmConfig_groupSubscriptionsDescription: + "既存のAIサブスクリプションをブラウザまたはデバイス認証で接続します。", + llmConfig_groupApi: "APIプロバイダー", + llmConfig_groupApiDescription: "APIキーでホスト型モデルに接続します。", + llmConfig_groupLocal: "ローカルとカスタム", + llmConfig_groupLocalDescription: + "Ollamaまたは管理下のOpenAI互換エンドポイントに接続します。", + llmConfig_summaryConfigured: "{count} 件設定済み", + llmConfig_summaryReady: "{count} 件利用可能", + llmConfig_emptyTitle: "モデルが設定されていません", + llmConfig_emptyDescription: + "サブスクリプション、APIプロバイダー、またはローカルランタイムを追加してください。", + llmConfig_ready: "利用可能", + llmConfig_needsAttention: "要確認", llmConfig_edit: "編集", llmConfig_delete: "削除", llmConfig_name: "表示名", @@ -268,6 +285,7 @@ const ja: Translation = { llmConfig_reasoningEffortXHigh: "超高", llmConfig_reasoningEffortUnsupported: "選択したモデルは推論強度の設定に対応していません。", + chat_reasoningAuto: "自動", llmConfig_temperatureInvalid: "Temperature は 0 から 2 の範囲で入力してください", llmConfig_topPInvalid: "Top P は 0 から 1 の範囲で入力してください", @@ -291,8 +309,8 @@ const ja: Translation = { "デフォルト設定を少なくとも1つ保持する必要があります", llmConfig_validationRequired: "この項目は必須です", llmConfig_authError: - "APIキーが無効または期限切れです。設定でチャネル構成を確認してください", - llmConfig_notFound: "選択されたLLM設定が存在しません", + "認証が無効または期限切れです。設定でモデル構成を確認してください", + llmConfig_notFound: "選択されたモデル設定が存在しません", llmConfig_save: "保存", llmConfig_cancel: "キャンセル", llmConfig_copilotTitle: "GitHub Copilot 認証", @@ -317,6 +335,24 @@ const ja: Translation = { llmConfig_copilotConnectedDescription: "切断すると保存済みの Copilot トークンを消去します。あとでこのチャネルを再認証できます。", llmConfig_copilotDisconnect: "切断", + llmConfig_chatgptTitle: "ChatGPT Plus/Pro", + llmConfig_chatgptDescription: + "ブラウザでChatGPTサブスクリプションにログインし、対象のCodexモデルを使用します。トークンはローカルで暗号化されます。", + llmConfig_chatgptBrowserLogin: "ブラウザログイン", + llmConfig_chatgptBrowserLoginHint: + "OpenAI公式ログインページを開き、ChatGPTワークスペースを選択します。", + llmConfig_chatgptOpenBrowser: "ログイン", + llmConfig_chatgptComplete: "接続を完了", + llmConfig_chatgptCompleteHint: + "ブラウザで成功が表示されたら、ここに戻って接続を保存します。", + llmConfig_chatgptWaitingHint: + "まずブラウザログインを開始してください。コールバックはlocalhostでのみ受信します。", + llmConfig_chatgptReopen: "再度開く", + llmConfig_chatgptConnect: "完了", + llmConfig_chatgptConnectedTitle: "ChatGPTサブスクリプション接続済み", + llmConfig_chatgptConnectedDescription: + "暗号化されたアクセストークンは自動更新されます。切断すると保存済みログインが削除されます。", + llmConfig_chatgptDisconnect: "切断", // MCP Configuration mcpConfig_title: "MCP サーバー", diff --git a/src/renderer/i18n/zh-CN/index.ts b/src/renderer/i18n/zh-CN/index.ts index a5da7c7..d91f94f 100644 --- a/src/renderer/i18n/zh-CN/index.ts +++ b/src/renderer/i18n/zh-CN/index.ts @@ -240,8 +240,24 @@ const zhCN: Translation = { task_failed: "失败", task_undo: "撤销", - llmConfig_title: "LLM 渠道配置", - llmConfig_add: "添加配置", + llmConfig_title: "模型配置", + llmConfig_description: + "统一管理订阅登录、API 服务和本地运行时。每个模型独立保存连接状态与生成参数。", + llmConfig_add: "添加模型", + llmConfig_groupSubscriptions: "订阅登录", + llmConfig_groupSubscriptionsDescription: + "使用已有 AI 订阅,通过浏览器或设备授权连接。", + llmConfig_groupApi: "API 服务", + llmConfig_groupApiDescription: "使用 API Key 连接托管模型服务。", + llmConfig_groupLocal: "本地与自定义", + llmConfig_groupLocalDescription: + "连接 Ollama 或你自行管理的 OpenAI 兼容端点。", + llmConfig_summaryConfigured: "已配置 {count} 个", + llmConfig_summaryReady: "{count} 个可用", + llmConfig_emptyTitle: "还没有模型配置", + llmConfig_emptyDescription: "添加订阅、API 服务或本地运行时后即可开始工作。", + llmConfig_ready: "可用", + llmConfig_needsAttention: "需要处理", llmConfig_edit: "编辑", llmConfig_delete: "删除", llmConfig_name: "显示名称", @@ -263,6 +279,7 @@ const zhCN: Translation = { llmConfig_reasoningEffortHigh: "高", llmConfig_reasoningEffortXHigh: "超高", llmConfig_reasoningEffortUnsupported: "当前模型不支持推理强度设置。", + chat_reasoningAuto: "自动", llmConfig_temperatureInvalid: "Temperature 必须在 0 到 2 之间", llmConfig_topPInvalid: "Top P 必须在 0 到 1 之间", llmConfig_maxOutputTokensInvalid: "最大输出 tokens 必须是正整数", @@ -282,8 +299,8 @@ const zhCN: Translation = { llmConfig_deleteConfirm: "确定要删除此配置吗?", llmConfig_deleteLastError: "至少保留一条默认配置", llmConfig_validationRequired: "此字段为必填项", - llmConfig_authError: "API Key 无效或已过期,请在设置中检查该渠道配置", - llmConfig_notFound: "所选 LLM 配置不存在", + llmConfig_authError: "认证无效或已过期,请在设置中检查模型配置", + llmConfig_notFound: "所选模型配置不存在", llmConfig_save: "保存", llmConfig_cancel: "取消", llmConfig_copilotTitle: "GitHub Copilot 授权", @@ -306,6 +323,23 @@ const zhCN: Translation = { llmConfig_copilotConnectedDescription: "断开后会清除已保存的 Copilot 令牌,之后可以在此重新授权连接。", llmConfig_copilotDisconnect: "断开连接", + llmConfig_chatgptTitle: "ChatGPT Plus/Pro", + llmConfig_chatgptDescription: + "通过浏览器登录 ChatGPT 订阅,使用套餐支持的 Codex 模型。令牌仅在本地加密保存。", + llmConfig_chatgptBrowserLogin: "浏览器登录", + llmConfig_chatgptBrowserLoginHint: + "打开 OpenAI 官方登录页,并选择要使用的 ChatGPT 工作区。", + llmConfig_chatgptOpenBrowser: "登录", + llmConfig_chatgptComplete: "完成连接", + llmConfig_chatgptCompleteHint: "浏览器显示登录成功后,返回这里保存连接。", + llmConfig_chatgptWaitingHint: + "请先发起浏览器登录;Workspace Agent 只会在 localhost 接收回调。", + llmConfig_chatgptReopen: "重新打开", + llmConfig_chatgptConnect: "完成", + llmConfig_chatgptConnectedTitle: "ChatGPT 订阅已连接", + llmConfig_chatgptConnectedDescription: + "Workspace Agent 会自动刷新本地加密的访问令牌;断开连接将清除保存的登录信息。", + llmConfig_chatgptDisconnect: "断开连接", // MCP 配置 mcpConfig_title: "MCP 服务器", diff --git a/src/shared/model-context-window.ts b/src/shared/model-context-window.ts index 2b4debc..45baa4e 100644 --- a/src/shared/model-context-window.ts +++ b/src/shared/model-context-window.ts @@ -41,6 +41,10 @@ const MODEL_LIMITS: [prefix: string, limits: KnownModelLimits][] = [ ["deepseek-reasoner", { contextWindow: 1_000_000, maxOutputTokens: 384_000 }], ["deepseek-coder", { contextWindow: 64_000 }], ["deepseek", { contextWindow: 64_000 }], + ["kimi-k3", { contextWindow: 1_000_000 }], + ["kimi-k2.7-code", { contextWindow: 256_000 }], + ["kimi-k2.6", { contextWindow: 256_000 }], + ["kimi-k2.5", { contextWindow: 256_000 }], ["mimo-v2.5", { contextWindow: 128_000 }], ["mimo", { contextWindow: 128_000 }], ];