Skip to content

Provider: 支持官方 OpenAI Chat Completions - #2

Merged
boluochoufeng merged 8 commits into
boluochoufeng:mainfrom
wen227:feat/openai-compatible-provider
Jul 31, 2026
Merged

Provider: 支持官方 OpenAI Chat Completions#2
boluochoufeng merged 8 commits into
boluochoufeng:mainfrom
wen227:feat/openai-compatible-provider

Conversation

@wen227

@wen227 wen227 commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

概要

  • 新增官方 OpenAI Chat Completions Provider,并通过 AnyChatCompletionModel 保持 Agent 层 Provider-neutral
  • OpenAI 与 DeepSeek 分别拥有独立的普通 request/response wire DTO
  • 抽取共享的 Chat Completions SSE、usage 泛型和 tool-call assembler
  • OpenAI 正确映射 max_completion_tokensreasoning_effort: "none" 和 strict JSON Schema
  • 普通及流式响应均保留 refusal 语义,并支持会话持久化
  • DeepSeek 固定使用官方 endpoint 与 DEEPSEEK_API_KEY;OpenAI 固定使用官方 endpoint 与 OPENAI_API_KEY
  • 项目配置不再控制 endpoint、凭据环境变量或自定义 headers

安全边界

本 PR 只支持两个官方连接配置。项目只能选择 deepseekopenai 协议与模型,不再能够指定凭据来源或发送目标,因此未信任项目无法组合 apiKeyEnv 与外部 baseUrl 导出用户环境变量。

用户级 Provider Profiles、可信项目覆盖、自定义 endpoint 和 headers 将在后续独立 PR 中实现。

协议边界

CompletionRequest
  ├─ OpenAI mapper   -> OpenAI wire DTO
  └─ DeepSeek mapper -> DeepSeek wire DTO

OpenAI / DeepSeek SSE
  -> shared StreamingChunk<U>
  -> shared stream assembler
  -> CompletionEvent

raw_response 对两个 Provider 都归一为服务端原始 JSON。

验证

  • cargo fmt --all -- --check
  • cargo clippy --workspace --all-targets -- -D warnings
  • cargo check --workspace --all-targets
  • cargo test --workspace(517 passed,2 个真实 DeepSeek API 测试 ignored)
  • cargo doc --workspace --no-deps

Copilot AI review requested due to automatic review settings July 21, 2026 14:58

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_compatible provider implementation in kuncode-core, reusing the existing DeepSeek Chat Completions DTO + SSE streaming parser, and normalizing OpenAI-style responses into the existing domain CompletionResponse.
  • Introduced AnyChatClient / AnyChatCompletionModel to abstract runtime provider selection behind the existing CompletionModel trait.
  • Extended CLI settings/runtime to support model.provider, baseUrl, apiKeyEnv, and KUNCODE_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.

Comment thread crates/kuncode-cli/src/settings.rs Outdated
@boluochoufeng

Copy link
Copy Markdown
Owner

整体方向是合理的:Provider 选择集中在 CLI/runtime,Agent 仍保持 CompletionModel 中立,依赖方向符合 cli → agent → core。对于当前 Provider 数量,AnyChatCompletionModel 也是务实且类型安全的方案。

不过建议暂缓合并,先处理配置来源和 Provider 协议边界这两个问题。

1. Provider 连接配置不应该由未信任项目直接控制

PR 之前通过 DeepSeekClient::from_env() 固定读取 DEEPSEEK_API_KEY,并发送到固定的 DeepSeek endpoint。仓库无法决定读取哪个环境变量,也无法改变凭据发送目标。

这个 PR 将以下字段加入了当前工作目录下的 .kuncode/settings.json

  • provider
  • baseUrl
  • apiKeyEnv

该文件属于项目配置,可以随仓库一起提交。当前代码会直接读取其中指定的环境变量,并把它作为 Bearer token 发往其中指定的地址。

例如,项目可以配置 apiKeyEnv: "GITHUB_TOKEN" 和一个外部 baseUrl。用户进入该目录运行 Kuncode 后,程序会读取用户进程中的 GITHUB_TOKEN,将其放入 Authorization 请求头,同时把提示词和会话上下文发送到项目指定的服务器。

这个过程目前不要求 --trust-project。现有信任判断只限制了项目权限规则,没有限制 Provider、endpoint 和凭据来源。

相关位置:

本地 mock 已确认上述请求链路确实会发生。

长期建议增加用户级全局 Provider Profile。每个 Profile 保存一套完整、可直接使用的默认配置:

  • Provider 协议
  • endpoint
  • API key 环境变量名
  • 默认模型
  • 默认 token budget
  • 可选的自定义 headers

项目配置只在显式信任后覆盖 Profile 或模型。推荐优先级为:

CLI 参数 > 可信项目配置 > 用户全局配置 > 内置默认值

未信任项目不能覆盖 profileprovidermodelbaseUrlapiKeyEnv 和自定义 headers。

如果本 PR 暂时不实现完整的全局配置,至少应保持原来的安全属性:

  • DeepSeek 固定使用 DeepSeek endpoint 和 DEEPSEEK_API_KEY
  • 官方 OpenAI 固定使用 OpenAI endpoint 和 OPENAI_API_KEY
  • 项目级自定义 endpoint 或凭据来源必须经过 --trust-project
  • 信任检查必须发生在读取环境变量之前。

不能先按当前状态合并,再在后续 PR 修复这个问题;因为风险正是本 PR 新增的配置组合造成的。

2. OpenAI Provider 不应直接复用 DeepSeek wire DTO

当前实现让 OpenAI-compatible Provider 复用 DeepSeekCompletionRequest 和 DeepSeek 响应类型。但二者只是 JSON 结构相似,线协议语义并不完全一致,已经产生以下问题:

  • 实际发送 max_tokens,而不是官方 OpenAI reasoning/o-series 使用的 max_completion_tokens
  • ReasoningEffort::Off 被映射为省略字段,而不是显式的 reasoning_effort: "none"
  • output_schema 被降级为 json_object,实际 schema 和 strict 约束被丢弃。
  • 合法的 OpenAI refusal 响应没有被保留,可能最终变成“没有 assistant content”的 Provider 错误。

相关实现:

字段语义应以 OpenAI Chat Completions 官方文档为准。

架构方向可以参考 rig-core 的 OpenAICompatibleProvider:以 OpenAI Chat Completions 作为基础协议,由 DeepSeek 等 Provider 显式声明能力和方言差异。

流式响应不需要为 OpenAI 和 DeepSeek 各复制一套状态机。rig-core 使用共享的 OpenAI-compatible chunk,通过泛型 usage 和 Provider Profile 表达差异;DeepSeek 只指定自己的 usage 类型及工具调用行为,复用共享 SSE 和 tool-call assembler:

这里建议借鉴 rig-core 的分层思路,但不完整照搬其泛型复杂度和序列化后 JSON hook。

建议 Kuncode 最终形成的结构

kuncode-cli
  UserProviderProfile
          │
          ▼
  trust-aware settings resolution
          │
          ▼
  ResolvedProviderConfig
          │
          ▼
  AnyChatClient / AnyChatCompletionModel

kuncode-core
  CompletionRequest                         Provider-neutral 领域模型
          │
          ├── OpenAiProtocol
          │     ├── OpenAiCompletionRequest
          │     └── OpenAiCompletionResponse
          │
          └── DeepSeekProtocol
                ├── DeepSeekCompletionRequest
                └── DeepSeekCompletionResponse

  ChatCompletionsStreaming                  协议族共享流式层
    ├── StreamingChunk<U>
    ├── ChunkChoice
    ├── ChunkDelta
    ├── ToolCallDelta
    ├── ChatStreamNormalizer
    └── ChatStreamAssembler

  shared transport
    ├── HTTP request execution
    ├── SSE framing and decoding
    ├── status/error-body handling
    └── retryable error classification

普通请求转换流程:

CompletionRequest
        │
        ├── OpenAI mapper ──▶ OpenAI wire request ──▶ OpenAI API
        │
        └── DeepSeek mapper ─▶ DeepSeek wire request ─▶ DeepSeek API

普通响应转换流程:

OpenAI wire response ──▶ OpenAI mapper ──┐
                                         ├──▶ CompletionResponse
DeepSeek wire response ─▶ DeepSeek mapper ┘

流式响应流程:

OpenAI / DeepSeek SSE
          │
          ▼
StreamingChunk<ProviderUsage>
          │
          ▼
ChatStreamNormalizer
          │
          ▼
text / reasoning / refusal / tool-call deltas
          │
          ▼
ChatStreamAssembler
          │
          ▼
CompletionEvent + final usage

其中 StreamingChunk<U> 和 tool-call assembler 可以共享:

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。

需要保持以下边界:

  1. kuncode-agent 只依赖 CompletionModel 和领域消息,不感知具体 Provider。
  2. OpenAI 和 DeepSeek 各自拥有普通请求及完整响应 DTO。
  3. 两个 Provider 都可以依赖共享的 Chat Completions streaming primitives,但不能互相依赖对方的 protocol 模块。
  4. 只共享真正相同的 HTTP、SSE、tool-call 累积和错误处理逻辑。
  5. AnyChatCompletionModel 只负责运行时分派,不负责修正 Provider 协议差异。
  6. 每个 Provider 在自己的 mapper 中完成字段映射、能力检查及响应归一化。
  7. raw_response 应对所有 Provider 保持一致语义:要么统一保留服务端原始 JSON,要么明确只保证可序列化。

对应仓库布局可以沿用现有的非 mod.rs 风格:

providers.rs
providers/
  any_chat.rs
  chat_completions.rs
  chat_completions/
    streaming.rs
  openai_compatible.rs
  openai_compatible/
    protocol.rs
  deepseek.rs
  deepseek/
    protocol.rs

具体是否拆出单独文件应由职责决定,不需要为了控制文件行数而拆分。关键是普通 wire DTO 保持 Provider 独立,而真正共享的流式协议与状态机落到中立模块。

3. 建议拆分实现范围

建议拆成两个 PR。

当前 PR:安全且协议正确的官方 OpenAI 支持

  • AnyChatCompletionModel
  • OpenAI 独立的普通 request/response DTO
  • 共享的 Chat Completions streaming primitives
  • 官方 OpenAI endpoint 和固定 API key 环境变量
  • reasoning、structured output、refusal、streaming 等协议测试
  • 移除或信任隔离项目级 baseUrlapiKeyEnv

后续 PR:全局 Provider Profiles 与广义兼容服务

  • 用户级全局配置
  • Profile 内配置默认模型
  • CLI、全局、可信项目配置的优先级
  • 自定义 endpoint 和 headers
  • Kimi、Qwen、智谱、vLLM 等 capabilities 与 fixture

如果不拆分,也必须在当前 PR 内同时完成信任隔离和协议修正,不能把安全修复留到后续。

其他问题

  • apiKeyEnv 没有 trim,现有 review thread 指出的问题在当前 head 仍存在。
  • 带 query 的完整 endpoint 会因为字符串后缀判断而被错误追加 /chat/completions,建议使用类型化 URL 处理 path 和 query。

验证

  • cargo fmtcargo clippy -D warningscargo checkcargo testcargo doc 均通过。
  • 全量测试:516 passed、2 ignored、0 failed。
  • 真实 CLI + localhost SSE happy path 可以正常工作。
  • 现有测试尚未覆盖未信任项目配置和上述 OpenAI wire contract。

结论:REQUEST_CHANGES。不需要推倒当前 crate 分层,重点是把 Provider 连接配置移回可信边界,拆开 OpenAI 与 DeepSeek 的普通 wire protocol,并共享真正一致的 Chat Completions 流式管线。

@wen227 wen227 changed the title Provider: 支持 OpenAI 兼容接口 Provider/TUI: 支持 OpenAI 兼容接口并优化交互体验 Jul 22, 2026
@wen227
wen227 force-pushed the feat/openai-compatible-provider branch 2 times, most recently from f6f23a2 to b9d1e3d Compare July 22, 2026 12:26
@wen227 wen227 changed the title Provider/TUI: 支持 OpenAI 兼容接口并优化交互体验 Provider: 支持官方 OpenAI Chat Completions Jul 22, 2026
@wen227

wen227 commented Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

已按反馈拆分并更新:

  • 当前 PR 仅保留官方 OpenAI 支持,固定 endpoint 与凭据变量。
  • OpenAI/DeepSeek 普通 wire DTO 已分离,共享中立的流式管线。
  • 已补齐 max_completion_tokens、reasoning_effort: none、strict JSON Schema、普通/流式 refusal 与 raw response 语义测试。
  • TUI 提交已从本 PR 移除。
  • 用户级 Profiles、可信项目覆盖和自定义兼容 endpoint 已拆到 draft PR Provider: 增加用户级 Profiles 与可信项目覆盖 #3

全量检查通过:517 passed、2 ignored,fmt/clippy/check/doc 均通过。

@boluochoufeng

boluochoufeng commented Jul 23, 2026

Copy link
Copy Markdown
Owner

审查:Provider: 支持官方 OpenAI Chat Completions

整体分层是对的 —— 共享传输、按 provider 拆 DTO、在 CLI 边界用 AnyChatClient 做运行时分发,比"一个 DTO 兼容所有 OpenAI-like 端点"干净得多。安全收窄(去掉 baseUrl / apiKeyEnv / 自定义 header,两个 client 都不 derive Debug)也是正确决定,值得合并。下面是合并前建议处理的问题。

正确性

1. strict: true 会让 OpenAI 对唯一的 output_schema 调用方直接 400(已实证)

openai/protocol.rsResponseFormat::json_schema 硬编码 strict: true。全仓库唯一走到 output_schema 的是 compaction summarizer,传的是 schemars::schema_for!(ContinuitySummary)。我把这个 schema dump 出来了:

  • CommandSummary.exit_code(Option<i32>)不在 required 里 —— OpenAI strict 子集要求 properties 的每个 key 都必须 required(可选字段要写成 nullable 联合类型且仍 required);
  • 根节点带 $schema,整数字段带 format: "int64"/"uint32"/"int32" —— 都不在 strict 支持的关键字集合内。

结论:provider: "openai" + compaction 启用时,每次摘要调用都会 Invalid schema for response_format 报 400。建议 strict 默认 false,或加一层 schema 规范化 + 单测钉住"生成的 schema 满足 strict 子集"。

2. reasoning_effort: "none" 会被大多数 OpenAI 模型拒绝

summarizer/attempt.rs 无条件 .reasoning(Some(ReasoningEffort::Off)),OpenAI mapper 映射成 "reasoning_effort": "none"。但 gpt-4o/4.1 这类非 reasoning 模型根本不接受该参数,o 系列/gpt-5 接受参数但不接受 "none"。对比 DeepSeek mapper:Off 被显式映射成省略该字段 + thinking.disabled。OpenAI 侧应同样把 Off 映射为 None。同一调用还带 temperature: 0.0(reasoning 模型只接受 1),OpenAI mapper 没有 DeepSeek 那样的 sampling 参数守卫。

1 和 2 叠加:compaction(shadow/enabled)在 OpenAI 下基本不可用,且这条路径完全没有测试覆盖。 这是本 PR 最建议先修的部分。

3. provider: "openai" 缺省会把 deepseek-v4-pro 发给 OpenAI

ModelSection 有容器级 #[serde(default)],{ "model": { "provider": "openai" } } 能解析,然后把 deepseek-v4-pro 发到 api.openai.com;同时 resolve_settings 对非 DeepSeek provider 强制 profile = None,max_tokens 静默取 32768,超过多数 OpenAI 模型输出上限,又一个 400。建议:provider != deepseek 时要求显式 name,并给非内置模型更保守的 maxTokens 默认。

4. DeepSeek 响应契约被无故放松

system_fingerprint String→OptionusagedefaultToolCall.indexdefaultMessage::Assistant.contentnull_or_default —— 这些是在 92cc008(DeepSeek DTO 曾被 OpenAI 兼容层复用那版)引入的。最后一版已给 OpenAI 独立 DTO,这些放松现在只削弱 DeepSeek 自身的严格性,而现存测试恰恰在保护它("a usage object missing one is malformed and must fail")。建议回滚,或保留并补注释/测试说明各自防的是哪种真实 DeepSeek 响应。

设计:Refusal 应放在哪一层

refusal 作为独立字段是 OpenAI 私有的 wire 概念(随 Structured Outputs 引入,为了让拒绝语不被当成符合 schema 的 JSON 解析)。其他 provider 不用内容字段表达拒绝,而是用终止原因:OpenAI message.refusal / Anthropic stop_reason: "refusal" / Gemini finishReason: SAFETY;DeepSeek 兼容 schema 名义上有该字段,实际不填充。

本 PR 把它做成了 AssistantContent::Refusal 领域内容变体,但运行时到处又把它拍平成文本(RefusalDeltaEventKind::TextDelta;assistant_text() 并入普通文本;DeepSeek replay mapper 拍平进 text_content)。结果是:付了领域变体的全部成本(每个 AssistantContentmatch 加 arm、StoredAssistantContent 多一个 tag、公共 API 多一个变体),却在除 OpenAI 回放外的每个消费点都主动丢弃了这个区分。

判断该不该进领域模型,承重的判据是 "Agent 现在是否要基于它做决策"(而不是"是否人人都有")。目前没有任何 agent 逻辑基于"这是不是拒绝"分支。据此建议:

  1. OpenAI mapper 内部把 refusal 拍平成 AssistantContent::Text 这一步是被迫的 —— NonEmptyVec 不能空、用户要看到拒绝语、要持久化/回放,绕不过去;
  2. 移除 AssistantContent::Refusal 及其全部 match / StoredAssistantContent::Refusal 改动;
  3. 暂不引入 FinishReason::Refusal 原始 refusal 字段完整躺在 raw_response(OpenAI 的 Response 关联类型是 Value),信息没丢,是逃生口;
  4. 将来真有消费方(retry 预算 / 停 loop / 差异化 UX)再加,且加在 FinishReason(中立的终止原因,与已有的 ContentFilter 同层,对齐 Anthropic/Gemini 的建模)而不是 AssistantContent

一个连带项:本 PR 把 refusal 放进了共享的 chat_completions/streaming.rsChunkDelta(DeepSeek 也走这个 assembler)。收敛后,共享 assembler 应把 refusal 当成 text delta 吐出(provider-agnostic,与拍平成 Text 一致),非流式和流式给同一个答案。

核心:领域模型是"Agent 的语义",不是"provider 的并集",更不是"某一家的 wire 形状"。当前的 AssistantContent::Refusal形状错(内容块 vs 终止原因)且时机早(无消费方),两头都不占。

测试覆盖

972 行新增只配了 5 个测试,偏薄。缺:openai.rs 完全无测(validate_stream_content_type、非 2xx、normalize_response);AnyChatCompletionModel 无测(含 DeepSeek 分支 raw_response 重序列化行为);OpenAI tool-call 映射无测;README 新写的"非内置模型压缩需显式 contextLimit"无测;KUNCODE_MODEL > DEEPSEEK_MODEL 的优先级无测。

小问题

  • AnyChatCompletionModel 的 DeepSeek 分支 serde_json::to_value(raw_response) 只回写已建模字段,和 PR 描述"归一为服务端原始 JSON"不符;且 raw_response 目前无任何生产消费方。
  • normalize_response 走了三遍(bytes→Value→clone→from_value),每响应深拷贝一次;response.id 在手边却 message_id: None
  • Content-Type 校验只在 OpenAI 路径有,DeepSeek stream() 没有;SSE 解码器已共享,守卫也该下沉到 chat_completions::streaming
  • StreamAssembler::finish 文档仍写"text, tool calls, reasoning",漏了末尾的 refusal(若按上面收敛,这段连带移除)。
  • max_completion_tokens: ... as u32:core 是公开 API,u32::try_from + RequestError 更诚实(DeepSeek 同样,属既存)。
  • KUNCODE_MODEL 无配套 provider 环境变量:单独 export 会把 OpenAI 模型名发给 DeepSeek。

结论

分层和安全收窄做得好,值得合并。但 compaction + OpenAI 这条路径目前是坏的(问题 1、2 各自独立地会 400),正因没有测试才没被发现。Refusal 建议收敛为:OpenAI mapper 内拍平成 Text、移除领域内容变体,FinishReason::Refusal 暂不加(留作将来有消费方时的正确落点)。建议至少先修 reasoning_effort 映射、strict 标志,补 openai.rs 单测。

@wen227

wen227 commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

已在 6b1b048 处理第二轮评审:

正确性

  1. strictjson_schema 改为 strict: false 并加注释与测试钉住:schemars 输出不满足 strict 子集,strict 模式下 compaction 摘要必 400;非 strict 仍以 schema 引导生成,调用方自行校验解析结果。
  2. reasoning_effortOff 改为省略字段(移除 wire 枚举的 None 变体),与 DeepSeek mapper 对 Off 的处理一致;补"Off 不产生字段"测试。
  3. 缺省模型provider: "openai" 未显式给 name 时直接报错,不再把 deepseek-v4-pro 发往 api.openai.com;无能力档案的模型(非内置 DeepSeek id 或非 DeepSeek provider)maxTokens 保守默认 16384。均有测试。
  4. DeepSeek 契约92cc008 引入的四处放松(system_fingerprint Option 化、usage default、ToolCall.index default、Assistant.content null_or_default)全部回滚,content 补注释说明 DeepSeek 纯工具轮也发空串。

Refusal 收敛 — 按建议的四步执行:OpenAI mapper 在普通与流式两条路径都把 refusal 拍平为 AssistantContent::Text(共享 assembler 把 refusal 增量当 text delta 吐出,流式与非流式同答案);移除 AssistantContent::RefusalStreamEvent::RefusalDeltaStoredAssistantContent::Refusal 及全部 match 臂;未引入 FinishReason::Refusal;原始 refusal 字段完整保留在 raw_response

小问题 — content-type 守卫下沉到 chat_completions::streaming 并同样作用于 DeepSeek 流式路径(补测试);normalize_response 改为按引用反序列化,不再整树深拷贝,并补"raw_response 为服务端原始 JSON、未建模字段保留"的测试;max_tokens 超 u32 报 RequestError;AnyChatCompletionModel 补文档明确两分支 raw_response 语义差异(OpenAI 原样透传、DeepSeek 为 DTO 重序列化,仅保证可序列化);finish 文档随 refusal 移除自然归正。

未在本 PR 处理(说明):

  • summarizer 的 temperature: 0.0 对 o 系列/gpt-5 仍会 400 —— sampling 守卫需要知道模型是否为 reasoning 模型,名字启发式太脆,这属于 PR Provider: 增加用户级 Profiles 与可信项目覆盖 #3 Provider Profile 的能力声明范畴;修掉 strict 与 reasoning_effort 后,compaction + 非 reasoning OpenAI 模型已可用。
  • KUNCODE_MODEL 无配套 provider 环境变量、非 2xx 路径的 HTTP 级测试(需 mock server 基建)、README contextLimit 行为测试,同样留待 profiles PR 一并解决。

验证:fmt --check / clippy -D warnings / test --workspace(518 passed, 2 ignored)/ doc 均通过。

@wen227

wen227 commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

追加 bb4994c:推送 6b1b048 后又对该提交做了一轮多智能体交叉审查(5 维度并行找茬、每个发现 2 名独立验证者对抗验证),确认并修复两个遗留问题:

  1. DEEPSEEK_MODEL 绕过 openai 模型名守卫(P2)6b1b048 的缺名守卫只挡住了配置文件路径:环境变量覆盖优先级最高且不看 provider,shell rc 里残留的 export DEEPSEEK_MODEL=...(这是多 provider 支持之前就存在的兼容变量,老用户很可能一直挂着)会把 DeepSeek 模型名照样发往 api.openai.com,甚至让"缺名报错"完全失效。现在 env 读取拆成两个通道:KUNCODE_MODEL 跨 provider 通用,DEEPSEEK_MODEL 仅当 provider=deepseek 时生效,其余情况忽略并打 warn。补了 openai + 残留 DEEPSEEK_MODEL 组合的三段测试(不改名/不绕守卫/KUNCODE_MODEL 仍通用)。

  2. 16384 新默认的升级路径(P3) — 旧版无档案模型 maxTokens 默认 32768,且 reservedOutput 被强制与其相等;按旧默认显式写过 "reservedOutput": 32768 的配置升级后启动即报错,而报错里的 16384 在用户配置中毫无出处。现在该报错会注明"16384 是无能力档案模型的内置默认,可用 model.maxTokens 覆盖"。

审查中另有 3 个候选问题被对抗验证驳回,不作改动:reasoning_effort: "xhigh" 对 gpt-5.x 系列合法;b9d1e3d 中间提交写入的 refusal 存量行无任何现存读取路径(分支未实现 resume);DeepSeek content 回严的风险面不存在(endpoint 硬编码 api.deepseek.com,兼容端点走独立 DTO)。

验证:fmt --check / clippy -D warnings / test --workspace(520 passed, 2 ignored)/ doc 通过。

@boluochoufeng boluochoufeng left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

概览

新增官方 OpenAI Chat Completions provider,做法是把原本 DeepSeek 专属的 SSE 流式模块提升为共享的 chat_completions::streamingStreamChunk<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_valueRetryModel 不重试 4xx,因此 provider: openai + 推理模型 + compaction.mode: enabled 的项目每次摘要都会失败并降级。

同一处还发 tool_choice: ToolChoice::None,而 toolsskip_serializing_if = "Vec::is_empty" 被整个省略——「有 tool_choicetools」在 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:250from_domainOff 映射为省略字段。这两者语义不同:省略字段意味着默认思考的模型仍然会思考,而 Off 是显式关闭的请求——摘要路径正是靠 Off 省 token 的。gpt-5.1 起 Chat Completions 已接受 "none",因此同一处注释里「reasoning models reject "none"」对当前一代模型也已经不准确。

请要么改代码要么改描述+注释,目前三者互相矛盾。

3. 默认 maxTokens 由 32768 降到 16384 是对既有 DeepSeek 用户的破坏性变更

crates/kuncode-cli/src/settings.rs:530CONSERVATIVE_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 用 \n join、纯 tool-call turn 序列化出 content: ""
  • ToolDefinition / ToolChoice 的线格式没有断言。ToolChoiceopenai/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"。给 ProviderKindDisplay/as_str 后格式化,加第三个 provider 时不会漏改。
  • openai.rs:21 的三个 timeout 常量与 DeepSeek 完全相同,但少了后者解释「为什么流式不设总超时」的那几段注释。既然已经有 chat_completions 共享模块,这三个常量可以提上去。
  • 流式与非流式的 refusal 投影其实不完全一致:非流式在 contentrefusal 都非空时产出两个 Text block,流式合并成一个ChunkDelta.refusal 的注释写的是「matching the non-streaming projection」,严格说不成立。实际上 OpenAI 不会同时发两者,但摘要路径有 response.choice.len() != 1 的断言,值得留意。
  • runtime.rsprovider_client() 提前到了 assemble 早期,凭据缺失现在会在权限解析和会话存储打开之前失败。这是更好的 fail-fast,但属于未在描述中提及的行为顺序变化。
  • README 示例用了占位符 "name": "your-openai-model",写一个真实模型 id 对用户更有用。
  • resolve_settingsvalidate_log_level(...)?; 后面删掉了一个空行,是与本 PR 无关的格式改动。

安全

安全边界的设计是这个 PR 最值得肯定的部分,方向正确:

  • 两个 provider 都是固定 endpoint + 固定凭据环境变量,项目文件只能二选一,拿不到「指定凭据来源 + 指定发送目标」的组合。
  • ModelSectiondeny_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)#2reasoning_effort 的三方不一致)#3(README 补默认值变更)——前两条是真实运行时会碰到的兼容问题,且当前测试套件(无真实 OpenAI 调用)覆盖不到。#4 的出站消息映射测试建议一并补上,那是这次改动里唯一没有测试保护的核心转换。

wen227 and others added 7 commits July 31, 2026 11:30
评审第二轮反馈:

- 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 默认值升级说明。
@wen227
wen227 force-pushed the feat/openai-compatible-provider branch from 147f7be to 49937af Compare July 31, 2026 03:50

@boluochoufeng boluochoufeng left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

重新审查了最新 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),

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里建议像 DeepSeekClient 一样把环境变量名保存在错误 variant 中,或者在 CLI 边界补充上下文。当前 main 返回 Result<(), Box>,进程终止时打印的是错误的 Debug;我用 README 的 OpenAI 配置实测,未设置 Key 时只得到:

Error: EnvironmentVariable(NotPresent)

用户看不到缺少的是 OPENAI_API_KEY。相同场景下 DeepSeek 会输出包含 DEEPSEEK_API_KEY 的错误。请同时补一条实际错误格式测试,钉住这个首启失败路径。

@boluochoufeng boluochoufeng left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

复审通过。上一轮阻塞项已在 7a9d0b0 修复:OpenAI 凭据错误现在保留并展示 OPENAI_API_KEY,且有对应错误格式测试。完整执行 fmt、clippy -D warnings、check、全量测试和 cargo doc 均通过;未发现新的阻塞问题。

@boluochoufeng
boluochoufeng merged commit 61d2fac into boluochoufeng:main Jul 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants