Provider: 支持官方 OpenAI Chat Completions - #2
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds an OpenAI Chat Completions–compatible provider path to the workspace so the CLI can select between DeepSeek’s native protocol and OpenAI-compatible /chat/completions endpoints at runtime, while keeping the agent layer provider-neutral.
Changes:
- Added
openai_compatibleprovider implementation inkuncode-core, reusing the existing DeepSeek Chat Completions DTO + SSE streaming parser, and normalizing OpenAI-style responses into the existing domainCompletionResponse. - Introduced
AnyChatClient/AnyChatCompletionModelto abstract runtime provider selection behind the existingCompletionModeltrait. - Extended CLI settings/runtime to support
model.provider,baseUrl,apiKeyEnv, andKUNCODE_MODEL, and updated README configuration guidance.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| README.md | Documents the new provider/config options and updated examples. |
| crates/kuncode-core/src/providers/openai_compatible.rs | New OpenAI-compatible provider client/model + request/response normalization + unit tests. |
| crates/kuncode-core/src/providers/deepseek/protocol.rs | Makes DeepSeek/OpenAI-compatible response parsing more tolerant (e.g., null content, missing fingerprint/usage, missing tool-call index). |
| crates/kuncode-core/src/providers/any_chat.rs | Adds runtime-selected provider client/model wrapper implementing CompletionModel. |
| crates/kuncode-core/src/providers.rs | Exposes the new provider modules. |
| crates/kuncode-core/src/json_utils.rs | Adds null_or_default serde helper for “nullable but semantically required” fields. |
| crates/kuncode-cli/src/settings.rs | Adds provider/baseUrl/apiKeyEnv support and KUNCODE_MODEL override handling. |
| crates/kuncode-cli/src/runtime.rs | Builds the configured provider client and wires AnyChatCompletionModel into the CLI runtime. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
整体方向是合理的:Provider 选择集中在 CLI/runtime,Agent 仍保持 不过建议暂缓合并,先处理配置来源和 Provider 协议边界这两个问题。 1. Provider 连接配置不应该由未信任项目直接控制PR 之前通过 这个 PR 将以下字段加入了当前工作目录下的
该文件属于项目配置,可以随仓库一起提交。当前代码会直接读取其中指定的环境变量,并把它作为 Bearer token 发往其中指定的地址。 例如,项目可以配置 这个过程目前不要求 相关位置: 本地 mock 已确认上述请求链路确实会发生。 长期建议增加用户级全局 Provider Profile。每个 Profile 保存一套完整、可直接使用的默认配置:
项目配置只在显式信任后覆盖 Profile 或模型。推荐优先级为: 未信任项目不能覆盖 如果本 PR 暂时不实现完整的全局配置,至少应保持原来的安全属性:
不能先按当前状态合并,再在后续 PR 修复这个问题;因为风险正是本 PR 新增的配置组合造成的。 2. OpenAI Provider 不应直接复用 DeepSeek wire DTO当前实现让 OpenAI-compatible Provider 复用
相关实现: 字段语义应以 OpenAI Chat Completions 官方文档为准。 架构方向可以参考 rig-core 的 流式响应不需要为 OpenAI 和 DeepSeek 各复制一套状态机。rig-core 使用共享的 OpenAI-compatible chunk,通过泛型 usage 和 Provider Profile 表达差异;DeepSeek 只指定自己的 usage 类型及工具调用行为,复用共享 SSE 和 tool-call assembler: 这里建议借鉴 rig-core 的分层思路,但不完整照搬其泛型复杂度和序列化后 JSON hook。 建议 Kuncode 最终形成的结构普通请求转换流程: 普通响应转换流程: 流式响应流程: 其中 struct StreamingChunk<U> {
choices: Vec<ChunkChoice>,
usage: Option<U>,
}Provider 只需使用自己的 usage: type OpenAiStreamingChunk = StreamingChunk<OpenAiUsage>;
type DeepSeekStreamingChunk = StreamingChunk<DeepSeekUsage>;这两个可以只是 type alias,不需要各自实现完整结构。只有 envelope、tool-call 增量语义或状态机确实出现分歧时,才拆成独立 streaming DTO。 需要保持以下边界:
对应仓库布局可以沿用现有的非 具体是否拆出单独文件应由职责决定,不需要为了控制文件行数而拆分。关键是普通 wire DTO 保持 Provider 独立,而真正共享的流式协议与状态机落到中立模块。 3. 建议拆分实现范围建议拆成两个 PR。 当前 PR:安全且协议正确的官方 OpenAI 支持
后续 PR:全局 Provider Profiles 与广义兼容服务
如果不拆分,也必须在当前 PR 内同时完成信任隔离和协议修正,不能把安全修复留到后续。 其他问题
验证
结论:REQUEST_CHANGES。不需要推倒当前 crate 分层,重点是把 Provider 连接配置移回可信边界,拆开 OpenAI 与 DeepSeek 的普通 wire protocol,并共享真正一致的 Chat Completions 流式管线。 |
f6f23a2 to
b9d1e3d
Compare
|
已按反馈拆分并更新:
全量检查通过:517 passed、2 ignored,fmt/clippy/check/doc 均通过。 |
审查:
|
|
已在 6b1b048 处理第二轮评审: 正确性
Refusal 收敛 — 按建议的四步执行:OpenAI mapper 在普通与流式两条路径都把 refusal 拍平为 小问题 — content-type 守卫下沉到 未在本 PR 处理(说明):
验证:fmt --check / clippy -D warnings / test --workspace(518 passed, 2 ignored)/ doc 均通过。 |
|
追加 bb4994c:推送 6b1b048 后又对该提交做了一轮多智能体交叉审查(5 维度并行找茬、每个发现 2 名独立验证者对抗验证),确认并修复两个遗留问题:
审查中另有 3 个候选问题被对抗验证驳回,不作改动: 验证:fmt --check / clippy -D warnings / test --workspace(520 passed, 2 ignored)/ doc 通过。 |
There was a problem hiding this comment.
概览
新增官方 OpenAI Chat Completions provider,做法是把原本 DeepSeek 专属的 SSE 流式模块提升为共享的 chat_completions::streaming(StreamChunk<U> + U: Into<Usage> 泛型化),两个 provider 各自保留独立的非流式 wire DTO,再用 AnyChatCompletionModel 枚举在 Agent 层保持 provider-neutral。配置侧新增 model.provider,并把 DEEPSEEK_MODEL 改为按 provider 门控、引入通用的 KUNCODE_MODEL。
整体质量很高:抽象切分点选得准(DeepSeek 侧行为零变化,测试用 TestUsage 别名钉住),注释一致地解释「为什么」而不是「是什么」,安全边界收敛(固定 endpoint + 固定凭据变量 + deny_unknown_fields)确实堵住了未信任项目组合 apiKeyEnv/baseUrl 的外泄路径。下面是我认为值得处理的点。
需要处理
1. 压缩摘要路径在 OpenAI 推理模型上会被拒绝
crates/kuncode-agent/src/compaction/summary/summarizer/attempt.rs:46 固定发送 .temperature(Some(0.0)),新 mapper 会把它原样映射到 temperature 字段。OpenAI 的 o 系列 / gpt-5 系列推理模型只接受默认值,其他值返回 400 unsupported_value。RetryModel 不重试 4xx,因此 provider: openai + 推理模型 + compaction.mode: enabled 的项目每次摘要都会失败并降级。
同一处还发 tool_choice: ToolChoice::None,而 tools 因 skip_serializing_if = "Vec::is_empty" 被整个省略——「有 tool_choice 无 tools」在 OpenAI 侧同样会被拒(这一条我无法离线确认,建议用真实 key 打一次验证)。
DeepSeek 对这两种组合都宽容,所以本地测试跑不出来。建议在 OpenAiCompletionRequest::try_from 里对这两个参数做兼容处理(或至少在 README 标注 OpenAI 推理模型 + compaction 的限制)。
2. reasoning_effort 的实现与 PR 描述不一致
PR 描述写「正确映射 reasoning_effort: "none"」,但 crates/kuncode-core/src/providers/openai/protocol.rs:250 的 from_domain 把 Off 映射为省略字段。这两者语义不同:省略字段意味着默认思考的模型仍然会思考,而 Off 是显式关闭的请求——摘要路径正是靠 Off 省 token 的。gpt-5.1 起 Chat Completions 已接受 "none",因此同一处注释里「reasoning models reject "none"」对当前一代模型也已经不准确。
请要么改代码要么改描述+注释,目前三者互相矛盾。
3. 默认 maxTokens 由 32768 降到 16384 是对既有 DeepSeek 用户的破坏性变更
crates/kuncode-cli/src/settings.rs:530 的 CONSERVATIVE_DEFAULT_MAX_TOKENS 同时作用于「非 DeepSeek provider」和「未内置的 DeepSeek 模型 id」。后者是既有用户:任何 model.name 不在内置档案里、且显式写过 reservedOutput: 32768 的配置,升级后会直接加载失败。
commit message 里写了,max_tokens_note 也做了很好的错误引导,但 README 的「补充说明」只加了 contextLimit 那一条,没提这个默认值变化。建议补一行。
建议
4. 测试覆盖缺口
新增测试质量不错(每条都带上下文注释说明在防什么),但漏了几处最容易出错的:
any_chat.rs零测试:枚举分发、DeepSeek 分支的raw_response再序列化都没有断言。- OpenAI 出站消息扁平化
From<message::Message> for Vec<Message>(openai/protocol.rs:39)没有测试,而 DeepSeek 的对应转换有三个(deepseek/protocol.rs:756-856)。这里有几条不显然的行为:tool result 排在 user 文本之前、多 text block 用\njoin、纯 tool-call turn 序列化出content: ""。 ToolDefinition/ToolChoice的线格式没有断言。ToolChoice(openai/protocol.rs:299)用untagged包一个 adjacently-tagged 内层枚举来产出{"type":"function","function":{"name":...}},完全靠 serde 属性推导,值得一条测试钉死。
5. normalize_response 的注释不准确
crates/kuncode-core/src/providers/openai.rs:156「Deserializes by reference so the projection does not deep-copy the body」——从 &Value 反序列化仍会为每个字符串字段分配新 String,峰值内存约为 body 的两倍;它避免的只是先 clone 一次 raw。措辞可以调整。
细节
settings.rs:323里 provider 名是硬编码字符串"openai"。给ProviderKind加Display/as_str后格式化,加第三个 provider 时不会漏改。openai.rs:21的三个 timeout 常量与 DeepSeek 完全相同,但少了后者解释「为什么流式不设总超时」的那几段注释。既然已经有chat_completions共享模块,这三个常量可以提上去。- 流式与非流式的 refusal 投影其实不完全一致:非流式在
content和refusal都非空时产出两个Textblock,流式合并成一个。ChunkDelta.refusal的注释写的是「matching the non-streaming projection」,严格说不成立。实际上 OpenAI 不会同时发两者,但摘要路径有response.choice.len() != 1的断言,值得留意。 runtime.rs把provider_client()提前到了assemble早期,凭据缺失现在会在权限解析和会话存储打开之前失败。这是更好的 fail-fast,但属于未在描述中提及的行为顺序变化。- README 示例用了占位符
"name": "your-openai-model",写一个真实模型 id 对用户更有用。 resolve_settings里validate_log_level(...)?;后面删掉了一个空行,是与本 PR 无关的格式改动。
安全
安全边界的设计是这个 PR 最值得肯定的部分,方向正确:
- 两个 provider 都是固定 endpoint + 固定凭据环境变量,项目文件只能二选一,拿不到「指定凭据来源 + 指定发送目标」的组合。
ModelSection的deny_unknown_fields意味着未来有人手写baseUrl/apiKeyEnv会响亮失败而不是被静默忽略。DEEPSEEK_MODEL的 provider 门控(含 warn 日志)堵住了 shell rc 残留 export 把 DeepSeek 模型名发往api.openai.com的路径,且有对应测试。
我确认了 CompletionRequest::additional_params 在 CLI/Agent 侧没有任何来自配置的写入点,所以 OpenAI mapper 里「caller keys 覆盖 model 等字段」的合并路径目前不可从项目文件触达。
结论
架构方向和边界收敛都对,代码风格与仓库现有约定高度一致。合并前建议至少处理 #1(摘要路径的 temperature/tool_choice)、#2(reasoning_effort 的三方不一致) 和 #3(README 补默认值变更)——前两条是真实运行时会碰到的兼容问题,且当前测试套件(无真实 OpenAI 调用)覆盖不到。#4 的出站消息映射测试建议一并补上,那是这次改动里唯一没有测试保护的核心转换。
评审第二轮反馈: - response_format 的 strict 改为 false:schemars 生成的 schema(可选 字段不在 required、根节点 $schema、整数 format)不满足 strict 子集, openai + compaction 下每次摘要调用都会 400 - ReasoningEffort::Off 改为省略 reasoning_effort 字段:非 reasoning 模型拒绝该参数、reasoning 模型拒绝 "none",省略是唯一通吃的拼法, 与 DeepSeek mapper 对 Off 的处理一致 - provider=openai 时要求显式模型名,不再把 deepseek-v4-pro 发往 api.openai.com;无能力档案的模型 maxTokens 保守默认 16384 - 回滚 92cc008 为共享 DTO 引入的 DeepSeek 契约放松(system_fingerprint /usage/ToolCall.index/Assistant.content),OpenAI 已有独立 DTO - 收敛 Refusal:移除 AssistantContent::Refusal / StreamEvent:: RefusalDelta / StoredAssistantContent::Refusal,OpenAI mapper 在 普通与流式路径都把 refusal 拍平为 Text,原始字段保留在 raw_response - content-type 守卫下沉到 chat_completions::streaming 并同样作用于 DeepSeek 流式路径;normalize_response 去掉整树深拷贝;max_tokens 超出 u32 报 RequestError 而非截断 - 补测:reasoning_effort 省略、strict=false、超界 max_tokens、refusal 拍平(普通/流式)、tool-call 映射、normalize_response 原样透传、 content-type 守卫、openai 缺模型名报错、保守 maxTokens 默认 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- DEEPSEEK_MODEL 是多 provider 之前的兼容变量,shell rc 里的残留 export 此前会无视 provider 覆盖模型名——provider=openai 时既会把 DeepSeek 模型名发往 api.openai.com,也会绕过显式模型名守卫。现在 KUNCODE_MODEL 保持跨 provider 通用,DEEPSEEK_MODEL 仅在 DeepSeek provider 下生效,其余情况忽略并打 warn 日志 - reservedOutput 与 maxTokens 不等的报错在 maxTokens 取自"无能力 档案默认值"时注明来源与改法:该默认值本次从 32768 降为 16384, 按旧默认显式写过 reservedOutput 的配置升级后会在此报错 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
按 review 收敛推理模型 temperature、reasoning_effort 和空工具选择的线协议行为。补齐 provider 分发、消息映射与序列化测试,并记录 token 默认值升级说明。
147f7be to
49937af
Compare
boluochoufeng
left a comment
There was a problem hiding this comment.
重新审查了最新 head 49937af。上一轮要求处理的 temperature / 空工具 tool_choice、reasoning_effort: "none"、默认 token 预算文档和协议测试都已修复;OpenAI/DeepSeek 独立普通 DTO、共享泛型流式 assembler、固定 endpoint/凭据变量的整体架构已经达到可合并水平。
合并前还需要处理 1 个用户可见问题:OpenAI API Key 缺失时,CLI 实际错误信息没有显示 OPENAI_API_KEY,具体见行内评论。这是 README 配置后的典型首启失败路径,而且修复范围很小,建议在本 PR 内完成。
非阻塞文档项:PR 描述称两个 Provider 的 raw_response 都是“服务端原始 JSON”,但当前实现中 OpenAI 原样保留,DeepSeek 是 typed DTO 重新序列化并会丢弃未建模字段;请同步修正描述,避免对外承诺与代码契约不一致。
本地验证:fmt、clippy -D warnings、check、全量测试和 cargo doc 均通过;652 passed、4 ignored、0 failed。GitHub 当前没有配置 CI checks。
结论:REQUEST_CHANGES。修复 API Key 错误上下文后即可重新确认。
| Client(#[from] reqwest::Error), | ||
| /// `OPENAI_API_KEY` was missing or invalid Unicode. | ||
| #[error("environment variable `OPENAI_API_KEY` is not set or is invalid")] | ||
| EnvironmentVariable(#[source] VarError), |
There was a problem hiding this comment.
这里建议像 DeepSeekClient 一样把环境变量名保存在错误 variant 中,或者在 CLI 边界补充上下文。当前 main 返回 Result<(), Box>,进程终止时打印的是错误的 Debug;我用 README 的 OpenAI 配置实测,未设置 Key 时只得到:
Error: EnvironmentVariable(NotPresent)
用户看不到缺少的是 OPENAI_API_KEY。相同场景下 DeepSeek 会输出包含 DEEPSEEK_API_KEY 的错误。请同时补一条实际错误格式测试,钉住这个首启失败路径。
boluochoufeng
left a comment
There was a problem hiding this comment.
复审通过。上一轮阻塞项已在 7a9d0b0 修复:OpenAI 凭据错误现在保留并展示 OPENAI_API_KEY,且有对应错误格式测试。完整执行 fmt、clippy -D warnings、check、全量测试和 cargo doc 均通过;未发现新的阻塞问题。
概要
AnyChatCompletionModel保持 Agent 层 Provider-neutralmax_completion_tokens、reasoning_effort: "none"和 strict JSON SchemaDEEPSEEK_API_KEY;OpenAI 固定使用官方 endpoint 与OPENAI_API_KEY安全边界
本 PR 只支持两个官方连接配置。项目只能选择
deepseek或openai协议与模型,不再能够指定凭据来源或发送目标,因此未信任项目无法组合apiKeyEnv与外部baseUrl导出用户环境变量。用户级 Provider Profiles、可信项目覆盖、自定义 endpoint 和 headers 将在后续独立 PR 中实现。
协议边界
raw_response对两个 Provider 都归一为服务端原始 JSON。验证
cargo fmt --all -- --checkcargo clippy --workspace --all-targets -- -D warningscargo check --workspace --all-targetscargo test --workspace(517 passed,2 个真实 DeepSeek API 测试 ignored)cargo doc --workspace --no-deps