Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 59 additions & 12 deletions docs/llm-configuration.md
Original file line number Diff line number Diff line change
@@ -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 等需要 |
Expand All @@ -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 配置

Expand Down Expand Up @@ -140,15 +185,15 @@ Copilot 配置的运行时维护规则:

## 高级生成参数

每个 LLM 配置可以设置一组可选的高级生成参数。所有字段留空时都会使用
每个模型配置可以设置一组可选的高级生成参数。所有字段留空时都会使用
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 运行都会复用同一套配置,避免主任务和委派任务的模型行为不一致。
Expand All @@ -160,7 +205,7 @@ provider 或模型默认值:

聊天发送统一从 `ai:executeTask` 进入主进程。运行时选择 LLM 的流程是:

1. 根据用户选择的 `llmConfigId` 找到配置;如果没有传入,则找默认配置。
1. 根据用户选择的 `llmConfigId` 找到模型配置;如果没有传入,则找默认配置。
2. 如果用户显式选择了 `image` / `video` 配置,运行时先检查它是否健康:
- `enabled !== false`
- `lastCheckStatus === "success"`
Expand Down Expand Up @@ -233,13 +278,15 @@ 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` |
| 主聊天任务编排 | `src/main/ipc/ai-handlers.ts` |
| 图像 / 视频媒体运行时 | `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` |
71 changes: 71 additions & 0 deletions src/main/ai/adapters/__tests__/deepseek.test.ts
Original file line number Diff line number Diff line change
@@ -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({});
});
});
82 changes: 81 additions & 1 deletion src/main/ai/adapters/__tests__/openai.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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<typeof fetch>(
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<typeof fetch>()
.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 });
});
});
12 changes: 11 additions & 1 deletion src/main/ai/adapters/__tests__/resolve-adapter-name.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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");
});
});
1 change: 1 addition & 0 deletions src/main/ai/adapters/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type { LanguageModel } from "ai";
export interface ProviderConfig {
provider: string;
apiKey: string;
accountId?: string | null;
resolveApiKey?: (options?: { forceRefresh?: boolean }) => Promise<string>;
baseUrl?: string | null;
apiPath?: string | null;
Expand Down
30 changes: 27 additions & 3 deletions src/main/ai/adapters/deepseek.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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(
Expand Down
3 changes: 3 additions & 0 deletions src/main/ai/adapters/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ const deepseekAdapter = new DeepSeekAdapter();
const adapters: Record<string, ProviderAdapter> = {
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 的解析
Expand Down
Loading
Loading