From 2fac59018b35f4bb3d3f7f86e44310b0603c8b02 Mon Sep 17 00:00:00 2001 From: wenzr <282277167@qq.com> Date: Tue, 21 Jul 2026 22:16:32 +0800 Subject: [PATCH 1/8] =?UTF-8?q?Provider:=E6=94=AF=E6=8C=81OpenAI=E5=85=BC?= =?UTF-8?q?=E5=AE=B9=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 55 +++- crates/kuncode-cli/src/runtime.rs | 52 +++- crates/kuncode-cli/src/settings.rs | 137 ++++++++- crates/kuncode-core/src/json_utils.rs | 11 + crates/kuncode-core/src/providers.rs | 7 +- crates/kuncode-core/src/providers/any_chat.rs | 77 +++++ .../src/providers/deepseek/protocol.rs | 10 +- .../src/providers/openai_compatible.rs | 281 ++++++++++++++++++ 8 files changed, 603 insertions(+), 27 deletions(-) create mode 100644 crates/kuncode-core/src/providers/any_chat.rs create mode 100644 crates/kuncode-core/src/providers/openai_compatible.rs diff --git a/README.md b/README.md index 6aca9aa..4ed800e 100644 --- a/README.md +++ b/README.md @@ -4,26 +4,27 @@ [`learn-claude-code`](https://github.com/shareAI-lab/learn-claude-code) 的 Harness Engineering 思路:模型负责判断下一步做什么,Harness 负责提供工具、上下文、权限边界、持久化和用户界面。 -当前版本为 `0.1.0`,使用 DeepSeek 模型,提供一次性命令行执行和交互式 TUI 两种使用方式。 +当前版本为 `0.1.0`,默认使用 DeepSeek,也支持 OpenAI Chat Completions +兼容协议,提供一次性命令行执行和交互式 TUI 两种使用方式。 ## 工作区结构 ```text -kuncode-cli ──▶ kuncode-agent ──▶ kuncode-core ──▶ DeepSeek API +kuncode-cli ──▶ kuncode-agent ──▶ kuncode-core ──▶ LLM API │ │ │ │ │ └─ 消息、Completion、流式协议、Provider │ └─ Agent Loop、工具、权限、会话、压缩与编排 └─ 参数、配置、审批、一次性输出与 TUI ``` -- `kuncode-core`:Provider-neutral 的消息与 Completion 抽象,以及 DeepSeek Provider。 +- `kuncode-core`:Provider-neutral 的消息与 Completion 抽象,以及 DeepSeek、OpenAI-compatible Provider。 - `kuncode-agent`:Agent 运行时、工具调度、权限、Hook、Todo、会话持久化和上下文压缩。 - `kuncode-cli`:命令行参数、项目配置、终端审批、普通输出和交互式 TUI。 ## 环境要求 - Rust stable,项目使用 Rust 2024 edition。 -- DeepSeek API Key。 +- 所选模型服务的 API Key;本地无鉴权服务可不配置。 - 支持 ANSI 终端;交互模式需要 stdin 和 stdout 都连接到真实终端。 ## 快速开始 @@ -40,6 +41,42 @@ export DEEPSEEK_API_KEY="your-api-key" DEEPSEEK_API_KEY=your-api-key ``` +使用 OpenAI 官方接口时,在 `.kuncode/settings.json` 配置: + +```json +{ + "model": { + "provider": "openai-compatible", + "name": "your-openai-model", + "apiKeyEnv": "OPENAI_API_KEY", + "maxTokens": 16384 + } +} +``` + +并设置对应环境变量: + +```bash +export OPENAI_API_KEY="your-api-key" +``` + +Kimi、Qwen、智谱、vLLM 等提供 OpenAI Chat Completions 兼容协议的服务,增加 +`baseUrl` 即可。它既可以是服务根地址,也可以是完整 endpoint: + +```json +{ + "model": { + "provider": "openai-compatible", + "name": "your-model", + "baseUrl": "http://localhost:8000/v1", + "apiKeyEnv": "", + "maxTokens": 8192 + } +} +``` + +`apiKeyEnv` 为空表示不发送 `Authorization`,适合无鉴权的本地服务。 + 一次性执行任务: ```bash @@ -70,13 +107,15 @@ cargo build --release -p kuncode-cli ```json { "permissions": { - "allow": ["Read", "Bash(cargo *)"], + "allow": ["Read(**)", "Bash(cargo *)"], "ask": ["Edit(.env)"], "deny": ["Bash(curl *)"], "defaultMode": "default" }, "model": { + "provider": "deepseek", "name": "deepseek-v4-pro", + "apiKeyEnv": "DEEPSEEK_API_KEY", "maxTokens": 65536 }, "agent": { @@ -94,8 +133,12 @@ cargo build --release -p kuncode-cli 补充说明: -- `DEEPSEEK_MODEL` 可以覆盖配置文件中的模型名称。 +- `KUNCODE_MODEL` 可以覆盖配置文件中的模型名称;`DEEPSEEK_MODEL` 作为兼容别名保留。 +- `model.provider` 支持 `deepseek` 和 `openai-compatible`。 +- `openai-compatible` 未设置 `baseUrl` 时默认使用 `https://api.openai.com/v1`。 +- `baseUrl` 会自动补全 `/chat/completions`;完整 endpoint 不会重复追加。 - 内置模型配置包括 `deepseek-v4-pro` 和 `deepseek-v4-flash`。 +- 非内置模型启用上下文压缩时,需要显式设置 `compaction.contextLimit`。 - `compaction.mode` 支持 `disabled`、`shadow` 和 `enabled`,默认是 `disabled`。 - `shadow` 只计算和报告压缩候选,不替换当前上下文。 - `enabled` 会在达到预算阈值时执行压缩,并要求会话持久化状态保持健康。 diff --git a/crates/kuncode-cli/src/runtime.rs b/crates/kuncode-cli/src/runtime.rs index bb04f12..8f32659 100644 --- a/crates/kuncode-cli/src/runtime.rs +++ b/crates/kuncode-cli/src/runtime.rs @@ -27,10 +27,14 @@ use kuncode_agent::system_prompt::{ }; use kuncode_agent::workspace::Workspace; use kuncode_core::completion::{CompletionModel, RetryModel, RetryPolicy}; -use kuncode_core::providers::deepseek::{DeepSeekClient, DeepSeekCompletionModel}; +use kuncode_core::providers::{ + any_chat::{AnyChatClient, AnyChatCompletionModel}, + deepseek::DeepSeekClient, + openai_compatible::OpenAiCompatibleClient, +}; use crate::config::{PermissionFlags, resolve_permissions}; -use crate::settings::{ProjectSettings, ProjectTrust, load_project_settings}; +use crate::settings::{ProjectSettings, ProjectTrust, ProviderKind, load_project_settings}; use crate::{Cli, logging::LoggingObserver}; /// Identity and behavioral instructions rendered as the first system-prompt @@ -46,7 +50,7 @@ Keep working until the task is done, then give a short, direct final answer."; /// observer + approver, plus the bits a frontend renders directly /// ([`model_name`](Self::model_name), [`mode`](Self::mode)). Generic over the /// model so a test or a future provider can supply its own `M`; [`assemble`] -/// pins it to the CLI's [`DeepSeekCompletionModel`] wrapped in a +/// pins it to the configured [`AnyChatCompletionModel`] wrapped in a /// [`RetryModel`] so transient provider failures are retried transparently. /// /// [`assemble`]: Self::assemble @@ -64,21 +68,21 @@ pub struct CliRuntime { persistence_error: Option, } -impl CliRuntime> { +impl CliRuntime> { /// Builds the runtime from parsed CLI args and the project settings file. /// /// Resolves permissions from built-in ∪ project file ∪ CLI flags (mode /// precedence CLI > project > Default), assembles the system prompt from its - /// identity/environment/tools sections, and wires the DeepSeek model + the - /// default workspace tool registry. + /// identity/environment/tools sections, and wires the configured model + + /// the default workspace tool registry. /// /// # Errors /// /// Fails if the current directory is not a usable workspace, the project /// settings or resolved permissions are invalid, active compaction cannot - /// be bound to the selected model, or the DeepSeek client cannot be built - /// from the environment. Failure to open the optional session store is - /// retained as degraded persistence state rather than failing assembly. + /// be bound to the selected model, or the provider client cannot be built + /// from its endpoint and environment. Failure to open the optional session + /// store is retained as degraded persistence state rather than failing assembly. pub async fn assemble(cli: &Cli) -> Result> { let workspace = Workspace::from_current_dir().await?; tracing::debug!( @@ -97,6 +101,7 @@ impl CliRuntime> { let project = load_project_settings(workspace.root(), project_trust)?; let model_name = project.model_name.clone(); let config = agent_config(&project)?; + let client = provider_client(&project)?; let flags = PermissionFlags { allow: &cli.allow, ask: &cli.ask, @@ -160,11 +165,10 @@ impl CliRuntime> { (None, Some("home directory unavailable".to_string())) } }; - let client = DeepSeekClient::from_env()?; // Normal turns inherit the default retry budget. Semantic summaries use // a separate one-retry wrapper so their fallback latency is bounded // independently of ordinary model calls. - let provider = DeepSeekCompletionModel::make(&client, model_name.clone()); + let provider = AnyChatCompletionModel::make(&client, model_name.clone()); let model = RetryModel::with_policy(provider.clone(), RetryPolicy::default()); let summary_model = RetryModel::with_policy(provider, summary_retry_policy()); let registry = ToolRegistry::with_default_workspace_tools(workspace)?; @@ -185,6 +189,32 @@ impl CliRuntime> { } } +fn provider_client(project: &ProjectSettings) -> Result> { + let api_key = if project.api_key_env.trim().is_empty() { + String::new() + } else { + std::env::var(&project.api_key_env).map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::NotFound, + format!( + "provider API key environment variable `{}` is unavailable: {error}", + project.api_key_env + ), + ) + })? + }; + match project.provider { + ProviderKind::DeepSeek => Ok(AnyChatClient::DeepSeek(DeepSeekClient::new(api_key)?)), + ProviderKind::OpenAiCompatible => { + let client = match project.base_url.as_deref() { + Some(base_url) => OpenAiCompatibleClient::new(api_key, base_url)?, + None => OpenAiCompatibleClient::openai(api_key)?, + }; + Ok(AnyChatClient::OpenAiCompatible(client)) + } + } +} + fn agent_config(project: &ProjectSettings) -> Result { let compaction = project .compaction diff --git a/crates/kuncode-cli/src/settings.rs b/crates/kuncode-cli/src/settings.rs index 843ebb7..657dcb1 100644 --- a/crates/kuncode-cli/src/settings.rs +++ b/crates/kuncode-cli/src/settings.rs @@ -76,19 +76,37 @@ struct PermissionsSection { #[derive(Debug, Deserialize)] #[serde(default, rename_all = "camelCase", deny_unknown_fields)] struct ModelSection { + provider: ProviderKind, name: String, + base_url: Option, + api_key_env: Option, max_tokens: Option, } impl Default for ModelSection { fn default() -> Self { Self { + provider: ProviderKind::DeepSeek, name: DEEPSEEK_V4_PRO_MODEL_ID.to_string(), + base_url: None, + api_key_env: None, max_tokens: None, } } } +/// Wire protocol selected for model requests. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)] +pub(crate) enum ProviderKind { + /// Native DeepSeek behavior and environment defaults. + #[default] + #[serde(rename = "deepseek")] + DeepSeek, + /// OpenAI Chat Completions and compatible services such as vLLM or Kimi. + #[serde(rename = "openai-compatible")] + OpenAiCompatible, +} + #[derive(Debug, Deserialize)] #[serde(default, rename_all = "camelCase", deny_unknown_fields)] struct AgentSection { @@ -166,6 +184,12 @@ pub struct ProjectSettings { pub default_mode: Option, /// Trust comes from CLI/user state, never from the project file itself. pub(crate) trust: ProjectTrust, + /// Effective provider protocol. + pub(crate) provider: ProviderKind, + /// Custom OpenAI-compatible service root or full completion endpoint. + pub(crate) base_url: Option, + /// Environment variable holding the provider API key. + pub(crate) api_key_env: String, /// Effective model identifier after file and environment precedence. pub(crate) model_name: String, /// Effective provider output budget for an ordinary turn. @@ -188,6 +212,9 @@ impl Default for ProjectSettings { policy: None, default_mode: None, trust: ProjectTrust::Untrusted, + provider: ProviderKind::DeepSeek, + base_url: None, + api_key_env: "DEEPSEEK_API_KEY".to_string(), model_name: DEEPSEEK_V4_PRO_MODEL_ID.to_string(), max_tokens, max_iterations: DEFAULT_MAX_ITERATIONS, @@ -199,9 +226,10 @@ impl Default for ProjectSettings { /// Loads `.kuncode/settings.json` under `root`. /// -/// A missing file returns defaults. `DEEPSEEK_MODEL` overrides the file's model -/// name when present. Every section forms a closed schema, so misspelled fields -/// fail instead of silently selecting defaults. +/// A missing file returns defaults. `KUNCODE_MODEL` overrides the file's model +/// name; `DEEPSEEK_MODEL` remains a backward-compatible fallback. Every section +/// forms a closed schema, so misspelled fields fail instead of silently selecting +/// defaults. /// /// # Errors /// @@ -213,7 +241,9 @@ pub(crate) fn load_project_settings( root: &Path, trust: ProjectTrust, ) -> Result { - let model_override = std::env::var("DEEPSEEK_MODEL").ok(); + let model_override = std::env::var("KUNCODE_MODEL") + .ok() + .or_else(|| std::env::var("DEEPSEEK_MODEL").ok()); load_project_settings_from(root, model_override.as_deref(), trust) } @@ -266,7 +296,11 @@ fn resolve_settings( "model name must not be blank".to_string(), )); } - let profile = model_profile(&model_name); + let profile = if file.model.provider == ProviderKind::DeepSeek { + model_profile(&model_name) + } else { + None + }; let max_tokens = file.model.max_tokens.unwrap_or_else(|| { profile.map_or_else( default_agent_max_tokens, @@ -276,6 +310,32 @@ fn resolve_settings( validate_model_max_tokens(max_tokens, profile)?; validate_agent(&file.agent)?; validate_log_level(&file.logging.level)?; + let base_url = match file.model.base_url { + Some(url) if url.trim().is_empty() => { + return Err(SettingsError::Model( + "baseUrl must not be blank when provided".to_string(), + )); + } + Some(url) => Some(url.trim().to_string()), + None => None, + }; + if file.model.provider == ProviderKind::DeepSeek && base_url.is_some() { + return Err(SettingsError::Model( + "baseUrl requires provider `openai-compatible`".to_string(), + )); + } + let api_key_env = file + .model + .api_key_env + .unwrap_or_else(|| match file.model.provider { + ProviderKind::DeepSeek => "DEEPSEEK_API_KEY".to_string(), + ProviderKind::OpenAiCompatible => "OPENAI_API_KEY".to_string(), + }); + if file.model.provider == ProviderKind::DeepSeek && api_key_env.trim().is_empty() { + return Err(SettingsError::Model( + "apiKeyEnv must not be blank for the DeepSeek provider".to_string(), + )); + } let canonical_root = std::fs::canonicalize(root).map_err(|error| SettingsError::Workspace(error.to_string()))?; @@ -300,6 +360,9 @@ fn resolve_settings( policy: Some(policy), default_mode, trust, + provider: file.model.provider, + base_url, + api_key_env, model_name, max_tokens, max_iterations: file.agent.max_iterations, @@ -555,6 +618,9 @@ mod tests { .expect("a missing file is fine"); let _ = fs::remove_dir_all(&dir); + assert_eq!(loaded.provider, ProviderKind::DeepSeek); + assert_eq!(loaded.base_url, None); + assert_eq!(loaded.api_key_env, "DEEPSEEK_API_KEY"); assert!(loaded.policy.expect("resolved policy").rules().is_empty()); assert!(loaded.default_mode.is_none()); assert!(loaded.compaction.is_none()); @@ -564,6 +630,67 @@ mod tests { assert_eq!(loaded.todo_reminder_interval, Some(3)); } + #[test] + fn loads_openai_compatible_provider_settings() { + let loaded = load_json( + "openai-compatible", + r#"{ "model": { + "provider": "openai-compatible", + "name": "gpt-test", + "baseUrl": "https://api.example.com/v1/", + "apiKeyEnv": "EXAMPLE_API_KEY", + "maxTokens": 8192 + } }"#, + ) + .expect("loads"); + + assert_eq!(loaded.provider, ProviderKind::OpenAiCompatible); + assert_eq!( + loaded.base_url.as_deref(), + Some("https://api.example.com/v1/") + ); + assert_eq!(loaded.api_key_env, "EXAMPLE_API_KEY"); + assert_eq!(loaded.model_name, "gpt-test"); + assert_eq!(loaded.max_tokens, 8_192); + } + + #[test] + fn loads_explicit_deepseek_provider_name() { + let loaded = load_json( + "deepseek-provider", + r#"{ "model": { "provider": "deepseek" } }"#, + ) + .expect("loads"); + + assert_eq!(loaded.provider, ProviderKind::DeepSeek); + } + + #[test] + fn openai_compatible_provider_allows_unauthenticated_local_endpoint() { + let loaded = load_json( + "openai-compatible-local", + r#"{ "model": { + "provider": "openai-compatible", + "name": "local-model", + "baseUrl": "http://localhost:8000/v1", + "apiKeyEnv": "" + } }"#, + ) + .expect("loads"); + + assert_eq!(loaded.api_key_env, ""); + } + + #[test] + fn deepseek_rejects_openai_only_base_url() { + let result = load_json( + "deepseek-base-url", + r#"{ "model": { "baseUrl": "https://example.com/v1" } }"#, + ); + + assert!(matches!(result, Err(SettingsError::Model(_)))); + } + #[test] fn logging_level_defaults_to_info() { let dir = std::env::temp_dir().join(format!("kuncode-log-absent-{}", std::process::id())); diff --git a/crates/kuncode-core/src/json_utils.rs b/crates/kuncode-core/src/json_utils.rs index 4115cbb..17bea53 100644 --- a/crates/kuncode-core/src/json_utils.rs +++ b/crates/kuncode-core/src/json_utils.rs @@ -65,3 +65,14 @@ where let opt = > as serde::Deserialize>::deserialize(deserializer)?; Ok(opt.unwrap_or_default()) } + +/// Deserializes a defaultable value from a field that providers may send as +/// JSON `null` even though the non-null wire value is required by the schema. +pub fn null_or_default<'de, D, T>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, + T: serde::Deserialize<'de> + Default, +{ + let opt = as serde::Deserialize>::deserialize(deserializer)?; + Ok(opt.unwrap_or_default()) +} diff --git a/crates/kuncode-core/src/providers.rs b/crates/kuncode-core/src/providers.rs index 990aff0..c722cc2 100644 --- a/crates/kuncode-core/src/providers.rs +++ b/crates/kuncode-core/src/providers.rs @@ -1,7 +1,10 @@ //! Concrete LLM provider integrations. //! //! Each provider owns the mapping from the provider-agnostic -//! [`crate::completion`] types to that provider's HTTP API. Currently this -//! crate ships the [`deepseek`] integration. +//! [`crate::completion`] types to that provider's HTTP API. DeepSeek remains +//! the default, while [`openai_compatible`] supports OpenAI and compatible +//! `/chat/completions` endpoints. +pub mod any_chat; pub mod deepseek; +pub mod openai_compatible; diff --git a/crates/kuncode-core/src/providers/any_chat.rs b/crates/kuncode-core/src/providers/any_chat.rs new file mode 100644 index 0000000..a186485 --- /dev/null +++ b/crates/kuncode-core/src/providers/any_chat.rs @@ -0,0 +1,77 @@ +//! Runtime-selected chat model used when provider choice comes from configuration. + +use serde_json::Value; + +use crate::{ + completion::{ + CompletionError, CompletionModel, CompletionRequest, CompletionResponse, CompletionStream, + }, + providers::{ + deepseek::{DeepSeekClient, DeepSeekCompletionModel}, + openai_compatible::{OpenAiCompatibleClient, OpenAiCompatibleCompletionModel}, + }, +}; + +/// Provider client selected by project configuration. +#[derive(Clone)] +pub enum AnyChatClient { + /// Native DeepSeek protocol behavior. + DeepSeek(DeepSeekClient), + /// OpenAI-compatible Chat Completions behavior. + OpenAiCompatible(OpenAiCompatibleClient), +} + +/// Model handle that keeps the agent runtime independent of provider choice. +#[derive(Clone)] +pub enum AnyChatCompletionModel { + /// Native DeepSeek model. + DeepSeek(DeepSeekCompletionModel), + /// OpenAI-compatible model. + OpenAiCompatible(OpenAiCompatibleCompletionModel), +} + +impl CompletionModel for AnyChatCompletionModel { + type Response = Value; + type Client = AnyChatClient; + + fn make(client: &Self::Client, model: impl Into) -> Self { + let model = model.into(); + match client { + AnyChatClient::DeepSeek(client) => { + Self::DeepSeek(DeepSeekCompletionModel::make(client, model)) + } + AnyChatClient::OpenAiCompatible(client) => { + Self::OpenAiCompatible(OpenAiCompatibleCompletionModel::make(client, model)) + } + } + } + + async fn completion( + &self, + request: CompletionRequest, + ) -> Result, CompletionError> { + match self { + Self::DeepSeek(model) => { + let response = model.completion(request).await?; + let raw_response = serde_json::to_value(response.raw_response)?; + Ok(CompletionResponse { + choice: response.choice, + usage: response.usage, + raw_response, + message_id: response.message_id, + }) + } + Self::OpenAiCompatible(model) => model.completion(request).await, + } + } + + async fn stream( + &self, + request: CompletionRequest, + ) -> Result { + match self { + Self::DeepSeek(model) => model.stream(request).await, + Self::OpenAiCompatible(model) => model.stream(request).await, + } + } +} diff --git a/crates/kuncode-core/src/providers/deepseek/protocol.rs b/crates/kuncode-core/src/providers/deepseek/protocol.rs index c97df9d..f38a071 100644 --- a/crates/kuncode-core/src/providers/deepseek/protocol.rs +++ b/crates/kuncode-core/src/providers/deepseek/protocol.rs @@ -51,6 +51,7 @@ pub enum Message { /// Assistant output: visible text plus optional tool calls and reasoning. Assistant { /// Visible assistant text. + #[serde(default, deserialize_with = "json_utils::null_or_default")] content: String, /// Optional speaker name accepted by OpenAI-compatible APIs. #[serde(skip_serializing_if = "Option::is_none")] @@ -207,6 +208,7 @@ pub struct ToolCall { // Position within parallel calls. DeepSeek includes it in responses and // streaming chunks; request replay uses array order instead, so outbound // conversion fills 0 and inbound projection does not read it. + #[serde(default)] pub index: usize, /// Tool-call kind; currently always [`ToolType::Function`]. pub r#type: ToolType, @@ -377,11 +379,13 @@ pub struct DeepSeekCompletionResponse { pub created: u64, /// Model id that served the request. pub model: String, - /// Provider backend fingerprint. - pub system_fingerprint: String, + /// Provider backend fingerprint; OpenAI-compatible endpoints may omit it. + #[serde(default)] + pub system_fingerprint: Option, /// OpenAI-compatible object type. pub object: String, - /// Token accounting for the call. + /// Token accounting for the call; some compatible endpoints omit it. + #[serde(default)] pub usage: Usage, } diff --git a/crates/kuncode-core/src/providers/openai_compatible.rs b/crates/kuncode-core/src/providers/openai_compatible.rs new file mode 100644 index 0000000..d2e1d35 --- /dev/null +++ b/crates/kuncode-core/src/providers/openai_compatible.rs @@ -0,0 +1,281 @@ +//! OpenAI-compatible `/chat/completions` provider with configurable endpoint. + +use std::time::Duration; + +use serde_json::Value; +use thiserror::Error; + +use crate::{ + completion::{ + CompletionError, CompletionModel, CompletionRequest, CompletionResponse, ReasoningEffort, + }, + json_utils, + providers::deepseek::protocol::{DeepSeekCompletionRequest, DeepSeekCompletionResponse}, +}; + +const DEFAULT_BASE_URL: &str = "https://api.openai.com/v1"; +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +const READ_TIMEOUT: Duration = Duration::from_secs(360); +const REQUEST_TIMEOUT: Duration = Duration::from_secs(360); + +/// Errors produced while constructing an OpenAI-compatible client. +#[derive(Debug, Error)] +pub enum Error { + /// The endpoint is empty and cannot be normalized. + #[error("OpenAI-compatible base URL must not be blank")] + BlankBaseUrl, + /// The underlying HTTP client could not be built. + #[error("HTTP client error: {0}")] + Client(#[from] reqwest::Error), +} + +/// Authenticated client for OpenAI and compatible Chat Completions endpoints. +#[derive(Clone)] +pub struct OpenAiCompatibleClient { + http_client: reqwest::Client, + api_key: String, + endpoint: String, +} + +impl OpenAiCompatibleClient { + /// Builds a client, appending `/chat/completions` when `base_url` names a + /// service root such as `https://api.openai.com/v1`. + /// + /// An empty API key is accepted for local endpoints that do not authenticate. + /// + /// # Errors + /// Returns [`enum@Error`] when the base URL is blank or the HTTP client fails. + pub fn new(api_key: impl Into, base_url: impl AsRef) -> Result { + let endpoint = completion_endpoint(base_url.as_ref())?; + let http_client = reqwest::Client::builder() + .read_timeout(READ_TIMEOUT) + .connect_timeout(CONNECT_TIMEOUT) + .build()?; + Ok(Self { + http_client, + api_key: api_key.into(), + endpoint, + }) + } + + /// Builds a client for the official OpenAI endpoint. + /// + /// # Errors + /// Returns [`enum@Error`] when the HTTP client cannot be built. + pub fn openai(api_key: impl Into) -> Result { + Self::new(api_key, DEFAULT_BASE_URL) + } + + fn post(&self) -> reqwest::RequestBuilder { + let request = self.http_client.post(&self.endpoint); + if self.api_key.is_empty() { + request + } else { + request.bearer_auth(&self.api_key) + } + } +} + +fn completion_endpoint(base_url: &str) -> Result { + let base_url = base_url.trim().trim_end_matches('/'); + if base_url.is_empty() { + return Err(Error::BlankBaseUrl); + } + if base_url.ends_with("/chat/completions") { + Ok(base_url.to_string()) + } else { + Ok(format!("{base_url}/chat/completions")) + } +} + +/// Completion model for OpenAI-compatible Chat Completions APIs. +#[derive(Clone)] +pub struct OpenAiCompatibleCompletionModel { + client: OpenAiCompatibleClient, + model: String, +} + +impl CompletionModel for OpenAiCompatibleCompletionModel { + type Response = Value; + type Client = OpenAiCompatibleClient; + + fn make(client: &Self::Client, model: impl Into) -> Self { + Self { + client: client.clone(), + model: model.into(), + } + } + + async fn completion( + &self, + request: CompletionRequest, + ) -> Result, CompletionError> { + let body = request_body(request, &self.model, false)?; + let response = self + .client + .post() + .timeout(REQUEST_TIMEOUT) + .json(&body) + .send() + .await?; + let status = response.status(); + if !status.is_success() { + return Err(CompletionError::ApiError { + status: status.as_u16(), + message: response.text().await.unwrap_or_default(), + }); + } + + let raw: Value = serde_json::from_slice(&response.bytes().await?)?; + normalize_response(raw) + } + + async fn stream( + &self, + request: CompletionRequest, + ) -> Result { + let body = request_body(request, &self.model, true)?; + let response = self.client.post().json(&body).send().await?; + let status = response.status(); + if !status.is_success() { + return Err(CompletionError::ApiError { + status: status.as_u16(), + message: response.text().await.unwrap_or_default(), + }); + } + Ok(crate::providers::deepseek::protocol::streaming::stream_events(response)) + } +} + +fn request_body( + mut request: CompletionRequest, + model: &str, + streaming: bool, +) -> Result { + request.model.get_or_insert_with(|| model.to_string()); + let extra = request.additional_params.take(); + let reasoning = request.reasoning.take(); + let request = DeepSeekCompletionRequest::try_from(request)?; + let mut body = if streaming { + serde_json::to_value(request.into_streaming())? + } else { + serde_json::to_value(request)? + }; + + // The shared DTO carries DeepSeek's `thinking` object. OpenAI-compatible + // endpoints use the flat `reasoning_effort` field instead. + if let Value::Object(fields) = &mut body { + fields.remove("thinking"); + fields.remove("reasoning_effort"); + if let Some(effort) = openai_reasoning_effort(reasoning) { + fields.insert( + "reasoning_effort".to_string(), + Value::String(effort.to_string()), + ); + } + } + + match extra { + Some(extra) => Ok(json_utils::merge(body, extra)), + None => Ok(body), + } +} + +fn normalize_response(raw: Value) -> Result, CompletionError> { + let provider: DeepSeekCompletionResponse = serde_json::from_value(raw.clone())?; + let normalized: CompletionResponse = provider.try_into()?; + Ok(CompletionResponse { + choice: normalized.choice, + usage: normalized.usage, + raw_response: raw, + message_id: normalized.message_id, + }) +} + +fn openai_reasoning_effort(effort: Option) -> Option<&'static str> { + match effort { + None | Some(ReasoningEffort::Off) => None, + Some(ReasoningEffort::Minimal) => Some("minimal"), + Some(ReasoningEffort::Low) => Some("low"), + Some(ReasoningEffort::Medium) => Some("medium"), + Some(ReasoningEffort::High) => Some("high"), + Some(ReasoningEffort::Xhigh) => Some("xhigh"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::completion::{AssistantContent, CompletionRequestBuilder, Message}; + + #[test] + fn appends_chat_completions_to_service_root() { + assert_eq!( + completion_endpoint("https://api.openai.com/v1/").expect("valid endpoint"), + "https://api.openai.com/v1/chat/completions" + ); + } + + #[test] + fn preserves_full_chat_completions_endpoint() { + assert_eq!( + completion_endpoint("http://localhost:8000/v1/chat/completions") + .expect("valid endpoint"), + "http://localhost:8000/v1/chat/completions" + ); + } + + #[test] + fn request_uses_openai_reasoning_field_without_deepseek_thinking() { + let request = CompletionRequestBuilder::new(Message::user("test")) + .reasoning(Some(ReasoningEffort::Low)) + .build(); + let body = request_body(request, "gpt-test", true).expect("request body"); + + assert_eq!(body["reasoning_effort"], "low"); + assert!(body.get("thinking").is_none()); + assert_eq!(body["stream_options"]["include_usage"], true); + } + + #[test] + fn accepts_openai_tool_call_with_null_content_and_no_fingerprint() { + let response = normalize_response(serde_json::json!({ + "id": "chatcmpl-test", + "choices": [{ + "finish_reason": "tool_calls", + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls": [{ + "id": "call-1", + "type": "function", + "function": { + "name": "bash", + "arguments": "{\"cmd\":\"pwd\"}" + } + }] + }, + "logprobs": null + }], + "created": 1, + "model": "gpt-test", + "object": "chat.completion", + "usage": { + "prompt_tokens": 10, + "completion_tokens": 4, + "total_tokens": 14, + "prompt_tokens_details": { "cached_tokens": 3 } + } + })) + .expect("OpenAI response normalizes"); + + assert_eq!(response.usage.cached_input_tokens, 3); + assert!(matches!( + response.choice.first(), + AssistantContent::ToolCall(call) + if call.function.name == "bash" + && call.function.arguments.get("cmd").and_then(Value::as_str) == Some("pwd") + )); + } +} From c467733494fa26bae0dae7852bd54bab53a828bf Mon Sep 17 00:00:00 2001 From: wenzr <282277167@qq.com> Date: Tue, 21 Jul 2026 23:29:20 +0800 Subject: [PATCH 2/8] =?UTF-8?q?Provider:=20=E6=94=B9=E8=BF=9B=E9=9D=9ESSE?= =?UTF-8?q?=E5=93=8D=E5=BA=94=E9=94=99=E8=AF=AF=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/providers/openai_compatible.rs | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/crates/kuncode-core/src/providers/openai_compatible.rs b/crates/kuncode-core/src/providers/openai_compatible.rs index d2e1d35..f958d49 100644 --- a/crates/kuncode-core/src/providers/openai_compatible.rs +++ b/crates/kuncode-core/src/providers/openai_compatible.rs @@ -2,6 +2,7 @@ use std::time::Duration; +use reqwest::header::CONTENT_TYPE; use serde_json::Value; use thiserror::Error; @@ -143,10 +144,30 @@ impl CompletionModel for OpenAiCompatibleCompletionModel { message: response.text().await.unwrap_or_default(), }); } + validate_stream_content_type( + response + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + )?; Ok(crate::providers::deepseek::protocol::streaming::stream_events(response)) } } +fn validate_stream_content_type(content_type: Option<&str>) -> Result<(), CompletionError> { + let Some(content_type) = content_type else { + return Ok(()); + }; + let media_type = content_type.split(';').next().unwrap_or_default().trim(); + if media_type.eq_ignore_ascii_case("text/event-stream") { + return Ok(()); + } + + Err(CompletionError::ResponseError(format!( + "expected an SSE response with content type `text/event-stream`, but the provider returned `{media_type}`; verify `baseUrl` points to the API root (usually ending in `/v1`) or the full `/chat/completions` endpoint" + ))) +} + fn request_body( mut request: CompletionRequest, model: &str, @@ -225,6 +246,29 @@ mod tests { ); } + #[test] + fn accepts_sse_content_type_with_parameters() { + validate_stream_content_type(Some("text/event-stream; charset=utf-8")) + .expect("SSE content type should be accepted"); + } + + #[test] + fn accepts_missing_content_type_for_compatible_gateways() { + validate_stream_content_type(None) + .expect("a missing content type should fall through to the SSE decoder"); + } + + #[test] + fn rejects_html_with_base_url_guidance() { + let error = validate_stream_content_type(Some("text/html; charset=utf-8")) + .expect_err("HTML is not an SSE response") + .to_string(); + + assert!(error.contains("`text/html`")); + assert!(error.contains("`baseUrl`")); + assert!(error.contains("/v1")); + } + #[test] fn request_uses_openai_reasoning_field_without_deepseek_thinking() { let request = CompletionRequestBuilder::new(Message::user("test")) From 5eb87706c204f6c4bff1e2810124c8fd15d574a2 Mon Sep 17 00:00:00 2001 From: wenzr <282277167@qq.com> Date: Wed, 22 Jul 2026 19:58:36 +0800 Subject: [PATCH 3/8] =?UTF-8?q?Provider:=20=E6=94=B6=E7=AA=84=E5=AE=98?= =?UTF-8?q?=E6=96=B9=20OpenAI=20=E5=8D=8F=E8=AE=AE=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 36 +- .../src/compaction/artifact/spill/runtime.rs | 4 +- .../src/compaction/protocol/grouping.rs | 4 +- .../src/compaction/slimming/marker.rs | 1 + crates/kuncode-agent/src/runner/iteration.rs | 4 + crates/kuncode-agent/src/runner/request.rs | 1 + crates/kuncode-agent/src/session_store/dto.rs | 3 + .../src/session_store/dto/conversion.rs | 4 + crates/kuncode-cli/src/runtime.rs | 27 +- crates/kuncode-cli/src/settings.rs | 88 +--- crates/kuncode-core/src/completion.rs | 2 +- crates/kuncode-core/src/completion/message.rs | 23 + .../kuncode-core/src/completion/streaming.rs | 2 + crates/kuncode-core/src/providers.rs | 6 +- crates/kuncode-core/src/providers/any_chat.rs | 14 +- .../src/providers/chat_completions.rs | 3 + .../streaming.rs | 78 ++- crates/kuncode-core/src/providers/deepseek.rs | 6 +- .../src/providers/deepseek/protocol.rs | 5 +- crates/kuncode-core/src/providers/openai.rs | 175 +++++++ .../src/providers/openai/protocol.rs | 472 ++++++++++++++++++ .../src/providers/openai_compatible.rs | 325 ------------ 22 files changed, 792 insertions(+), 491 deletions(-) create mode 100644 crates/kuncode-core/src/providers/chat_completions.rs rename crates/kuncode-core/src/providers/{deepseek/protocol => chat_completions}/streaming.rs (89%) create mode 100644 crates/kuncode-core/src/providers/openai.rs create mode 100644 crates/kuncode-core/src/providers/openai/protocol.rs delete mode 100644 crates/kuncode-core/src/providers/openai_compatible.rs diff --git a/README.md b/README.md index 4ed800e..53461b6 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ [`learn-claude-code`](https://github.com/shareAI-lab/learn-claude-code) 的 Harness Engineering 思路:模型负责判断下一步做什么,Harness 负责提供工具、上下文、权限边界、持久化和用户界面。 -当前版本为 `0.1.0`,默认使用 DeepSeek,也支持 OpenAI Chat Completions -兼容协议,提供一次性命令行执行和交互式 TUI 两种使用方式。 +当前版本为 `0.1.0`,默认使用 DeepSeek,也支持 OpenAI Chat Completions, +提供一次性命令行执行和交互式 TUI 两种使用方式。 ## 工作区结构 @@ -17,14 +17,14 @@ kuncode-cli ──▶ kuncode-agent ──▶ kuncode-core ──▶ LLM API └─ 参数、配置、审批、一次性输出与 TUI ``` -- `kuncode-core`:Provider-neutral 的消息与 Completion 抽象,以及 DeepSeek、OpenAI-compatible Provider。 +- `kuncode-core`:Provider-neutral 的消息与 Completion 抽象,以及 DeepSeek、OpenAI Provider。 - `kuncode-agent`:Agent 运行时、工具调度、权限、Hook、Todo、会话持久化和上下文压缩。 - `kuncode-cli`:命令行参数、项目配置、终端审批、普通输出和交互式 TUI。 ## 环境要求 - Rust stable,项目使用 Rust 2024 edition。 -- 所选模型服务的 API Key;本地无鉴权服务可不配置。 +- DeepSeek 或 OpenAI API Key。 - 支持 ANSI 终端;交互模式需要 stdin 和 stdout 都连接到真实终端。 ## 快速开始 @@ -46,9 +46,8 @@ DEEPSEEK_API_KEY=your-api-key ```json { "model": { - "provider": "openai-compatible", + "provider": "openai", "name": "your-openai-model", - "apiKeyEnv": "OPENAI_API_KEY", "maxTokens": 16384 } } @@ -60,23 +59,6 @@ DEEPSEEK_API_KEY=your-api-key export OPENAI_API_KEY="your-api-key" ``` -Kimi、Qwen、智谱、vLLM 等提供 OpenAI Chat Completions 兼容协议的服务,增加 -`baseUrl` 即可。它既可以是服务根地址,也可以是完整 endpoint: - -```json -{ - "model": { - "provider": "openai-compatible", - "name": "your-model", - "baseUrl": "http://localhost:8000/v1", - "apiKeyEnv": "", - "maxTokens": 8192 - } -} -``` - -`apiKeyEnv` 为空表示不发送 `Authorization`,适合无鉴权的本地服务。 - 一次性执行任务: ```bash @@ -107,7 +89,7 @@ cargo build --release -p kuncode-cli ```json { "permissions": { - "allow": ["Read(**)", "Bash(cargo *)"], + "allow": ["Read", "Bash(cargo *)"], "ask": ["Edit(.env)"], "deny": ["Bash(curl *)"], "defaultMode": "default" @@ -115,7 +97,6 @@ cargo build --release -p kuncode-cli "model": { "provider": "deepseek", "name": "deepseek-v4-pro", - "apiKeyEnv": "DEEPSEEK_API_KEY", "maxTokens": 65536 }, "agent": { @@ -134,9 +115,8 @@ cargo build --release -p kuncode-cli 补充说明: - `KUNCODE_MODEL` 可以覆盖配置文件中的模型名称;`DEEPSEEK_MODEL` 作为兼容别名保留。 -- `model.provider` 支持 `deepseek` 和 `openai-compatible`。 -- `openai-compatible` 未设置 `baseUrl` 时默认使用 `https://api.openai.com/v1`。 -- `baseUrl` 会自动补全 `/chat/completions`;完整 endpoint 不会重复追加。 +- `model.provider` 支持 `deepseek` 和 `openai`;两者分别使用固定官方 endpoint, + 并读取 `DEEPSEEK_API_KEY` 或 `OPENAI_API_KEY`。 - 内置模型配置包括 `deepseek-v4-pro` 和 `deepseek-v4-flash`。 - 非内置模型启用上下文压缩时,需要显式设置 `compaction.contextLimit`。 - `compaction.mode` 支持 `disabled`、`shadow` 和 `enabled`,默认是 `disabled`。 diff --git a/crates/kuncode-agent/src/compaction/artifact/spill/runtime.rs b/crates/kuncode-agent/src/compaction/artifact/spill/runtime.rs index fd1214d..fe4337c 100644 --- a/crates/kuncode-agent/src/compaction/artifact/spill/runtime.rs +++ b/crates/kuncode-agent/src/compaction/artifact/spill/runtime.rs @@ -255,7 +255,9 @@ fn call_names(message: &Message) -> BTreeMap { if let Message::Assistant { content, .. } = message { for call in content.iter().filter_map(|block| match block { AssistantContent::ToolCall(call) => Some(call), - AssistantContent::Text(_) | AssistantContent::Reasoning(_) => None, + AssistantContent::Text(_) + | AssistantContent::Reasoning(_) + | AssistantContent::Refusal(_) => None, }) { names.insert(call.id.clone(), call.function.name.clone()); } diff --git a/crates/kuncode-agent/src/compaction/protocol/grouping.rs b/crates/kuncode-agent/src/compaction/protocol/grouping.rs index 7dd4d97..dbd3929 100644 --- a/crates/kuncode-agent/src/compaction/protocol/grouping.rs +++ b/crates/kuncode-agent/src/compaction/protocol/grouping.rs @@ -127,7 +127,9 @@ pub fn group_messages(messages: &[Message]) -> Result, Protoc .iter() .filter_map(|block| match block { AssistantContent::ToolCall(call) => Some(call), - AssistantContent::Text(_) | AssistantContent::Reasoning(_) => None, + AssistantContent::Text(_) + | AssistantContent::Reasoning(_) + | AssistantContent::Refusal(_) => None, }) .collect::>(); if calls.is_empty() { diff --git a/crates/kuncode-agent/src/compaction/slimming/marker.rs b/crates/kuncode-agent/src/compaction/slimming/marker.rs index 0ad7770..2d40be1 100644 --- a/crates/kuncode-agent/src/compaction/slimming/marker.rs +++ b/crates/kuncode-agent/src/compaction/slimming/marker.rs @@ -139,6 +139,7 @@ fn assistant_call<'a>( AssistantContent::ToolCall(call) if call.id == result_id => Some(call), AssistantContent::Text(_) | AssistantContent::Reasoning(_) + | AssistantContent::Refusal(_) | AssistantContent::ToolCall(_) => None, }) } diff --git a/crates/kuncode-agent/src/runner/iteration.rs b/crates/kuncode-agent/src/runner/iteration.rs index ab89bd9..7841ace 100644 --- a/crates/kuncode-agent/src/runner/iteration.rs +++ b/crates/kuncode-agent/src/runner/iteration.rs @@ -144,6 +144,9 @@ where StreamEvent::ReasoningDelta(text) => { self.emit(session, Some(iteration), EventKind::ReasoningDelta { text }); } + StreamEvent::RefusalDelta(text) => { + self.emit(session, Some(iteration), EventKind::TextDelta { text }); + } // The "calling X" hint is surfaced by `ToolStart` after the turn // completes and the call is gated; ignore the earlier signal. StreamEvent::ToolCallStart { .. } => {} @@ -188,6 +191,7 @@ fn stream_event_kind(event: &StreamEvent) -> &'static str { match event { StreamEvent::TextDelta(_) => "text_delta", StreamEvent::ReasoningDelta(_) => "reasoning_delta", + StreamEvent::RefusalDelta(_) => "refusal_delta", StreamEvent::ToolCallStart { .. } => "tool_call_start", StreamEvent::Completed { .. } => "completed", } diff --git a/crates/kuncode-agent/src/runner/request.rs b/crates/kuncode-agent/src/runner/request.rs index 93fc5d5..eaa611b 100644 --- a/crates/kuncode-agent/src/runner/request.rs +++ b/crates/kuncode-agent/src/runner/request.rs @@ -170,6 +170,7 @@ pub(super) fn assistant_text(content: &NonEmptyVec) -> String .iter() .filter_map(|content| match content { AssistantContent::Text(text) => Some(text.text_ref()), + AssistantContent::Refusal(refusal) => Some(refusal.text_ref()), _ => None, }) .collect::>() diff --git a/crates/kuncode-agent/src/session_store/dto.rs b/crates/kuncode-agent/src/session_store/dto.rs index 1b77c81..2d57809 100644 --- a/crates/kuncode-agent/src/session_store/dto.rs +++ b/crates/kuncode-agent/src/session_store/dto.rs @@ -72,6 +72,9 @@ enum StoredAssistantContent { Text { text: String, }, + Refusal { + text: String, + }, ToolCall { id: String, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/crates/kuncode-agent/src/session_store/dto/conversion.rs b/crates/kuncode-agent/src/session_store/dto/conversion.rs index 1a430d3..7069c8d 100644 --- a/crates/kuncode-agent/src/session_store/dto/conversion.rs +++ b/crates/kuncode-agent/src/session_store/dto/conversion.rs @@ -129,6 +129,9 @@ impl StoredAssistantContent { AssistantContent::Text(text) => Self::Text { text: text.text_ref().to_string(), }, + AssistantContent::Refusal(refusal) => Self::Refusal { + text: refusal.text_ref().to_string(), + }, AssistantContent::ToolCall(call) => Self::ToolCall { id: call.id.clone(), call_id: call.call_id.clone(), @@ -151,6 +154,7 @@ impl StoredAssistantContent { fn into_content(self) -> Result { match self { Self::Text { text } => Ok(AssistantContent::Text(Text::from(text))), + Self::Refusal { text } => Ok(AssistantContent::refusal(text)), Self::ToolCall { id, call_id, diff --git a/crates/kuncode-cli/src/runtime.rs b/crates/kuncode-cli/src/runtime.rs index 8f32659..605616b 100644 --- a/crates/kuncode-cli/src/runtime.rs +++ b/crates/kuncode-cli/src/runtime.rs @@ -30,7 +30,7 @@ use kuncode_core::completion::{CompletionModel, RetryModel, RetryPolicy}; use kuncode_core::providers::{ any_chat::{AnyChatClient, AnyChatCompletionModel}, deepseek::DeepSeekClient, - openai_compatible::OpenAiCompatibleClient, + openai::OpenAiClient, }; use crate::config::{PermissionFlags, resolve_permissions}; @@ -81,7 +81,7 @@ impl CliRuntime> { /// Fails if the current directory is not a usable workspace, the project /// settings or resolved permissions are invalid, active compaction cannot /// be bound to the selected model, or the provider client cannot be built - /// from its endpoint and environment. Failure to open the optional session + /// from its fixed credential environment. Failure to open the optional session /// store is retained as degraded persistence state rather than failing assembly. pub async fn assemble(cli: &Cli) -> Result> { let workspace = Workspace::from_current_dir().await?; @@ -190,28 +190,9 @@ impl CliRuntime> { } fn provider_client(project: &ProjectSettings) -> Result> { - let api_key = if project.api_key_env.trim().is_empty() { - String::new() - } else { - std::env::var(&project.api_key_env).map_err(|error| { - std::io::Error::new( - std::io::ErrorKind::NotFound, - format!( - "provider API key environment variable `{}` is unavailable: {error}", - project.api_key_env - ), - ) - })? - }; match project.provider { - ProviderKind::DeepSeek => Ok(AnyChatClient::DeepSeek(DeepSeekClient::new(api_key)?)), - ProviderKind::OpenAiCompatible => { - let client = match project.base_url.as_deref() { - Some(base_url) => OpenAiCompatibleClient::new(api_key, base_url)?, - None => OpenAiCompatibleClient::openai(api_key)?, - }; - Ok(AnyChatClient::OpenAiCompatible(client)) - } + ProviderKind::DeepSeek => Ok(AnyChatClient::DeepSeek(DeepSeekClient::from_env()?)), + ProviderKind::OpenAi => Ok(AnyChatClient::OpenAi(OpenAiClient::from_env()?)), } } diff --git a/crates/kuncode-cli/src/settings.rs b/crates/kuncode-cli/src/settings.rs index 657dcb1..be13563 100644 --- a/crates/kuncode-cli/src/settings.rs +++ b/crates/kuncode-cli/src/settings.rs @@ -78,8 +78,6 @@ struct PermissionsSection { struct ModelSection { provider: ProviderKind, name: String, - base_url: Option, - api_key_env: Option, max_tokens: Option, } @@ -88,8 +86,6 @@ impl Default for ModelSection { Self { provider: ProviderKind::DeepSeek, name: DEEPSEEK_V4_PRO_MODEL_ID.to_string(), - base_url: None, - api_key_env: None, max_tokens: None, } } @@ -102,9 +98,9 @@ pub(crate) enum ProviderKind { #[default] #[serde(rename = "deepseek")] DeepSeek, - /// OpenAI Chat Completions and compatible services such as vLLM or Kimi. - #[serde(rename = "openai-compatible")] - OpenAiCompatible, + /// Official OpenAI Chat Completions protocol and endpoint. + #[serde(rename = "openai")] + OpenAi, } #[derive(Debug, Deserialize)] @@ -186,10 +182,6 @@ pub struct ProjectSettings { pub(crate) trust: ProjectTrust, /// Effective provider protocol. pub(crate) provider: ProviderKind, - /// Custom OpenAI-compatible service root or full completion endpoint. - pub(crate) base_url: Option, - /// Environment variable holding the provider API key. - pub(crate) api_key_env: String, /// Effective model identifier after file and environment precedence. pub(crate) model_name: String, /// Effective provider output budget for an ordinary turn. @@ -213,8 +205,6 @@ impl Default for ProjectSettings { default_mode: None, trust: ProjectTrust::Untrusted, provider: ProviderKind::DeepSeek, - base_url: None, - api_key_env: "DEEPSEEK_API_KEY".to_string(), model_name: DEEPSEEK_V4_PRO_MODEL_ID.to_string(), max_tokens, max_iterations: DEFAULT_MAX_ITERATIONS, @@ -310,33 +300,6 @@ fn resolve_settings( validate_model_max_tokens(max_tokens, profile)?; validate_agent(&file.agent)?; validate_log_level(&file.logging.level)?; - let base_url = match file.model.base_url { - Some(url) if url.trim().is_empty() => { - return Err(SettingsError::Model( - "baseUrl must not be blank when provided".to_string(), - )); - } - Some(url) => Some(url.trim().to_string()), - None => None, - }; - if file.model.provider == ProviderKind::DeepSeek && base_url.is_some() { - return Err(SettingsError::Model( - "baseUrl requires provider `openai-compatible`".to_string(), - )); - } - let api_key_env = file - .model - .api_key_env - .unwrap_or_else(|| match file.model.provider { - ProviderKind::DeepSeek => "DEEPSEEK_API_KEY".to_string(), - ProviderKind::OpenAiCompatible => "OPENAI_API_KEY".to_string(), - }); - if file.model.provider == ProviderKind::DeepSeek && api_key_env.trim().is_empty() { - return Err(SettingsError::Model( - "apiKeyEnv must not be blank for the DeepSeek provider".to_string(), - )); - } - let canonical_root = std::fs::canonicalize(root).map_err(|error| SettingsError::Workspace(error.to_string()))?; let canonical_root = CanonicalPath::from_absolute(&canonical_root) @@ -361,8 +324,6 @@ fn resolve_settings( default_mode, trust, provider: file.model.provider, - base_url, - api_key_env, model_name, max_tokens, max_iterations: file.agent.max_iterations, @@ -619,8 +580,6 @@ mod tests { let _ = fs::remove_dir_all(&dir); assert_eq!(loaded.provider, ProviderKind::DeepSeek); - assert_eq!(loaded.base_url, None); - assert_eq!(loaded.api_key_env, "DEEPSEEK_API_KEY"); assert!(loaded.policy.expect("resolved policy").rules().is_empty()); assert!(loaded.default_mode.is_none()); assert!(loaded.compaction.is_none()); @@ -631,25 +590,18 @@ mod tests { } #[test] - fn loads_openai_compatible_provider_settings() { + fn loads_official_openai_provider_settings() { let loaded = load_json( - "openai-compatible", + "openai", r#"{ "model": { - "provider": "openai-compatible", + "provider": "openai", "name": "gpt-test", - "baseUrl": "https://api.example.com/v1/", - "apiKeyEnv": "EXAMPLE_API_KEY", "maxTokens": 8192 } }"#, ) .expect("loads"); - assert_eq!(loaded.provider, ProviderKind::OpenAiCompatible); - assert_eq!( - loaded.base_url.as_deref(), - Some("https://api.example.com/v1/") - ); - assert_eq!(loaded.api_key_env, "EXAMPLE_API_KEY"); + assert_eq!(loaded.provider, ProviderKind::OpenAi); assert_eq!(loaded.model_name, "gpt-test"); assert_eq!(loaded.max_tokens, 8_192); } @@ -665,32 +617,6 @@ mod tests { assert_eq!(loaded.provider, ProviderKind::DeepSeek); } - #[test] - fn openai_compatible_provider_allows_unauthenticated_local_endpoint() { - let loaded = load_json( - "openai-compatible-local", - r#"{ "model": { - "provider": "openai-compatible", - "name": "local-model", - "baseUrl": "http://localhost:8000/v1", - "apiKeyEnv": "" - } }"#, - ) - .expect("loads"); - - assert_eq!(loaded.api_key_env, ""); - } - - #[test] - fn deepseek_rejects_openai_only_base_url() { - let result = load_json( - "deepseek-base-url", - r#"{ "model": { "baseUrl": "https://example.com/v1" } }"#, - ); - - assert!(matches!(result, Err(SettingsError::Model(_)))); - } - #[test] fn logging_level_defaults_to_info() { let dir = std::env::temp_dir().join(format!("kuncode-log-absent-{}", std::process::id())); diff --git a/crates/kuncode-core/src/completion.rs b/crates/kuncode-core/src/completion.rs index 2a80ea7..75094d2 100644 --- a/crates/kuncode-core/src/completion.rs +++ b/crates/kuncode-core/src/completion.rs @@ -11,7 +11,7 @@ pub mod retry; pub mod streaming; pub use message::{ - AssistantContent, Message, Reasoning, ReasoningContent, Text, ToolCall, ToolChoice, + AssistantContent, Message, Reasoning, ReasoningContent, Refusal, Text, ToolCall, ToolChoice, ToolFunction, ToolResult, ToolResultContent, UserContent, }; diff --git a/crates/kuncode-core/src/completion/message.rs b/crates/kuncode-core/src/completion/message.rs index 6139725..82bbb17 100644 --- a/crates/kuncode-core/src/completion/message.rs +++ b/crates/kuncode-core/src/completion/message.rs @@ -150,6 +150,8 @@ pub enum AssistantContent { ToolCall(ToolCall), /// Reasoning/thinking content returned by reasoning-capable models. Reasoning(Reasoning), + /// Safety refusal returned instead of ordinary assistant text. + Refusal(Refusal), } impl AssistantContent { @@ -198,6 +200,26 @@ impl AssistantContent { pub fn reasoning(reasoning: impl AsRef) -> Self { Self::Reasoning(Reasoning::new(reasoning.as_ref())) } + + /// Preserves a provider refusal separately from ordinary assistant text. + pub fn refusal(refusal: impl Into) -> Self { + Self::Refusal(Refusal { + refusal: refusal.into(), + }) + } +} + +/// A safety refusal emitted instead of ordinary assistant content. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +pub struct Refusal { + refusal: String, +} + +impl Refusal { + /// Returns the refusal text. + pub fn text_ref(&self) -> &str { + &self.refusal + } } /// A plain-text content block. @@ -428,6 +450,7 @@ mod tests { Reasoning::summaries(vec!["s1".into(), "s2".into()]) .with_id("r_1".to_string()), ), + AssistantContent::refusal("cannot comply"), ], ), }, diff --git a/crates/kuncode-core/src/completion/streaming.rs b/crates/kuncode-core/src/completion/streaming.rs index 706e0bd..1a5d5a8 100644 --- a/crates/kuncode-core/src/completion/streaming.rs +++ b/crates/kuncode-core/src/completion/streaming.rs @@ -55,6 +55,8 @@ pub enum StreamEvent { /// A chunk of reasoning/thinking text, kept separate from the answer /// (e.g. DeepSeek's `reasoning_content`). Render in a distinct channel. ReasoningDelta(String), + /// A chunk of a safety refusal, kept distinct from ordinary answer text. + RefusalDelta(String), /// A tool call has started: its `id` and `name` are known before the /// arguments finish streaming. Useful for an immediate "calling X" hint; /// the complete call (with assembled arguments) arrives in diff --git a/crates/kuncode-core/src/providers.rs b/crates/kuncode-core/src/providers.rs index c722cc2..5f50135 100644 --- a/crates/kuncode-core/src/providers.rs +++ b/crates/kuncode-core/src/providers.rs @@ -2,9 +2,9 @@ //! //! Each provider owns the mapping from the provider-agnostic //! [`crate::completion`] types to that provider's HTTP API. DeepSeek remains -//! the default, while [`openai_compatible`] supports OpenAI and compatible -//! `/chat/completions` endpoints. +//! the default, while [`openai`] implements the official OpenAI protocol. pub mod any_chat; +pub(crate) mod chat_completions; pub mod deepseek; -pub mod openai_compatible; +pub mod openai; diff --git a/crates/kuncode-core/src/providers/any_chat.rs b/crates/kuncode-core/src/providers/any_chat.rs index a186485..306c22f 100644 --- a/crates/kuncode-core/src/providers/any_chat.rs +++ b/crates/kuncode-core/src/providers/any_chat.rs @@ -8,7 +8,7 @@ use crate::{ }, providers::{ deepseek::{DeepSeekClient, DeepSeekCompletionModel}, - openai_compatible::{OpenAiCompatibleClient, OpenAiCompatibleCompletionModel}, + openai::{OpenAiClient, OpenAiCompletionModel}, }, }; @@ -18,7 +18,7 @@ pub enum AnyChatClient { /// Native DeepSeek protocol behavior. DeepSeek(DeepSeekClient), /// OpenAI-compatible Chat Completions behavior. - OpenAiCompatible(OpenAiCompatibleClient), + OpenAi(OpenAiClient), } /// Model handle that keeps the agent runtime independent of provider choice. @@ -27,7 +27,7 @@ pub enum AnyChatCompletionModel { /// Native DeepSeek model. DeepSeek(DeepSeekCompletionModel), /// OpenAI-compatible model. - OpenAiCompatible(OpenAiCompatibleCompletionModel), + OpenAi(OpenAiCompletionModel), } impl CompletionModel for AnyChatCompletionModel { @@ -40,8 +40,8 @@ impl CompletionModel for AnyChatCompletionModel { AnyChatClient::DeepSeek(client) => { Self::DeepSeek(DeepSeekCompletionModel::make(client, model)) } - AnyChatClient::OpenAiCompatible(client) => { - Self::OpenAiCompatible(OpenAiCompatibleCompletionModel::make(client, model)) + AnyChatClient::OpenAi(client) => { + Self::OpenAi(OpenAiCompletionModel::make(client, model)) } } } @@ -61,7 +61,7 @@ impl CompletionModel for AnyChatCompletionModel { message_id: response.message_id, }) } - Self::OpenAiCompatible(model) => model.completion(request).await, + Self::OpenAi(model) => model.completion(request).await, } } @@ -71,7 +71,7 @@ impl CompletionModel for AnyChatCompletionModel { ) -> Result { match self { Self::DeepSeek(model) => model.stream(request).await, - Self::OpenAiCompatible(model) => model.stream(request).await, + Self::OpenAi(model) => model.stream(request).await, } } } diff --git a/crates/kuncode-core/src/providers/chat_completions.rs b/crates/kuncode-core/src/providers/chat_completions.rs new file mode 100644 index 0000000..c3d92e0 --- /dev/null +++ b/crates/kuncode-core/src/providers/chat_completions.rs @@ -0,0 +1,3 @@ +//! Shared transport primitives for Chat Completions protocol providers. + +pub(crate) mod streaming; diff --git a/crates/kuncode-core/src/providers/deepseek/protocol/streaming.rs b/crates/kuncode-core/src/providers/chat_completions/streaming.rs similarity index 89% rename from crates/kuncode-core/src/providers/deepseek/protocol/streaming.rs rename to crates/kuncode-core/src/providers/chat_completions/streaming.rs index 40cbe30..3be0df8 100644 --- a/crates/kuncode-core/src/providers/deepseek/protocol/streaming.rs +++ b/crates/kuncode-core/src/providers/chat_completions/streaming.rs @@ -1,4 +1,4 @@ -//! DeepSeek Server-Sent Events (SSE) streaming: wire chunk DTOs, an incremental +//! Chat Completions Server-Sent Events (SSE) streaming: wire chunk DTOs, an incremental //! SSE frame decoder, and an assembler that folds chunks into [`StreamEvent`]s. //! //! Split into pure pieces — [`SseDecoder`] (bytes → `data:` payloads) and @@ -7,22 +7,21 @@ //! that drives a live [`reqwest::Response`] body through them. use async_stream::try_stream; -use serde::Deserialize; +use serde::{Deserialize, de::DeserializeOwned}; -use super::Usage; use crate::completion::{ - AssistantContent, CompletionError, CompletionStream, FinishReason, StreamEvent, + AssistantContent, CompletionError, CompletionStream, FinishReason, StreamEvent, Usage, }; use crate::non_empty_vec::NonEmptyVec; /// One `chat.completion.chunk` frame. The terminal usage-only frame carries an /// empty `choices`, so it defaults rather than failing to deserialize. #[derive(Debug, Deserialize)] -struct StreamChunk { +struct StreamChunk { #[serde(default)] choices: Vec, /// Present only on the final frame when `stream_options.include_usage` is set. - usage: Option, + usage: Option, /// Set when the endpoint reports a failure *mid-stream* as a data frame /// instead of via HTTP status; see [`StreamErrorBody`]. error: Option, @@ -54,6 +53,7 @@ struct ChunkChoice { struct ChunkDelta { content: Option, reasoning_content: Option, + refusal: Option, tool_calls: Option>, } @@ -139,6 +139,7 @@ struct PartialToolCall { struct StreamAssembler { text: String, reasoning: String, + refusal: String, tool_calls: Vec, finish_reason: Option, usage: Option, @@ -148,9 +149,12 @@ impl StreamAssembler { /// Folds one chunk in, returning the render deltas it produced (text / /// reasoning / tool-call-start). The terminal [`StreamEvent::Completed`] is /// produced separately by [`finish`](Self::finish). - fn ingest(&mut self, chunk: StreamChunk) -> Vec { - if chunk.usage.is_some() { - self.usage = chunk.usage; + fn ingest(&mut self, chunk: StreamChunk) -> Vec + where + U: Into, + { + if let Some(usage) = chunk.usage { + self.usage = Some(usage.into()); } let mut events = Vec::new(); for choice in chunk.choices { @@ -162,6 +166,10 @@ impl StreamAssembler { self.reasoning.push_str(&reasoning); events.push(StreamEvent::ReasoningDelta(reasoning)); } + if let Some(refusal) = choice.delta.refusal.filter(|s| !s.is_empty()) { + self.refusal.push_str(&refusal); + events.push(StreamEvent::RefusalDelta(refusal)); + } for delta in choice.delta.tool_calls.into_iter().flatten() { self.ingest_tool_call(delta, &mut events); } @@ -244,6 +252,9 @@ impl StreamAssembler { if !self.reasoning.is_empty() { content.push(AssistantContent::reasoning(self.reasoning)); } + if !self.refusal.is_empty() { + content.push(AssistantContent::refusal(self.refusal)); + } let content = NonEmptyVec::try_from(content).map_err(|err| { CompletionError::ResponseError(format!("stream produced no assistant content: {err}")) @@ -251,7 +262,7 @@ impl StreamAssembler { Ok(StreamEvent::Completed { content, - usage: self.usage.unwrap_or_default().into(), + usage: self.usage.unwrap_or_default(), finish_reason: map_finish_reason(self.finish_reason.as_deref()), }) } @@ -266,7 +277,7 @@ fn stream_error(error: StreamErrorBody) -> CompletionError { CompletionError::ResponseError(format!("provider reported a mid-stream error: {detail}")) } -/// Maps DeepSeek's stop-reason string onto the neutral [`FinishReason`]. A +/// Maps a Chat Completions stop-reason string onto the neutral [`FinishReason`]. A /// missing reason (stream ended without one) reads as a natural stop. fn map_finish_reason(reason: Option<&str>) -> FinishReason { match reason { @@ -286,7 +297,10 @@ fn map_finish_reason(reason: Option<&str>) -> FinishReason { /// [`StreamAssembler`], yielding render deltas as they arrive and a final /// [`StreamEvent::Completed`] once the body ends or `[DONE]` is seen. Dropping /// the returned stream closes the HTTP response and halts generation. -pub(crate) fn stream_events(mut response: reqwest::Response) -> CompletionStream { +pub(crate) fn stream_events(mut response: reqwest::Response) -> CompletionStream +where + U: DeserializeOwned + Into + Send + 'static, +{ Box::pin(try_stream! { let mut decoder = SseDecoder::new(); let mut assembler = StreamAssembler::default(); @@ -295,7 +309,7 @@ pub(crate) fn stream_events(mut response: reqwest::Response) -> CompletionStream match event { SseEvent::Done => break 'body, SseEvent::Data(payload) => { - let mut chunk: StreamChunk = serde_json::from_str(&payload)?; + let mut chunk: StreamChunk = serde_json::from_str(&payload)?; // A mid-stream error frame ends the stream with an error; // otherwise the partial answer would be assembled and // reported as a clean completion. @@ -316,6 +330,7 @@ pub(crate) fn stream_events(mut response: reqwest::Response) -> CompletionStream #[cfg(test)] mod tests { use super::*; + use crate::providers::deepseek::protocol::Usage as TestUsage; /// Runs `sse` through a fresh decoder + assembler, splitting the input into /// `chunk_size`-byte network chunks to exercise cross-chunk buffering. @@ -329,7 +344,7 @@ mod tests { match ev { SseEvent::Done => done = true, SseEvent::Data(payload) => { - let chunk: StreamChunk = + let chunk: StreamChunk = serde_json::from_str(&payload).expect("chunk json"); events.extend(assembler.ingest(chunk)); } @@ -430,6 +445,33 @@ data: [DONE] ); } + #[test] + fn refusal_streams_and_is_preserved_in_completed_content() { + let sse = "\ +data: {\"choices\":[{\"delta\":{\"refusal\":\"Cannot \"}}]} + +data: {\"choices\":[{\"delta\":{\"refusal\":\"comply\"},\"finish_reason\":\"stop\"}]} + +data: [DONE] + +"; + let events = run(sse, 4096); + let refusal: String = events + .iter() + .filter_map(|event| match event { + StreamEvent::RefusalDelta(text) => Some(text.clone()), + _ => None, + }) + .collect(); + assert_eq!(refusal, "Cannot comply"); + + let (content, _) = completed(events); + assert!(matches!( + content.first(), + AssistantContent::Refusal(value) if value.text_ref() == "Cannot comply" + )); + } + #[test] fn tool_call_arguments_assemble_across_fragments() { let sse = "\ @@ -480,7 +522,7 @@ data: [DONE] Some(SseEvent::Data(p)) => p, other => panic!("expected a data line, got {other:?}"), }; - assert!(serde_json::from_str::(payload).is_err()); + assert!(serde_json::from_str::>(payload).is_err()); } #[test] @@ -510,7 +552,7 @@ data: {\"error\":{\"message\":\"rate limited\",\"type\":\"server_error\"}} 'outer: for piece in sse.as_bytes().chunks(4096) { for ev in decoder.push(piece) { if let SseEvent::Data(payload) = ev { - let mut chunk: StreamChunk = + let mut chunk: StreamChunk = serde_json::from_str(&payload).expect("chunk json"); if let Some(err) = chunk.error.take() { error = Some(stream_error(err)); @@ -534,10 +576,10 @@ data: {\"error\":{\"message\":\"rate limited\",\"type\":\"server_error\"}} // The standard trio is required: a usage object missing one is malformed // and must fail the parse, not silently read as zero. let bad = r#"{"choices":[],"usage":{"prompt_tokens":3,"completion_tokens":2}}"#; - assert!(serde_json::from_str::(bad).is_err()); + assert!(serde_json::from_str::>(bad).is_err()); // The DeepSeek cache extensions, by contrast, may be omitted. let ok = r#"{"choices":[],"usage":{"prompt_tokens":3,"completion_tokens":2,"total_tokens":5}}"#; - assert!(serde_json::from_str::(ok).is_ok()); + assert!(serde_json::from_str::>(ok).is_ok()); } } diff --git a/crates/kuncode-core/src/providers/deepseek.rs b/crates/kuncode-core/src/providers/deepseek.rs index 161b85f..30ef00b 100644 --- a/crates/kuncode-core/src/providers/deepseek.rs +++ b/crates/kuncode-core/src/providers/deepseek.rs @@ -205,7 +205,11 @@ impl CompletionModel for DeepSeekCompletionModel { }); } - Ok(protocol::streaming::stream_events(response)) + Ok( + crate::providers::chat_completions::streaming::stream_events::( + response, + ), + ) } } diff --git a/crates/kuncode-core/src/providers/deepseek/protocol.rs b/crates/kuncode-core/src/providers/deepseek/protocol.rs index f38a071..25bcc9d 100644 --- a/crates/kuncode-core/src/providers/deepseek/protocol.rs +++ b/crates/kuncode-core/src/providers/deepseek/protocol.rs @@ -19,8 +19,6 @@ use crate::{ non_empty_vec::NonEmptyVec, }; -pub(crate) mod streaming; - /// DeepSeek wire message serialized by `role`. /// /// This is flatter than the domain-side [`message::Message`]: `content` is a @@ -160,6 +158,9 @@ impl From for Vec { .as_str(), ); } + message::AssistantContent::Refusal(refusal) => { + text_content.push_str(refusal.text_ref()); + } } } diff --git a/crates/kuncode-core/src/providers/openai.rs b/crates/kuncode-core/src/providers/openai.rs new file mode 100644 index 0000000..3ce23d5 --- /dev/null +++ b/crates/kuncode-core/src/providers/openai.rs @@ -0,0 +1,175 @@ +//! Official OpenAI Chat Completions provider. + +use std::{env::VarError, time::Duration}; + +use reqwest::header::CONTENT_TYPE; +use serde_json::Value; +use thiserror::Error; + +use crate::{ + completion::{CompletionError, CompletionModel, CompletionRequest, CompletionResponse}, + json_utils, +}; + +use self::protocol::{OpenAiCompletionRequest, OpenAiCompletionResponse, Usage}; + +mod protocol; + +const OPENAI_COMPLETIONS_URL: &str = "https://api.openai.com/v1/chat/completions"; +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +const READ_TIMEOUT: Duration = Duration::from_secs(360); +const REQUEST_TIMEOUT: Duration = Duration::from_secs(360); + +/// Errors produced while constructing an OpenAI client. +#[derive(Debug, Error)] +pub enum Error { + /// The underlying HTTP client could not be built. + #[error("HTTP client error: {0}")] + 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), +} + +/// Authenticated client for the official OpenAI API. +#[derive(Clone)] +pub struct OpenAiClient { + http_client: reqwest::Client, + api_key: String, +} + +impl OpenAiClient { + /// Builds a client for the fixed official OpenAI endpoint. + /// + /// # Errors + /// + /// Returns [`enum@Error`] when the HTTP client cannot be configured. + pub fn new(api_key: impl Into) -> Result { + let http_client = reqwest::Client::builder() + .read_timeout(READ_TIMEOUT) + .connect_timeout(CONNECT_TIMEOUT) + .build()?; + Ok(Self { + http_client, + api_key: api_key.into(), + }) + } + + /// Reads `OPENAI_API_KEY` and builds an official OpenAI client. + /// + /// # Errors + /// + /// Returns [`Error::EnvironmentVariable`] when the credential is unavailable, + /// or [`Error::Client`] when the HTTP client cannot be configured. + pub fn from_env() -> Result { + let api_key = std::env::var("OPENAI_API_KEY").map_err(Error::EnvironmentVariable)?; + Self::new(api_key) + } + + fn post(&self) -> reqwest::RequestBuilder { + self.http_client + .post(OPENAI_COMPLETIONS_URL) + .bearer_auth(&self.api_key) + } +} + +/// Completion model for the official OpenAI Chat Completions API. +#[derive(Clone)] +pub struct OpenAiCompletionModel { + client: OpenAiClient, + model: String, +} + +impl CompletionModel for OpenAiCompletionModel { + type Response = Value; + type Client = OpenAiClient; + + fn make(client: &Self::Client, model: impl Into) -> Self { + Self { + client: client.clone(), + model: model.into(), + } + } + + async fn completion( + &self, + mut request: CompletionRequest, + ) -> Result, CompletionError> { + request.model.get_or_insert_with(|| self.model.clone()); + let extra = request.additional_params.take(); + let wire = OpenAiCompletionRequest::try_from(request)?; + let builder = self.client.post().timeout(REQUEST_TIMEOUT); + let response = match extra { + Some(extra) => { + let body = json_utils::merge(serde_json::to_value(&wire)?, extra); + builder.json(&body).send().await? + } + None => builder.json(&wire).send().await?, + }; + let status = response.status(); + if !status.is_success() { + return Err(CompletionError::ApiError { + status: status.as_u16(), + message: response.text().await.unwrap_or_default(), + }); + } + let raw: Value = serde_json::from_slice(&response.bytes().await?)?; + normalize_response(raw) + } + + async fn stream( + &self, + mut request: CompletionRequest, + ) -> Result { + request.model.get_or_insert_with(|| self.model.clone()); + let extra = request.additional_params.take(); + let wire = OpenAiCompletionRequest::try_from(request)?.into_streaming(); + let builder = self.client.post(); + let response = match extra { + Some(extra) => { + let body = json_utils::merge(serde_json::to_value(&wire)?, extra); + builder.json(&body).send().await? + } + None => builder.json(&wire).send().await?, + }; + let status = response.status(); + if !status.is_success() { + return Err(CompletionError::ApiError { + status: status.as_u16(), + message: response.text().await.unwrap_or_default(), + }); + } + validate_stream_content_type( + response + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + )?; + Ok(crate::providers::chat_completions::streaming::stream_events::(response)) + } +} + +fn normalize_response(raw: Value) -> Result, CompletionError> { + let response: OpenAiCompletionResponse = serde_json::from_value(raw.clone())?; + let normalized: CompletionResponse = response.try_into()?; + Ok(CompletionResponse { + choice: normalized.choice, + usage: normalized.usage, + raw_response: raw, + message_id: normalized.message_id, + }) +} + +fn validate_stream_content_type(content_type: Option<&str>) -> Result<(), CompletionError> { + let Some(content_type) = content_type else { + return Ok(()); + }; + let media_type = content_type.split(';').next().unwrap_or_default().trim(); + if media_type.eq_ignore_ascii_case("text/event-stream") { + Ok(()) + } else { + Err(CompletionError::ResponseError(format!( + "expected an OpenAI SSE response, but received `{media_type}`" + ))) + } +} diff --git a/crates/kuncode-core/src/providers/openai/protocol.rs b/crates/kuncode-core/src/providers/openai/protocol.rs new file mode 100644 index 0000000..7864244 --- /dev/null +++ b/crates/kuncode-core/src/providers/openai/protocol.rs @@ -0,0 +1,472 @@ +//! OpenAI Chat Completions wire DTOs and domain mappings. + +use serde::{Deserialize, Serialize}; + +use crate::{ + completion::{self, AssistantContent, CompletionError, message}, + json_utils, + non_empty_vec::NonEmptyVec, +}; + +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +#[serde(tag = "role", rename_all = "lowercase")] +pub(crate) enum Message { + System { + content: String, + }, + User { + content: String, + }, + Assistant { + #[serde(default, deserialize_with = "json_utils::null_or_default")] + content: String, + #[serde(skip_serializing_if = "Option::is_none")] + refusal: Option, + #[serde( + default, + deserialize_with = "json_utils::null_or_vec", + skip_serializing_if = "Vec::is_empty" + )] + tool_calls: Vec, + }, + #[serde(rename = "tool")] + ToolResult { + tool_call_id: String, + content: String, + }, +} + +impl From for Vec { + fn from(value: message::Message) -> Self { + match value { + message::Message::System { content } => vec![Message::System { content }], + message::Message::User { content } => { + let mut messages = Vec::with_capacity(content.len()); + let mut text = Vec::new(); + for block in content { + match block { + message::UserContent::Text(value) => text.push(value.text()), + message::UserContent::ToolResult(result) => { + messages.push(Message::from(result)); + } + } + } + if !text.is_empty() { + messages.push(Message::User { + content: text.join("\n"), + }); + } + messages + } + message::Message::Assistant { id: _, content } => { + let mut text = String::new(); + let mut refusal = String::new(); + let mut tool_calls = Vec::new(); + for block in content { + match block { + message::AssistantContent::Text(value) => text.push_str(value.text_ref()), + message::AssistantContent::Refusal(value) => { + refusal.push_str(value.text_ref()); + } + message::AssistantContent::ToolCall(call) => { + tool_calls.push(ToolCall::from(call)); + } + // Chat Completions does not accept replayed reasoning text. + message::AssistantContent::Reasoning(_) => {} + } + } + vec![Message::Assistant { + content: text, + refusal: (!refusal.is_empty()).then_some(refusal), + tool_calls, + }] + } + } + } +} + +impl From for Message { + fn from(value: message::ToolResult) -> Self { + let content = value + .content + .iter() + .map(|block| match block { + message::ToolResultContent::Text(text) => text.text_ref(), + }) + .collect::>() + .join("\n"); + Self::ToolResult { + tool_call_id: value.call_id.unwrap_or(value.id), + content, + } + } +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +pub(crate) struct ToolCall { + id: String, + #[serde(rename = "type")] + kind: ToolType, + function: Function, +} + +impl From for ToolCall { + fn from(value: message::ToolCall) -> Self { + Self { + id: value.call_id.unwrap_or(value.id), + kind: ToolType::Function, + function: Function { + name: value.function.name, + arguments: value.function.arguments, + }, + } + } +} + +#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Clone)] +#[serde(rename_all = "lowercase")] +enum ToolType { + #[default] + Function, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +struct Function { + name: String, + #[serde(with = "json_utils::stringified_json")] + arguments: serde_json::Value, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct ToolDefinition { + #[serde(rename = "type")] + kind: ToolType, + function: completion::ToolDefinition, +} + +impl From for ToolDefinition { + fn from(function: completion::ToolDefinition) -> Self { + Self { + kind: ToolType::Function, + function, + } + } +} + +#[derive(Debug, Serialize)] +pub(crate) struct OpenAiCompletionRequest { + model: String, + messages: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + tools: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + tool_choice: Option, + #[serde(skip_serializing_if = "Option::is_none")] + max_completion_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + temperature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + top_p: Option, + #[serde(skip_serializing_if = "Option::is_none")] + stop: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + reasoning_effort: Option, + #[serde(skip_serializing_if = "Option::is_none")] + response_format: Option, + #[serde(skip_serializing_if = "Option::is_none")] + stream: Option, + #[serde(skip_serializing_if = "Option::is_none")] + stream_options: Option, +} + +impl TryFrom for OpenAiCompletionRequest { + type Error = CompletionError; + + fn try_from(request: completion::CompletionRequest) -> Result { + let model = request.model.ok_or_else(|| { + CompletionError::RequestError("OpenAI request is missing a model ID".to_string()) + })?; + let messages = request + .chat_history + .into_iter() + .flat_map(Vec::::from) + .collect(); + Ok(Self { + model, + messages, + tools: request + .tools + .into_iter() + .map(ToolDefinition::from) + .collect(), + tool_choice: request.tool_choice.map(ToolChoice::from), + max_completion_tokens: request.max_tokens.map(|value| value as u32), + temperature: request.temperature, + top_p: request.top_p, + stop: request.stop.filter(|value| !value.is_empty()), + reasoning_effort: request.reasoning.map(ReasoningEffort::from), + response_format: request.output_schema.map(ResponseFormat::json_schema), + stream: None, + stream_options: None, + }) + } +} + +impl OpenAiCompletionRequest { + pub(crate) fn into_streaming(mut self) -> Self { + self.stream = Some(true); + self.stream_options = Some(StreamOptions { + include_usage: true, + }); + self + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "snake_case")] +enum ReasoningEffort { + None, + Minimal, + Low, + Medium, + High, + Xhigh, +} + +impl From for ReasoningEffort { + fn from(value: completion::ReasoningEffort) -> Self { + match value { + completion::ReasoningEffort::Off => Self::None, + completion::ReasoningEffort::Minimal => Self::Minimal, + completion::ReasoningEffort::Low => Self::Low, + completion::ReasoningEffort::Medium => Self::Medium, + completion::ReasoningEffort::High => Self::High, + completion::ReasoningEffort::Xhigh => Self::Xhigh, + } + } +} + +#[derive(Debug, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum ResponseFormat { + JsonSchema { json_schema: JsonSchema }, +} + +impl ResponseFormat { + fn json_schema(schema: serde_json::Value) -> Self { + Self::JsonSchema { + json_schema: JsonSchema { + name: "kuncode_output", + schema, + strict: true, + }, + } + } +} + +#[derive(Debug, Serialize)] +struct JsonSchema { + name: &'static str, + schema: serde_json::Value, + strict: bool, +} + +#[derive(Debug, Serialize)] +struct StreamOptions { + include_usage: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "lowercase")] +enum ToolChoice { + None, + Auto, + Required, + #[serde(untagged)] + Function(ToolChoiceFunction), +} + +#[derive(Debug, Serialize)] +#[serde(tag = "type", content = "function", rename_all = "lowercase")] +enum ToolChoiceFunction { + Function { name: String }, +} + +impl From for ToolChoice { + fn from(value: message::ToolChoice) -> Self { + match value { + message::ToolChoice::None => Self::None, + message::ToolChoice::Auto => Self::Auto, + message::ToolChoice::Required => Self::Required, + message::ToolChoice::Specific { function_name } => { + Self::Function(ToolChoiceFunction::Function { + name: function_name, + }) + } + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub(crate) struct OpenAiCompletionResponse { + pub(crate) id: String, + pub(crate) choices: Vec, + pub(crate) created: u64, + pub(crate) model: String, + pub(crate) object: String, + #[serde(default)] + pub(crate) system_fingerprint: Option, + #[serde(default)] + pub(crate) usage: Usage, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub(crate) struct Choice { + finish_reason: String, + index: usize, + message: Message, + logprobs: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub(crate) struct Usage { + completion_tokens: u32, + prompt_tokens: u32, + total_tokens: u32, + #[serde(default)] + completion_tokens_details: Option, + #[serde(default)] + prompt_tokens_details: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +struct CompletionTokenDetails { + #[serde(default)] + reasoning_tokens: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +struct PromptTokenDetails { + #[serde(default)] + cached_tokens: u32, +} + +impl From for completion::Usage { + fn from(value: Usage) -> Self { + Self { + input_tokens: u64::from(value.prompt_tokens), + output_tokens: u64::from(value.completion_tokens), + total_tokens: u64::from(value.total_tokens), + cached_input_tokens: value + .prompt_tokens_details + .map_or(0, |details| u64::from(details.cached_tokens)), + cache_creation_input_tokens: 0, + reasoning_tokens: value + .completion_tokens_details + .map_or(0, |details| u64::from(details.reasoning_tokens)), + } + } +} + +impl TryFrom + for completion::CompletionResponse +{ + type Error = CompletionError; + + fn try_from(response: OpenAiCompletionResponse) -> Result { + let choice = response.choices.first().ok_or_else(|| { + CompletionError::ResponseError("OpenAI response contained no choices".to_string()) + })?; + let Message::Assistant { + content, + refusal, + tool_calls, + } = &choice.message + else { + return Err(CompletionError::ResponseError( + "OpenAI response did not contain an assistant message".to_string(), + )); + }; + let mut blocks = Vec::new(); + if !content.trim().is_empty() { + blocks.push(AssistantContent::text(content)); + } + blocks.extend(tool_calls.iter().map(|call| { + AssistantContent::tool_call( + &call.id, + &call.function.name, + call.function.arguments.clone(), + ) + })); + if let Some(refusal) = refusal.as_ref().filter(|value| !value.is_empty()) { + blocks.push(AssistantContent::refusal(refusal)); + } + let blocks = NonEmptyVec::try_from(blocks).map_err(|error| { + CompletionError::ResponseError(format!( + "OpenAI response contained no assistant content: {error}" + )) + })?; + Ok(completion::CompletionResponse { + choice: blocks, + usage: response.usage.clone().into(), + raw_response: response, + message_id: None, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::completion::{CompletionRequestBuilder, Message as DomainMessage, ReasoningEffort}; + + #[test] + fn maps_openai_specific_request_fields() { + let schema = serde_json::json!({ + "type": "object", + "properties": { "answer": { "type": "string" } }, + "required": ["answer"], + "additionalProperties": false + }); + let request = CompletionRequestBuilder::new(DomainMessage::user("test")) + .model("gpt-test") + .max_tokens(Some(512)) + .reasoning(Some(ReasoningEffort::Off)) + .output_schema(Some(schema.clone())) + .build(); + let wire = OpenAiCompletionRequest::try_from(request).expect("wire request"); + let json = serde_json::to_value(wire).expect("serialize request"); + + assert_eq!(json["max_completion_tokens"], 512); + assert!(json.get("max_tokens").is_none()); + assert_eq!(json["reasoning_effort"], "none"); + assert_eq!(json["response_format"]["type"], "json_schema"); + assert_eq!(json["response_format"]["json_schema"]["strict"], true); + assert_eq!(json["response_format"]["json_schema"]["schema"], schema); + } + + #[test] + fn preserves_refusal_as_assistant_content() { + let response: OpenAiCompletionResponse = serde_json::from_value(serde_json::json!({ + "id": "chatcmpl-test", + "choices": [{ + "finish_reason": "stop", + "index": 0, + "message": {"role": "assistant", "content": null, "refusal": "Cannot comply"}, + "logprobs": null + }], + "created": 1, + "model": "gpt-test", + "object": "chat.completion", + "usage": {"prompt_tokens": 4, "completion_tokens": 2, "total_tokens": 6} + })) + .expect("response fixture"); + let normalized: completion::CompletionResponse<_> = + response.try_into().expect("normalize response"); + + assert!(matches!( + normalized.choice.first(), + AssistantContent::Refusal(value) if value.text_ref() == "Cannot comply" + )); + } +} diff --git a/crates/kuncode-core/src/providers/openai_compatible.rs b/crates/kuncode-core/src/providers/openai_compatible.rs deleted file mode 100644 index f958d49..0000000 --- a/crates/kuncode-core/src/providers/openai_compatible.rs +++ /dev/null @@ -1,325 +0,0 @@ -//! OpenAI-compatible `/chat/completions` provider with configurable endpoint. - -use std::time::Duration; - -use reqwest::header::CONTENT_TYPE; -use serde_json::Value; -use thiserror::Error; - -use crate::{ - completion::{ - CompletionError, CompletionModel, CompletionRequest, CompletionResponse, ReasoningEffort, - }, - json_utils, - providers::deepseek::protocol::{DeepSeekCompletionRequest, DeepSeekCompletionResponse}, -}; - -const DEFAULT_BASE_URL: &str = "https://api.openai.com/v1"; -const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); -const READ_TIMEOUT: Duration = Duration::from_secs(360); -const REQUEST_TIMEOUT: Duration = Duration::from_secs(360); - -/// Errors produced while constructing an OpenAI-compatible client. -#[derive(Debug, Error)] -pub enum Error { - /// The endpoint is empty and cannot be normalized. - #[error("OpenAI-compatible base URL must not be blank")] - BlankBaseUrl, - /// The underlying HTTP client could not be built. - #[error("HTTP client error: {0}")] - Client(#[from] reqwest::Error), -} - -/// Authenticated client for OpenAI and compatible Chat Completions endpoints. -#[derive(Clone)] -pub struct OpenAiCompatibleClient { - http_client: reqwest::Client, - api_key: String, - endpoint: String, -} - -impl OpenAiCompatibleClient { - /// Builds a client, appending `/chat/completions` when `base_url` names a - /// service root such as `https://api.openai.com/v1`. - /// - /// An empty API key is accepted for local endpoints that do not authenticate. - /// - /// # Errors - /// Returns [`enum@Error`] when the base URL is blank or the HTTP client fails. - pub fn new(api_key: impl Into, base_url: impl AsRef) -> Result { - let endpoint = completion_endpoint(base_url.as_ref())?; - let http_client = reqwest::Client::builder() - .read_timeout(READ_TIMEOUT) - .connect_timeout(CONNECT_TIMEOUT) - .build()?; - Ok(Self { - http_client, - api_key: api_key.into(), - endpoint, - }) - } - - /// Builds a client for the official OpenAI endpoint. - /// - /// # Errors - /// Returns [`enum@Error`] when the HTTP client cannot be built. - pub fn openai(api_key: impl Into) -> Result { - Self::new(api_key, DEFAULT_BASE_URL) - } - - fn post(&self) -> reqwest::RequestBuilder { - let request = self.http_client.post(&self.endpoint); - if self.api_key.is_empty() { - request - } else { - request.bearer_auth(&self.api_key) - } - } -} - -fn completion_endpoint(base_url: &str) -> Result { - let base_url = base_url.trim().trim_end_matches('/'); - if base_url.is_empty() { - return Err(Error::BlankBaseUrl); - } - if base_url.ends_with("/chat/completions") { - Ok(base_url.to_string()) - } else { - Ok(format!("{base_url}/chat/completions")) - } -} - -/// Completion model for OpenAI-compatible Chat Completions APIs. -#[derive(Clone)] -pub struct OpenAiCompatibleCompletionModel { - client: OpenAiCompatibleClient, - model: String, -} - -impl CompletionModel for OpenAiCompatibleCompletionModel { - type Response = Value; - type Client = OpenAiCompatibleClient; - - fn make(client: &Self::Client, model: impl Into) -> Self { - Self { - client: client.clone(), - model: model.into(), - } - } - - async fn completion( - &self, - request: CompletionRequest, - ) -> Result, CompletionError> { - let body = request_body(request, &self.model, false)?; - let response = self - .client - .post() - .timeout(REQUEST_TIMEOUT) - .json(&body) - .send() - .await?; - let status = response.status(); - if !status.is_success() { - return Err(CompletionError::ApiError { - status: status.as_u16(), - message: response.text().await.unwrap_or_default(), - }); - } - - let raw: Value = serde_json::from_slice(&response.bytes().await?)?; - normalize_response(raw) - } - - async fn stream( - &self, - request: CompletionRequest, - ) -> Result { - let body = request_body(request, &self.model, true)?; - let response = self.client.post().json(&body).send().await?; - let status = response.status(); - if !status.is_success() { - return Err(CompletionError::ApiError { - status: status.as_u16(), - message: response.text().await.unwrap_or_default(), - }); - } - validate_stream_content_type( - response - .headers() - .get(CONTENT_TYPE) - .and_then(|value| value.to_str().ok()), - )?; - Ok(crate::providers::deepseek::protocol::streaming::stream_events(response)) - } -} - -fn validate_stream_content_type(content_type: Option<&str>) -> Result<(), CompletionError> { - let Some(content_type) = content_type else { - return Ok(()); - }; - let media_type = content_type.split(';').next().unwrap_or_default().trim(); - if media_type.eq_ignore_ascii_case("text/event-stream") { - return Ok(()); - } - - Err(CompletionError::ResponseError(format!( - "expected an SSE response with content type `text/event-stream`, but the provider returned `{media_type}`; verify `baseUrl` points to the API root (usually ending in `/v1`) or the full `/chat/completions` endpoint" - ))) -} - -fn request_body( - mut request: CompletionRequest, - model: &str, - streaming: bool, -) -> Result { - request.model.get_or_insert_with(|| model.to_string()); - let extra = request.additional_params.take(); - let reasoning = request.reasoning.take(); - let request = DeepSeekCompletionRequest::try_from(request)?; - let mut body = if streaming { - serde_json::to_value(request.into_streaming())? - } else { - serde_json::to_value(request)? - }; - - // The shared DTO carries DeepSeek's `thinking` object. OpenAI-compatible - // endpoints use the flat `reasoning_effort` field instead. - if let Value::Object(fields) = &mut body { - fields.remove("thinking"); - fields.remove("reasoning_effort"); - if let Some(effort) = openai_reasoning_effort(reasoning) { - fields.insert( - "reasoning_effort".to_string(), - Value::String(effort.to_string()), - ); - } - } - - match extra { - Some(extra) => Ok(json_utils::merge(body, extra)), - None => Ok(body), - } -} - -fn normalize_response(raw: Value) -> Result, CompletionError> { - let provider: DeepSeekCompletionResponse = serde_json::from_value(raw.clone())?; - let normalized: CompletionResponse = provider.try_into()?; - Ok(CompletionResponse { - choice: normalized.choice, - usage: normalized.usage, - raw_response: raw, - message_id: normalized.message_id, - }) -} - -fn openai_reasoning_effort(effort: Option) -> Option<&'static str> { - match effort { - None | Some(ReasoningEffort::Off) => None, - Some(ReasoningEffort::Minimal) => Some("minimal"), - Some(ReasoningEffort::Low) => Some("low"), - Some(ReasoningEffort::Medium) => Some("medium"), - Some(ReasoningEffort::High) => Some("high"), - Some(ReasoningEffort::Xhigh) => Some("xhigh"), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::completion::{AssistantContent, CompletionRequestBuilder, Message}; - - #[test] - fn appends_chat_completions_to_service_root() { - assert_eq!( - completion_endpoint("https://api.openai.com/v1/").expect("valid endpoint"), - "https://api.openai.com/v1/chat/completions" - ); - } - - #[test] - fn preserves_full_chat_completions_endpoint() { - assert_eq!( - completion_endpoint("http://localhost:8000/v1/chat/completions") - .expect("valid endpoint"), - "http://localhost:8000/v1/chat/completions" - ); - } - - #[test] - fn accepts_sse_content_type_with_parameters() { - validate_stream_content_type(Some("text/event-stream; charset=utf-8")) - .expect("SSE content type should be accepted"); - } - - #[test] - fn accepts_missing_content_type_for_compatible_gateways() { - validate_stream_content_type(None) - .expect("a missing content type should fall through to the SSE decoder"); - } - - #[test] - fn rejects_html_with_base_url_guidance() { - let error = validate_stream_content_type(Some("text/html; charset=utf-8")) - .expect_err("HTML is not an SSE response") - .to_string(); - - assert!(error.contains("`text/html`")); - assert!(error.contains("`baseUrl`")); - assert!(error.contains("/v1")); - } - - #[test] - fn request_uses_openai_reasoning_field_without_deepseek_thinking() { - let request = CompletionRequestBuilder::new(Message::user("test")) - .reasoning(Some(ReasoningEffort::Low)) - .build(); - let body = request_body(request, "gpt-test", true).expect("request body"); - - assert_eq!(body["reasoning_effort"], "low"); - assert!(body.get("thinking").is_none()); - assert_eq!(body["stream_options"]["include_usage"], true); - } - - #[test] - fn accepts_openai_tool_call_with_null_content_and_no_fingerprint() { - let response = normalize_response(serde_json::json!({ - "id": "chatcmpl-test", - "choices": [{ - "finish_reason": "tool_calls", - "index": 0, - "message": { - "role": "assistant", - "content": null, - "tool_calls": [{ - "id": "call-1", - "type": "function", - "function": { - "name": "bash", - "arguments": "{\"cmd\":\"pwd\"}" - } - }] - }, - "logprobs": null - }], - "created": 1, - "model": "gpt-test", - "object": "chat.completion", - "usage": { - "prompt_tokens": 10, - "completion_tokens": 4, - "total_tokens": 14, - "prompt_tokens_details": { "cached_tokens": 3 } - } - })) - .expect("OpenAI response normalizes"); - - assert_eq!(response.usage.cached_input_tokens, 3); - assert!(matches!( - response.choice.first(), - AssistantContent::ToolCall(call) - if call.function.name == "bash" - && call.function.arguments.get("cmd").and_then(Value::as_str) == Some("pwd") - )); - } -} From 2ff8ad1c5057a73b2bf67b601cb85a497fa8c631 Mon Sep 17 00:00:00 2001 From: wenzr <282277167@qq.com> Date: Sun, 26 Jul 2026 21:55:03 +0800 Subject: [PATCH 4/8] =?UTF-8?q?Provider:=20=E4=BF=AE=E5=A4=8D=20OpenAI=20?= =?UTF-8?q?=E5=8D=8F=E8=AE=AE=E6=AD=A3=E7=A1=AE=E6=80=A7=E5=B9=B6=E6=94=B6?= =?UTF-8?q?=E6=95=9B=20Refusal=20=E5=BB=BA=E6=A8=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 评审第二轮反馈: - 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 --- .../src/compaction/artifact/spill/runtime.rs | 4 +- .../src/compaction/protocol/grouping.rs | 4 +- .../src/compaction/slimming/marker.rs | 1 - crates/kuncode-agent/src/runner/iteration.rs | 4 - crates/kuncode-agent/src/runner/request.rs | 1 - crates/kuncode-agent/src/session_store/dto.rs | 3 - .../src/session_store/dto/conversion.rs | 4 - crates/kuncode-cli/src/settings.rs | 82 +++++++--- crates/kuncode-core/src/completion.rs | 2 +- crates/kuncode-core/src/completion/message.rs | 23 --- .../kuncode-core/src/completion/streaming.rs | 2 - crates/kuncode-core/src/providers/any_chat.rs | 5 + .../providers/chat_completions/streaming.rs | 60 ++++++-- crates/kuncode-core/src/providers/deepseek.rs | 7 + .../src/providers/deepseek/protocol.rs | 17 +-- crates/kuncode-core/src/providers/openai.rs | 66 +++++++-- .../src/providers/openai/protocol.rs | 140 ++++++++++++++---- 17 files changed, 298 insertions(+), 127 deletions(-) diff --git a/crates/kuncode-agent/src/compaction/artifact/spill/runtime.rs b/crates/kuncode-agent/src/compaction/artifact/spill/runtime.rs index fe4337c..fd1214d 100644 --- a/crates/kuncode-agent/src/compaction/artifact/spill/runtime.rs +++ b/crates/kuncode-agent/src/compaction/artifact/spill/runtime.rs @@ -255,9 +255,7 @@ fn call_names(message: &Message) -> BTreeMap { if let Message::Assistant { content, .. } = message { for call in content.iter().filter_map(|block| match block { AssistantContent::ToolCall(call) => Some(call), - AssistantContent::Text(_) - | AssistantContent::Reasoning(_) - | AssistantContent::Refusal(_) => None, + AssistantContent::Text(_) | AssistantContent::Reasoning(_) => None, }) { names.insert(call.id.clone(), call.function.name.clone()); } diff --git a/crates/kuncode-agent/src/compaction/protocol/grouping.rs b/crates/kuncode-agent/src/compaction/protocol/grouping.rs index dbd3929..7dd4d97 100644 --- a/crates/kuncode-agent/src/compaction/protocol/grouping.rs +++ b/crates/kuncode-agent/src/compaction/protocol/grouping.rs @@ -127,9 +127,7 @@ pub fn group_messages(messages: &[Message]) -> Result, Protoc .iter() .filter_map(|block| match block { AssistantContent::ToolCall(call) => Some(call), - AssistantContent::Text(_) - | AssistantContent::Reasoning(_) - | AssistantContent::Refusal(_) => None, + AssistantContent::Text(_) | AssistantContent::Reasoning(_) => None, }) .collect::>(); if calls.is_empty() { diff --git a/crates/kuncode-agent/src/compaction/slimming/marker.rs b/crates/kuncode-agent/src/compaction/slimming/marker.rs index 2d40be1..0ad7770 100644 --- a/crates/kuncode-agent/src/compaction/slimming/marker.rs +++ b/crates/kuncode-agent/src/compaction/slimming/marker.rs @@ -139,7 +139,6 @@ fn assistant_call<'a>( AssistantContent::ToolCall(call) if call.id == result_id => Some(call), AssistantContent::Text(_) | AssistantContent::Reasoning(_) - | AssistantContent::Refusal(_) | AssistantContent::ToolCall(_) => None, }) } diff --git a/crates/kuncode-agent/src/runner/iteration.rs b/crates/kuncode-agent/src/runner/iteration.rs index 7841ace..ab89bd9 100644 --- a/crates/kuncode-agent/src/runner/iteration.rs +++ b/crates/kuncode-agent/src/runner/iteration.rs @@ -144,9 +144,6 @@ where StreamEvent::ReasoningDelta(text) => { self.emit(session, Some(iteration), EventKind::ReasoningDelta { text }); } - StreamEvent::RefusalDelta(text) => { - self.emit(session, Some(iteration), EventKind::TextDelta { text }); - } // The "calling X" hint is surfaced by `ToolStart` after the turn // completes and the call is gated; ignore the earlier signal. StreamEvent::ToolCallStart { .. } => {} @@ -191,7 +188,6 @@ fn stream_event_kind(event: &StreamEvent) -> &'static str { match event { StreamEvent::TextDelta(_) => "text_delta", StreamEvent::ReasoningDelta(_) => "reasoning_delta", - StreamEvent::RefusalDelta(_) => "refusal_delta", StreamEvent::ToolCallStart { .. } => "tool_call_start", StreamEvent::Completed { .. } => "completed", } diff --git a/crates/kuncode-agent/src/runner/request.rs b/crates/kuncode-agent/src/runner/request.rs index eaa611b..93fc5d5 100644 --- a/crates/kuncode-agent/src/runner/request.rs +++ b/crates/kuncode-agent/src/runner/request.rs @@ -170,7 +170,6 @@ pub(super) fn assistant_text(content: &NonEmptyVec) -> String .iter() .filter_map(|content| match content { AssistantContent::Text(text) => Some(text.text_ref()), - AssistantContent::Refusal(refusal) => Some(refusal.text_ref()), _ => None, }) .collect::>() diff --git a/crates/kuncode-agent/src/session_store/dto.rs b/crates/kuncode-agent/src/session_store/dto.rs index 2d57809..1b77c81 100644 --- a/crates/kuncode-agent/src/session_store/dto.rs +++ b/crates/kuncode-agent/src/session_store/dto.rs @@ -72,9 +72,6 @@ enum StoredAssistantContent { Text { text: String, }, - Refusal { - text: String, - }, ToolCall { id: String, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/crates/kuncode-agent/src/session_store/dto/conversion.rs b/crates/kuncode-agent/src/session_store/dto/conversion.rs index 7069c8d..1a430d3 100644 --- a/crates/kuncode-agent/src/session_store/dto/conversion.rs +++ b/crates/kuncode-agent/src/session_store/dto/conversion.rs @@ -129,9 +129,6 @@ impl StoredAssistantContent { AssistantContent::Text(text) => Self::Text { text: text.text_ref().to_string(), }, - AssistantContent::Refusal(refusal) => Self::Refusal { - text: refusal.text_ref().to_string(), - }, AssistantContent::ToolCall(call) => Self::ToolCall { id: call.id.clone(), call_id: call.call_id.clone(), @@ -154,7 +151,6 @@ impl StoredAssistantContent { fn into_content(self) -> Result { match self { Self::Text { text } => Ok(AssistantContent::Text(Text::from(text))), - Self::Refusal { text } => Ok(AssistantContent::refusal(text)), Self::ToolCall { id, call_id, diff --git a/crates/kuncode-cli/src/settings.rs b/crates/kuncode-cli/src/settings.rs index be13563..0c0e90b 100644 --- a/crates/kuncode-cli/src/settings.rs +++ b/crates/kuncode-cli/src/settings.rs @@ -9,7 +9,7 @@ use std::{num::NonZeroU32, path::Path}; use kuncode_agent::{ compaction::budget::{CompactionConfig, CompactionMode}, permission::{CanonicalPath, PermissionMode, PolicyEffect, PolicyOrigin, PolicySet}, - runner::{AgentCompactionConfig, AgentCompactionConfigError, AgentConfig}, + runner::{AgentCompactionConfig, AgentCompactionConfigError}, }; use kuncode_core::providers::deepseek::{ DEEPSEEK_V4_PRO_MODEL_ID, DeepSeekModelProfile, model_profile, @@ -73,24 +73,17 @@ struct PermissionsSection { default_mode: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Default, Deserialize)] #[serde(default, rename_all = "camelCase", deny_unknown_fields)] struct ModelSection { provider: ProviderKind, - name: String, + /// Model identifier. Only DeepSeek has a built-in default; other providers + /// must name a model explicitly — silently sending the DeepSeek default + /// model id to a different provider would fail at the first request. + name: Option, max_tokens: Option, } -impl Default for ModelSection { - fn default() -> Self { - Self { - provider: ProviderKind::DeepSeek, - name: DEEPSEEK_V4_PRO_MODEL_ID.to_string(), - max_tokens: None, - } - } -} - /// Wire protocol selected for model requests. #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)] pub(crate) enum ProviderKind { @@ -196,8 +189,8 @@ pub struct ProjectSettings { impl Default for ProjectSettings { fn default() -> Self { - let max_tokens = model_profile(DEEPSEEK_V4_PRO_MODEL_ID).map_or_else( - default_agent_max_tokens, + let max_tokens = model_profile(DEEPSEEK_V4_PRO_MODEL_ID).map_or( + CONSERVATIVE_DEFAULT_MAX_TOKENS, DeepSeekModelProfile::default_max_tokens, ); Self { @@ -280,7 +273,18 @@ fn resolve_settings( root: &Path, trust: ProjectTrust, ) -> Result { - let model_name = model_override.unwrap_or(&file.model.name).to_string(); + let model_name = match (model_override, &file.model.name, file.model.provider) { + (Some(name), _, _) => name.to_string(), + (None, Some(name), _) => name.clone(), + (None, None, ProviderKind::DeepSeek) => DEEPSEEK_V4_PRO_MODEL_ID.to_string(), + // No cross-provider default exists: falling back to the DeepSeek model + // id would send it to the other provider and fail at the first request. + (None, None, ProviderKind::OpenAi) => { + return Err(SettingsError::Model( + "provider \"openai\" requires an explicit model name".to_string(), + )); + } + }; if model_name.trim().is_empty() { return Err(SettingsError::Model( "model name must not be blank".to_string(), @@ -292,8 +296,12 @@ fn resolve_settings( None }; let max_tokens = file.model.max_tokens.unwrap_or_else(|| { - profile.map_or_else( - default_agent_max_tokens, + profile.map_or( + // No capability profile to consult (an unknown DeepSeek id or a + // non-DeepSeek provider): default conservatively — every current + // OpenAI model accepts 16k output, while the agent default of 32k + // exceeds several of them and 400s the first request. + CONSERVATIVE_DEFAULT_MAX_TOKENS, DeepSeekModelProfile::default_max_tokens, ) }); @@ -467,9 +475,12 @@ fn normalized_log_level(level: &str) -> Option<&'static str> { } } -fn default_agent_max_tokens() -> u64 { - AgentConfig::default().max_tokens.unwrap_or(32_768) -} +/// Output budget when no capability profile is available (unknown DeepSeek ids +/// and every non-DeepSeek model). Deliberately below the agent's own default: +/// several current OpenAI models cap output at 16k, and a budget the provider +/// rejects fails every request outright — a smaller turn budget merely finishes +/// a long answer over more turns. +const CONSERVATIVE_DEFAULT_MAX_TOKENS: u64 = 16_384; fn push_rules( policy: &mut PolicySet, @@ -606,6 +617,35 @@ mod tests { assert_eq!(loaded.max_tokens, 8_192); } + #[test] + fn openai_provider_without_a_model_name_is_an_error() { + // There is no cross-provider default: silently sending the DeepSeek + // default model id to api.openai.com would 404 on the first request. + let error = load_json("openai-unnamed", r#"{ "model": { "provider": "openai" } }"#) + .expect_err("provider openai must require an explicit model name"); + + assert!(matches!(error, SettingsError::Model(_))); + } + + #[test] + fn non_builtin_model_defaults_to_a_conservative_output_budget() { + // No capability profile → 16k, not the 32k agent default that several + // OpenAI models reject outright. + let loaded = load_json( + "openai-budget", + r#"{ "model": { "provider": "openai", "name": "gpt-test" } }"#, + ) + .expect("loads"); + assert_eq!(loaded.max_tokens, CONSERVATIVE_DEFAULT_MAX_TOKENS); + + let unknown_deepseek = load_json( + "deepseek-unknown-budget", + r#"{ "model": { "name": "deepseek-custom" } }"#, + ) + .expect("loads"); + assert_eq!(unknown_deepseek.max_tokens, CONSERVATIVE_DEFAULT_MAX_TOKENS); + } + #[test] fn loads_explicit_deepseek_provider_name() { let loaded = load_json( diff --git a/crates/kuncode-core/src/completion.rs b/crates/kuncode-core/src/completion.rs index 75094d2..2a80ea7 100644 --- a/crates/kuncode-core/src/completion.rs +++ b/crates/kuncode-core/src/completion.rs @@ -11,7 +11,7 @@ pub mod retry; pub mod streaming; pub use message::{ - AssistantContent, Message, Reasoning, ReasoningContent, Refusal, Text, ToolCall, ToolChoice, + AssistantContent, Message, Reasoning, ReasoningContent, Text, ToolCall, ToolChoice, ToolFunction, ToolResult, ToolResultContent, UserContent, }; diff --git a/crates/kuncode-core/src/completion/message.rs b/crates/kuncode-core/src/completion/message.rs index 82bbb17..6139725 100644 --- a/crates/kuncode-core/src/completion/message.rs +++ b/crates/kuncode-core/src/completion/message.rs @@ -150,8 +150,6 @@ pub enum AssistantContent { ToolCall(ToolCall), /// Reasoning/thinking content returned by reasoning-capable models. Reasoning(Reasoning), - /// Safety refusal returned instead of ordinary assistant text. - Refusal(Refusal), } impl AssistantContent { @@ -200,26 +198,6 @@ impl AssistantContent { pub fn reasoning(reasoning: impl AsRef) -> Self { Self::Reasoning(Reasoning::new(reasoning.as_ref())) } - - /// Preserves a provider refusal separately from ordinary assistant text. - pub fn refusal(refusal: impl Into) -> Self { - Self::Refusal(Refusal { - refusal: refusal.into(), - }) - } -} - -/// A safety refusal emitted instead of ordinary assistant content. -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] -pub struct Refusal { - refusal: String, -} - -impl Refusal { - /// Returns the refusal text. - pub fn text_ref(&self) -> &str { - &self.refusal - } } /// A plain-text content block. @@ -450,7 +428,6 @@ mod tests { Reasoning::summaries(vec!["s1".into(), "s2".into()]) .with_id("r_1".to_string()), ), - AssistantContent::refusal("cannot comply"), ], ), }, diff --git a/crates/kuncode-core/src/completion/streaming.rs b/crates/kuncode-core/src/completion/streaming.rs index 1a5d5a8..706e0bd 100644 --- a/crates/kuncode-core/src/completion/streaming.rs +++ b/crates/kuncode-core/src/completion/streaming.rs @@ -55,8 +55,6 @@ pub enum StreamEvent { /// A chunk of reasoning/thinking text, kept separate from the answer /// (e.g. DeepSeek's `reasoning_content`). Render in a distinct channel. ReasoningDelta(String), - /// A chunk of a safety refusal, kept distinct from ordinary answer text. - RefusalDelta(String), /// A tool call has started: its `id` and `name` are known before the /// arguments finish streaming. Useful for an immediate "calling X" hint; /// the complete call (with assembled arguments) arrives in diff --git a/crates/kuncode-core/src/providers/any_chat.rs b/crates/kuncode-core/src/providers/any_chat.rs index 306c22f..e2e8f95 100644 --- a/crates/kuncode-core/src/providers/any_chat.rs +++ b/crates/kuncode-core/src/providers/any_chat.rs @@ -46,6 +46,11 @@ impl CompletionModel for AnyChatCompletionModel { } } + /// `raw_response` semantics differ by branch and are best-effort only: the + /// OpenAI branch passes the server's original JSON through verbatim, while + /// the DeepSeek branch re-serializes its typed DTO (unmodeled fields are + /// dropped). Callers may rely on it being valid JSON, not on it being + /// byte-faithful; nothing in the runtime consumes it today. async fn completion( &self, request: CompletionRequest, diff --git a/crates/kuncode-core/src/providers/chat_completions/streaming.rs b/crates/kuncode-core/src/providers/chat_completions/streaming.rs index 3be0df8..85a11c5 100644 --- a/crates/kuncode-core/src/providers/chat_completions/streaming.rs +++ b/crates/kuncode-core/src/providers/chat_completions/streaming.rs @@ -53,6 +53,9 @@ struct ChunkChoice { struct ChunkDelta { content: Option, reasoning_content: Option, + /// OpenAI's wire spelling for "the answer text is a decline". Folded into + /// ordinary text on ingest, matching the non-streaming projection; no other + /// Chat Completions provider populates it. refusal: Option, tool_calls: Option>, } @@ -139,7 +142,6 @@ struct PartialToolCall { struct StreamAssembler { text: String, reasoning: String, - refusal: String, tool_calls: Vec, finish_reason: Option, usage: Option, @@ -167,8 +169,8 @@ impl StreamAssembler { events.push(StreamEvent::ReasoningDelta(reasoning)); } if let Some(refusal) = choice.delta.refusal.filter(|s| !s.is_empty()) { - self.refusal.push_str(&refusal); - events.push(StreamEvent::RefusalDelta(refusal)); + self.text.push_str(&refusal); + events.push(StreamEvent::TextDelta(refusal)); } for delta in choice.delta.tool_calls.into_iter().flatten() { self.ingest_tool_call(delta, &mut events); @@ -252,9 +254,6 @@ impl StreamAssembler { if !self.reasoning.is_empty() { content.push(AssistantContent::reasoning(self.reasoning)); } - if !self.refusal.is_empty() { - content.push(AssistantContent::refusal(self.refusal)); - } let content = NonEmptyVec::try_from(content).map_err(|err| { CompletionError::ResponseError(format!("stream produced no assistant content: {err}")) @@ -289,6 +288,27 @@ fn map_finish_reason(reason: Option<&str>) -> FinishReason { } } +/// Rejects a 2xx streaming response whose body is not SSE (e.g. a proxy or +/// gateway answering with an HTML or JSON page): fail with one clear error up +/// front instead of letting the SSE decoder grind through a non-SSE body and +/// report a confusing "no assistant content". A missing header passes — some +/// proxies strip it, and the decoder handles genuine SSE fine without it. +pub(crate) fn validate_stream_content_type( + content_type: Option<&str>, +) -> Result<(), CompletionError> { + let Some(content_type) = content_type else { + return Ok(()); + }; + let media_type = content_type.split(';').next().unwrap_or_default().trim(); + if media_type.eq_ignore_ascii_case("text/event-stream") { + Ok(()) + } else { + Err(CompletionError::ResponseError(format!( + "expected an SSE response, but received `{media_type}`" + ))) + } +} + /// Drives a streaming `/chat/completions` response body into a /// [`CompletionStream`]. /// @@ -446,7 +466,9 @@ data: [DONE] } #[test] - fn refusal_streams_and_is_preserved_in_completed_content() { + fn refusal_deltas_flatten_into_the_text_channel() { + // OpenAI streams refusals on a dedicated wire field; the assembler folds + // them into ordinary text so streaming and non-streaming agree. let sse = "\ data: {\"choices\":[{\"delta\":{\"refusal\":\"Cannot \"}}]} @@ -456,19 +478,19 @@ data: [DONE] "; let events = run(sse, 4096); - let refusal: String = events + let text: String = events .iter() .filter_map(|event| match event { - StreamEvent::RefusalDelta(text) => Some(text.clone()), + StreamEvent::TextDelta(text) => Some(text.clone()), _ => None, }) .collect(); - assert_eq!(refusal, "Cannot comply"); + assert_eq!(text, "Cannot comply"); let (content, _) = completed(events); assert!(matches!( content.first(), - AssistantContent::Refusal(value) if value.text_ref() == "Cannot comply" + AssistantContent::Text(value) if value.text_ref() == "Cannot comply" )); } @@ -571,6 +593,22 @@ data: {\"error\":{\"message\":\"rate limited\",\"type\":\"server_error\"}} )); } + #[test] + fn stream_content_type_accepts_sse_and_absence_only() { + assert!(validate_stream_content_type(None).is_ok()); + assert!(validate_stream_content_type(Some("text/event-stream")).is_ok()); + assert!(validate_stream_content_type(Some("Text/Event-Stream; charset=utf-8")).is_ok()); + // A proxy answering with a page instead of a stream fails up front. + assert!(matches!( + validate_stream_content_type(Some("text/html")), + Err(CompletionError::ResponseError(_)) + )); + assert!(matches!( + validate_stream_content_type(Some("application/json")), + Err(CompletionError::ResponseError(_)) + )); + } + #[test] fn usage_frame_missing_a_core_count_is_rejected() { // The standard trio is required: a usage object missing one is malformed diff --git a/crates/kuncode-core/src/providers/deepseek.rs b/crates/kuncode-core/src/providers/deepseek.rs index 30ef00b..75a8298 100644 --- a/crates/kuncode-core/src/providers/deepseek.rs +++ b/crates/kuncode-core/src/providers/deepseek.rs @@ -205,6 +205,13 @@ impl CompletionModel for DeepSeekCompletionModel { }); } + crate::providers::chat_completions::streaming::validate_stream_content_type( + response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + )?; + Ok( crate::providers::chat_completions::streaming::stream_events::( response, diff --git a/crates/kuncode-core/src/providers/deepseek/protocol.rs b/crates/kuncode-core/src/providers/deepseek/protocol.rs index 25bcc9d..0740b9e 100644 --- a/crates/kuncode-core/src/providers/deepseek/protocol.rs +++ b/crates/kuncode-core/src/providers/deepseek/protocol.rs @@ -48,8 +48,9 @@ pub enum Message { /// Assistant output: visible text plus optional tool calls and reasoning. Assistant { - /// Visible assistant text. - #[serde(default, deserialize_with = "json_utils::null_or_default")] + /// Visible assistant text. DeepSeek always sends the field (empty + /// string for pure tool-call turns), so it stays required — a missing + /// key means a malformed body, not a default. content: String, /// Optional speaker name accepted by OpenAI-compatible APIs. #[serde(skip_serializing_if = "Option::is_none")] @@ -158,9 +159,6 @@ impl From for Vec { .as_str(), ); } - message::AssistantContent::Refusal(refusal) => { - text_content.push_str(refusal.text_ref()); - } } } @@ -209,7 +207,6 @@ pub struct ToolCall { // Position within parallel calls. DeepSeek includes it in responses and // streaming chunks; request replay uses array order instead, so outbound // conversion fills 0 and inbound projection does not read it. - #[serde(default)] pub index: usize, /// Tool-call kind; currently always [`ToolType::Function`]. pub r#type: ToolType, @@ -380,13 +377,11 @@ pub struct DeepSeekCompletionResponse { pub created: u64, /// Model id that served the request. pub model: String, - /// Provider backend fingerprint; OpenAI-compatible endpoints may omit it. - #[serde(default)] - pub system_fingerprint: Option, + /// Provider backend fingerprint. + pub system_fingerprint: String, /// OpenAI-compatible object type. pub object: String, - /// Token accounting for the call; some compatible endpoints omit it. - #[serde(default)] + /// Token accounting for the call. pub usage: Usage, } diff --git a/crates/kuncode-core/src/providers/openai.rs b/crates/kuncode-core/src/providers/openai.rs index 3ce23d5..331538f 100644 --- a/crates/kuncode-core/src/providers/openai.rs +++ b/crates/kuncode-core/src/providers/openai.rs @@ -3,12 +3,14 @@ use std::{env::VarError, time::Duration}; use reqwest::header::CONTENT_TYPE; +use serde::Deserialize; use serde_json::Value; use thiserror::Error; use crate::{ completion::{CompletionError, CompletionModel, CompletionRequest, CompletionResponse}, json_utils, + providers::chat_completions::streaming, }; use self::protocol::{OpenAiCompletionRequest, OpenAiCompletionResponse, Usage}; @@ -139,18 +141,21 @@ impl CompletionModel for OpenAiCompletionModel { message: response.text().await.unwrap_or_default(), }); } - validate_stream_content_type( + streaming::validate_stream_content_type( response .headers() .get(CONTENT_TYPE) .and_then(|value| value.to_str().ok()), )?; - Ok(crate::providers::chat_completions::streaming::stream_events::(response)) + Ok(streaming::stream_events::(response)) } } +/// Projects the server's JSON into the domain response while handing the +/// untouched original through as `raw_response`. Deserializes by reference so +/// the projection does not deep-copy the body. fn normalize_response(raw: Value) -> Result, CompletionError> { - let response: OpenAiCompletionResponse = serde_json::from_value(raw.clone())?; + let response = OpenAiCompletionResponse::deserialize(&raw)?; let normalized: CompletionResponse = response.try_into()?; Ok(CompletionResponse { choice: normalized.choice, @@ -160,16 +165,49 @@ fn normalize_response(raw: Value) -> Result, Completio }) } -fn validate_stream_content_type(content_type: Option<&str>) -> Result<(), CompletionError> { - let Some(content_type) = content_type else { - return Ok(()); - }; - let media_type = content_type.split(';').next().unwrap_or_default().trim(); - if media_type.eq_ignore_ascii_case("text/event-stream") { - Ok(()) - } else { - Err(CompletionError::ResponseError(format!( - "expected an OpenAI SSE response, but received `{media_type}`" - ))) +#[cfg(test)] +mod tests { + use super::*; + use crate::completion::AssistantContent; + + #[test] + fn normalize_response_projects_content_and_keeps_the_original_json() { + let raw = serde_json::json!({ + "id": "chatcmpl-test", + "choices": [{ + "finish_reason": "stop", + "index": 0, + "message": {"role": "assistant", "content": "hello"}, + "logprobs": null + }], + "created": 1, + "model": "gpt-test", + "object": "chat.completion", + "system_fingerprint": "fp_1", + "usage": {"prompt_tokens": 4, "completion_tokens": 2, "total_tokens": 6, + "unmodeled_extension": {"depth": 3}} + }); + + let normalized = normalize_response(raw.clone()).expect("normalize"); + + assert!(matches!( + normalized.choice.first(), + AssistantContent::Text(text) if text.text_ref() == "hello" + )); + assert_eq!(normalized.usage.input_tokens, 4); + // The raw side is the *server's* JSON verbatim — unmodeled fields + // included — not a re-serialization of the typed DTO. + assert_eq!(normalized.raw_response, raw); + assert_eq!( + normalized.raw_response["usage"]["unmodeled_extension"]["depth"], + 3 + ); + } + + #[test] + fn normalize_response_rejects_a_non_completion_body() { + let error = normalize_response(serde_json::json!({"error": "nope"})) + .expect_err("not a completion body"); + assert!(matches!(error, CompletionError::JsonError(_))); } } diff --git a/crates/kuncode-core/src/providers/openai/protocol.rs b/crates/kuncode-core/src/providers/openai/protocol.rs index 7864244..7dd4dd6 100644 --- a/crates/kuncode-core/src/providers/openai/protocol.rs +++ b/crates/kuncode-core/src/providers/openai/protocol.rs @@ -60,14 +60,10 @@ impl From for Vec { } message::Message::Assistant { id: _, content } => { let mut text = String::new(); - let mut refusal = String::new(); let mut tool_calls = Vec::new(); for block in content { match block { message::AssistantContent::Text(value) => text.push_str(value.text_ref()), - message::AssistantContent::Refusal(value) => { - refusal.push_str(value.text_ref()); - } message::AssistantContent::ToolCall(call) => { tool_calls.push(ToolCall::from(call)); } @@ -77,7 +73,9 @@ impl From for Vec { } vec![Message::Assistant { content: text, - refusal: (!refusal.is_empty()).then_some(refusal), + // Inbound-only: refusals are flattened to text on receipt, + // so replayed history never carries the field. + refusal: None, tool_calls, }] } @@ -200,11 +198,20 @@ impl TryFrom for OpenAiCompletionRequest { .map(ToolDefinition::from) .collect(), tool_choice: request.tool_choice.map(ToolChoice::from), - max_completion_tokens: request.max_tokens.map(|value| value as u32), + max_completion_tokens: request + .max_tokens + .map(|value| { + u32::try_from(value).map_err(|_| { + CompletionError::RequestError(format!( + "max_tokens {value} exceeds the OpenAI u32 wire range" + )) + }) + }) + .transpose()?, temperature: request.temperature, top_p: request.top_p, stop: request.stop.filter(|value| !value.is_empty()), - reasoning_effort: request.reasoning.map(ReasoningEffort::from), + reasoning_effort: request.reasoning.and_then(ReasoningEffort::from_domain), response_format: request.output_schema.map(ResponseFormat::json_schema), stream: None, stream_options: None, @@ -225,7 +232,6 @@ impl OpenAiCompletionRequest { #[derive(Debug, Serialize)] #[serde(rename_all = "snake_case")] enum ReasoningEffort { - None, Minimal, Low, Medium, @@ -233,15 +239,22 @@ enum ReasoningEffort { Xhigh, } -impl From for ReasoningEffort { - fn from(value: completion::ReasoningEffort) -> Self { +impl ReasoningEffort { + /// Maps the domain effort onto the wire value; [`Off`] maps to omitting the + /// field entirely. Non-reasoning models (gpt-4o family) reject the parameter + /// outright, and reasoning models reject `"none"` — leaving the field out is + /// the only spelling every Chat Completions model accepts, mirroring the + /// DeepSeek mapper's treatment of [`Off`]. + /// + /// [`Off`]: completion::ReasoningEffort::Off + fn from_domain(value: completion::ReasoningEffort) -> Option { match value { - completion::ReasoningEffort::Off => Self::None, - completion::ReasoningEffort::Minimal => Self::Minimal, - completion::ReasoningEffort::Low => Self::Low, - completion::ReasoningEffort::Medium => Self::Medium, - completion::ReasoningEffort::High => Self::High, - completion::ReasoningEffort::Xhigh => Self::Xhigh, + completion::ReasoningEffort::Off => None, + completion::ReasoningEffort::Minimal => Some(Self::Minimal), + completion::ReasoningEffort::Low => Some(Self::Low), + completion::ReasoningEffort::Medium => Some(Self::Medium), + completion::ReasoningEffort::High => Some(Self::High), + completion::ReasoningEffort::Xhigh => Some(Self::Xhigh), } } } @@ -253,12 +266,17 @@ enum ResponseFormat { } impl ResponseFormat { + /// `strict: false` deliberately: strict mode accepts only a narrow schema + /// subset (every property `required`, no `$schema`/`format`, ...), and the + /// schemas callers pass here are plain `schemars` output that violates it — + /// OpenAI would 400 on the request. Non-strict still steers generation with + /// the schema; callers validate the parsed result themselves. fn json_schema(schema: serde_json::Value) -> Self { Self::JsonSchema { json_schema: JsonSchema { name: "kuncode_output", schema, - strict: true, + strict: false, }, } } @@ -391,6 +409,12 @@ impl TryFrom if !content.trim().is_empty() { blocks.push(AssistantContent::text(content)); } + // A refusal is OpenAI's wire spelling for "the answer text is a + // decline"; the agent has no refusal-aware branching, so it flattens to + // ordinary text. The verbatim field survives in `raw_response`. + if let Some(refusal) = refusal.as_ref().filter(|value| !value.is_empty()) { + blocks.push(AssistantContent::text(refusal)); + } blocks.extend(tool_calls.iter().map(|call| { AssistantContent::tool_call( &call.id, @@ -398,9 +422,6 @@ impl TryFrom call.function.arguments.clone(), ) })); - if let Some(refusal) = refusal.as_ref().filter(|value| !value.is_empty()) { - blocks.push(AssistantContent::refusal(refusal)); - } let blocks = NonEmptyVec::try_from(blocks).map_err(|error| { CompletionError::ResponseError(format!( "OpenAI response contained no assistant content: {error}" @@ -410,6 +431,8 @@ impl TryFrom choice: blocks, usage: response.usage.clone().into(), raw_response: response, + // `id` is the completion-call id (`chatcmpl-...`), not a message id; + // mirror the DeepSeek mapping and leave it in `raw_response` only. message_id: None, }) } @@ -431,7 +454,7 @@ mod tests { let request = CompletionRequestBuilder::new(DomainMessage::user("test")) .model("gpt-test") .max_tokens(Some(512)) - .reasoning(Some(ReasoningEffort::Off)) + .reasoning(Some(ReasoningEffort::Medium)) .output_schema(Some(schema.clone())) .build(); let wire = OpenAiCompletionRequest::try_from(request).expect("wire request"); @@ -439,14 +462,43 @@ mod tests { assert_eq!(json["max_completion_tokens"], 512); assert!(json.get("max_tokens").is_none()); - assert_eq!(json["reasoning_effort"], "none"); + assert_eq!(json["reasoning_effort"], "medium"); assert_eq!(json["response_format"]["type"], "json_schema"); - assert_eq!(json["response_format"]["json_schema"]["strict"], true); + // Strict mode rejects plain schemars output (optional fields, $schema, + // integer formats) with a 400 — the request must never opt in. + assert_eq!(json["response_format"]["json_schema"]["strict"], false); assert_eq!(json["response_format"]["json_schema"]["schema"], schema); } #[test] - fn preserves_refusal_as_assistant_content() { + fn reasoning_effort_off_omits_the_field_entirely() { + // gpt-4o-family models reject the parameter and reasoning models reject + // "none"; the only universally-accepted spelling of Off is absence. + let request = CompletionRequestBuilder::new(DomainMessage::user("test")) + .model("gpt-test") + .reasoning(Some(ReasoningEffort::Off)) + .build(); + let wire = OpenAiCompletionRequest::try_from(request).expect("wire request"); + let json = serde_json::to_value(wire).expect("serialize request"); + + assert!(json.get("reasoning_effort").is_none()); + } + + #[test] + fn oversized_max_tokens_is_a_request_error_not_a_wrap() { + let request = CompletionRequestBuilder::new(DomainMessage::user("test")) + .model("gpt-test") + .max_tokens(Some(u64::from(u32::MAX) + 1)) + .build(); + + assert!(matches!( + OpenAiCompletionRequest::try_from(request), + Err(CompletionError::RequestError(_)) + )); + } + + #[test] + fn refusal_flattens_to_assistant_text() { let response: OpenAiCompletionResponse = serde_json::from_value(serde_json::json!({ "id": "chatcmpl-test", "choices": [{ @@ -464,9 +516,47 @@ mod tests { let normalized: completion::CompletionResponse<_> = response.try_into().expect("normalize response"); + // The wire refusal reaches the agent as ordinary text — the domain has + // no refusal-aware consumer — while `raw_response` keeps the original. assert!(matches!( normalized.choice.first(), - AssistantContent::Refusal(value) if value.text_ref() == "Cannot comply" + AssistantContent::Text(value) if value.text_ref() == "Cannot comply" )); } + + #[test] + fn tool_calls_map_with_ids_names_and_parsed_arguments() { + let response: OpenAiCompletionResponse = serde_json::from_value(serde_json::json!({ + "id": "chatcmpl-test", + "choices": [{ + "finish_reason": "tool_calls", + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls": [{ + "id": "call_9", + "type": "function", + "function": {"name": "get_weather", "arguments": "{\"city\":\"NYC\"}"} + }] + }, + "logprobs": null + }], + "created": 1, + "model": "gpt-test", + "object": "chat.completion", + "usage": {"prompt_tokens": 4, "completion_tokens": 2, "total_tokens": 6} + })) + .expect("response fixture"); + let normalized: completion::CompletionResponse<_> = + response.try_into().expect("normalize response"); + + let call = match normalized.choice.first() { + AssistantContent::ToolCall(call) => call, + other => panic!("expected a tool call, got {other:?}"), + }; + assert_eq!(call.id, "call_9"); + assert_eq!(call.function.name, "get_weather"); + assert_eq!(call.function.arguments, serde_json::json!({"city": "NYC"})); + } } From 2f84def3136fcf91e0f19a674ec08f7701bbd7c0 Mon Sep 17 00:00:00 2001 From: wenzr <282277167@qq.com> Date: Sun, 26 Jul 2026 22:17:20 +0800 Subject: [PATCH 5/8] =?UTF-8?q?settings:=20DEEPSEEK=5FMODEL=20=E6=8C=89=20?= =?UTF-8?q?provider=20=E9=97=A8=E6=8E=A7;=E9=A2=84=E7=AE=97=E6=8A=A5?= =?UTF-8?q?=E9=94=99=E6=A0=87=E6=B3=A8=E9=BB=98=E8=AE=A4=E5=80=BC=E6=9D=A5?= =?UTF-8?q?=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- crates/kuncode-cli/src/runtime.rs | 5 +- crates/kuncode-cli/src/settings.rs | 151 +++++++++++++++++++++++++---- 2 files changed, 136 insertions(+), 20 deletions(-) diff --git a/crates/kuncode-cli/src/runtime.rs b/crates/kuncode-cli/src/runtime.rs index 605616b..76ab16a 100644 --- a/crates/kuncode-cli/src/runtime.rs +++ b/crates/kuncode-cli/src/runtime.rs @@ -293,7 +293,7 @@ impl CliRuntime { #[cfg(test)] mod tests { use super::*; - use crate::settings::{ProjectSettings, load_project_settings_from}; + use crate::settings::{ModelOverrides, ProjectSettings, load_project_settings_from}; use std::fs; fn compaction_settings(tag: &str) -> ProjectSettings { @@ -312,7 +312,8 @@ mod tests { ) .expect("write settings"); let settings = - load_project_settings_from(&dir, None, ProjectTrust::Untrusted).expect("load settings"); + load_project_settings_from(&dir, ModelOverrides::default(), ProjectTrust::Untrusted) + .expect("load settings"); let _ = fs::remove_dir_all(&dir); settings } diff --git a/crates/kuncode-cli/src/settings.rs b/crates/kuncode-cli/src/settings.rs index 0c0e90b..ac72084 100644 --- a/crates/kuncode-cli/src/settings.rs +++ b/crates/kuncode-cli/src/settings.rs @@ -210,9 +210,10 @@ impl Default for ProjectSettings { /// Loads `.kuncode/settings.json` under `root`. /// /// A missing file returns defaults. `KUNCODE_MODEL` overrides the file's model -/// name; `DEEPSEEK_MODEL` remains a backward-compatible fallback. Every section -/// forms a closed schema, so misspelled fields fail instead of silently selecting -/// defaults. +/// name for any provider; `DEEPSEEK_MODEL` remains a backward-compatible +/// fallback that applies only when the DeepSeek provider is selected. Every +/// section forms a closed schema, so misspelled fields fail instead of silently +/// selecting defaults. /// /// # Errors /// @@ -224,20 +225,48 @@ pub(crate) fn load_project_settings( root: &Path, trust: ProjectTrust, ) -> Result { - let model_override = std::env::var("KUNCODE_MODEL") - .ok() - .or_else(|| std::env::var("DEEPSEEK_MODEL").ok()); - load_project_settings_from(root, model_override.as_deref(), trust) + let universal = std::env::var("KUNCODE_MODEL").ok(); + let deepseek = std::env::var("DEEPSEEK_MODEL").ok(); + load_project_settings_from( + root, + ModelOverrides { + universal: universal.as_deref(), + deepseek: deepseek.as_deref(), + }, + trust, + ) +} + +/// Environment model-name overrides, applied by provider: `universal` +/// (`KUNCODE_MODEL`) wins for every provider, while `deepseek` +/// (`DEEPSEEK_MODEL`, the pre-multi-provider compatibility variable) applies +/// only when the file selects the DeepSeek provider. A stale `DEEPSEEK_MODEL` +/// export in a shell rc must neither steer another provider's requests nor +/// bypass that provider's explicit-model-name requirement. +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct ModelOverrides<'a> { + pub(crate) universal: Option<&'a str>, + pub(crate) deepseek: Option<&'a str>, +} + +impl<'a> ModelOverrides<'a> { + /// The override that actually applies once the file's provider is known. + fn for_provider(self, provider: ProviderKind) -> Option<&'a str> { + self.universal.or(match provider { + ProviderKind::DeepSeek => self.deepseek, + ProviderKind::OpenAi => None, + }) + } } pub(crate) fn load_project_settings_from( root: &Path, - model_override: Option<&str>, + overrides: ModelOverrides<'_>, trust: ProjectTrust, ) -> Result { let file = read_settings_file(root)?; - resolve_settings(file, model_override, root, trust) + resolve_settings(file, overrides, root, trust) } /// Loads only the bootstrap settings required to initialize file logging. @@ -269,10 +298,20 @@ fn read_settings_file(root: &Path) -> Result { fn resolve_settings( file: SettingsFile, - model_override: Option<&str>, + overrides: ModelOverrides<'_>, root: &Path, trust: ProjectTrust, ) -> Result { + if overrides.universal.is_none() + && overrides.deepseek.is_some() + && file.model.provider != ProviderKind::DeepSeek + { + tracing::warn!( + target: "kuncode::runtime", + "DEEPSEEK_MODEL is set but the project selects a different provider; ignoring it", + ); + } + let model_override = overrides.for_provider(file.model.provider); let model_name = match (model_override, &file.model.name, file.model.provider) { (Some(name), _, _) => name.to_string(), (None, Some(name), _) => name.clone(), @@ -325,7 +364,14 @@ fn resolve_settings( Some(name) => Some(PermissionMode::parse(&name).ok_or(SettingsError::Mode(name))?), None => None, }; - let compaction = parse_compaction(file.compaction, profile, max_tokens)?; + // Name where a defaulted budget came from: when the mismatch error below + // fires, "16384" must be traceable to something the user can act on. + let max_tokens_note = if file.model.max_tokens.is_none() && profile.is_none() { + " (the built-in default for a model without a capability profile; set model.maxTokens to override)" + } else { + "" + }; + let compaction = parse_compaction(file.compaction, profile, max_tokens, max_tokens_note)?; Ok(ProjectSettings { policy: Some(policy), @@ -344,6 +390,7 @@ fn parse_compaction( section: CompactionSection, profile: Option, max_tokens: u64, + max_tokens_note: &str, ) -> Result, SettingsError> { let mode = match section.mode.as_deref().unwrap_or("disabled") { "disabled" => return Ok(None), @@ -372,7 +419,7 @@ fn parse_compaction( } if reserved_output != max_tokens { return Err(SettingsError::Compaction(format!( - "reservedOutput {reserved_output} must equal model maxTokens {max_tokens}" + "reservedOutput {reserved_output} must equal model maxTokens {max_tokens}{max_tokens_note}" ))); } let policy = CompactionConfig::new( @@ -576,7 +623,8 @@ mod tests { fn load_json(tag: &str, json: &str) -> Result { let dir = unique_dir(tag); fs::write(dir.join(".kuncode/settings.json"), json).expect("write settings"); - let result = load_project_settings_from(&dir, None, ProjectTrust::Trusted); + let result = + load_project_settings_from(&dir, ModelOverrides::default(), ProjectTrust::Trusted); let _ = fs::remove_dir_all(&dir); result } @@ -586,8 +634,9 @@ mod tests { let dir = std::env::temp_dir().join(format!("kuncode-absent-{}", std::process::id())); fs::create_dir_all(&dir).expect("temp dir"); - let loaded = load_project_settings_from(&dir, None, ProjectTrust::Untrusted) - .expect("a missing file is fine"); + let loaded = + load_project_settings_from(&dir, ModelOverrides::default(), ProjectTrust::Untrusted) + .expect("a missing file is fine"); let _ = fs::remove_dir_all(&dir); assert_eq!(loaded.provider, ProviderKind::DeepSeek); @@ -617,6 +666,44 @@ mod tests { assert_eq!(loaded.max_tokens, 8_192); } + #[test] + fn deepseek_model_env_override_does_not_apply_to_openai() { + // A stale `DEEPSEEK_MODEL` export must neither rename an OpenAI model… + let dir = unique_dir("openai-stale-deepseek-env"); + fs::write( + dir.join(".kuncode/settings.json"), + r#"{ "model": { "provider": "openai", "name": "gpt-test" } }"#, + ) + .expect("write settings"); + let overrides = ModelOverrides { + universal: None, + deepseek: Some("deepseek-v4-flash"), + }; + let loaded = load_project_settings_from(&dir, overrides, ProjectTrust::Trusted) + .expect("loads with the file's own model"); + assert_eq!(loaded.model_name, "gpt-test"); + + // …nor bypass the explicit-model-name requirement. + fs::write( + dir.join(".kuncode/settings.json"), + r#"{ "model": { "provider": "openai" } }"#, + ) + .expect("write settings"); + let error = load_project_settings_from(&dir, overrides, ProjectTrust::Trusted) + .expect_err("the missing-name guard still fires"); + assert!(matches!(error, SettingsError::Model(_))); + + // KUNCODE_MODEL stays a universal override. + let universal = ModelOverrides { + universal: Some("gpt-override"), + deepseek: Some("deepseek-v4-flash"), + }; + let loaded = load_project_settings_from(&dir, universal, ProjectTrust::Trusted) + .expect("universal override names the model"); + assert_eq!(loaded.model_name, "gpt-override"); + let _ = fs::remove_dir_all(&dir); + } + #[test] fn openai_provider_without_a_model_name_is_an_error() { // There is no cross-provider default: silently sending the DeepSeek @@ -646,6 +733,28 @@ mod tests { assert_eq!(unknown_deepseek.max_tokens, CONSERVATIVE_DEFAULT_MAX_TOKENS); } + #[test] + fn budget_mismatch_error_names_the_defaulted_max_tokens_source() { + // A config written against the old 32768 default must fail with an + // error that explains where the new 16384 figure comes from. + let error = load_json( + "budget-note", + r#"{ "model": { "name": "deepseek-custom" }, + "compaction": { "mode": "enabled", "contextLimit": 131072, "reservedOutput": 32768 } }"#, + ) + .expect_err("reservedOutput no longer matches the defaulted maxTokens"); + + let text = error.to_string(); + assert!( + text.contains("16384"), + "cites the effective default: {text}" + ); + assert!( + text.contains("capability profile"), + "explains the default's origin and the fix: {text}" + ); + } + #[test] fn loads_explicit_deepseek_provider_name() { let loaded = load_json( @@ -905,9 +1014,15 @@ mod tests { ) .expect("write settings"); - let loaded = - load_project_settings_from(&dir, Some("deepseek-v4-flash"), ProjectTrust::Trusted) - .expect("environment override selects a known model"); + let loaded = load_project_settings_from( + &dir, + ModelOverrides { + universal: Some("deepseek-v4-flash"), + deepseek: None, + }, + ProjectTrust::Trusted, + ) + .expect("environment override selects a known model"); let _ = fs::remove_dir_all(&dir); assert_eq!(loaded.model_name, "deepseek-v4-flash"); From 06ce241b08172816602238b4049a5e44826ff71e Mon Sep 17 00:00:00 2001 From: wenzr Date: Thu, 30 Jul 2026 14:29:33 +0800 Subject: [PATCH 6/8] =?UTF-8?q?Provider:=20=E5=AE=8C=E5=96=84=20OpenAI=20?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E5=85=BC=E5=AE=B9=E4=B8=8E=E5=8D=8F=E8=AE=AE?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 review 收敛推理模型 temperature、reasoning_effort 和空工具选择的线协议行为。补齐 provider 分发、消息映射与序列化测试,并记录 token 默认值升级说明。 --- README.md | 5 +- .../src/compaction/summary/summarizer.rs | 4 +- .../compaction/summary/summarizer/attempt.rs | 4 +- crates/kuncode-cli/src/settings.rs | 16 +- crates/kuncode-core/src/providers/any_chat.rs | 91 +++++- .../src/providers/chat_completions.rs | 11 + crates/kuncode-core/src/providers/deepseek.rs | 18 +- crates/kuncode-core/src/providers/openai.rs | 11 +- .../src/providers/openai/protocol.rs | 281 ++++++++++++++++-- 9 files changed, 381 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index 53461b6..bb2bd10 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ DEEPSEEK_API_KEY=your-api-key { "model": { "provider": "openai", - "name": "your-openai-model", + "name": "gpt-5.1", "maxTokens": 16384 } } @@ -118,6 +118,9 @@ cargo build --release -p kuncode-cli - `model.provider` 支持 `deepseek` 和 `openai`;两者分别使用固定官方 endpoint, 并读取 `DEEPSEEK_API_KEY` 或 `OPENAI_API_KEY`。 - 内置模型配置包括 `deepseek-v4-pro` 和 `deepseek-v4-flash`。 +- 没有内置能力档案的模型,`model.maxTokens` 默认值为 `16384`;从旧版 + `32768` 默认值升级时,如配置了 `compaction.reservedOutput`,需同步调整或显式设置 + `model.maxTokens`。 - 非内置模型启用上下文压缩时,需要显式设置 `compaction.contextLimit`。 - `compaction.mode` 支持 `disabled`、`shadow` 和 `enabled`,默认是 `disabled`。 - `shadow` 只计算和报告压缩候选,不替换当前上下文。 diff --git a/crates/kuncode-agent/src/compaction/summary/summarizer.rs b/crates/kuncode-agent/src/compaction/summary/summarizer.rs index 4488a20..daf54da 100644 --- a/crates/kuncode-agent/src/compaction/summary/summarizer.rs +++ b/crates/kuncode-agent/src/compaction/summary/summarizer.rs @@ -219,7 +219,7 @@ mod tests { use kuncode_core::{ completion::{ AssistantContent, CompletionError, CompletionModel, CompletionRequest, - CompletionResponse, CompletionStream, Message, ReasoningEffort, ToolChoice, Usage, + CompletionResponse, CompletionStream, Message, ReasoningEffort, Usage, }, non_empty_vec::NonEmptyVec, }; @@ -308,7 +308,7 @@ mod tests { assert!(sent.model.is_none()); assert_eq!(sent.chat_history.len(), 2); assert!(sent.tools.is_empty()); - assert_eq!(sent.tool_choice, Some(ToolChoice::None)); + assert!(sent.tool_choice.is_none()); assert_eq!(sent.temperature, Some(0.0)); assert_eq!(sent.max_tokens, Some(2_048)); assert_eq!(sent.reasoning, Some(ReasoningEffort::Off)); diff --git a/crates/kuncode-agent/src/compaction/summary/summarizer/attempt.rs b/crates/kuncode-agent/src/compaction/summary/summarizer/attempt.rs index f11a4fd..bd9e286 100644 --- a/crates/kuncode-agent/src/compaction/summary/summarizer/attempt.rs +++ b/crates/kuncode-agent/src/compaction/summary/summarizer/attempt.rs @@ -3,7 +3,7 @@ use kuncode_core::{ completion::{ AssistantContent, CompletionError, CompletionModel, CompletionRequestBuilder, Message, - ReasoningEffort, ToolChoice, Usage, + ReasoningEffort, Usage, }, non_empty_vec::NonEmptyVec, }; @@ -42,11 +42,11 @@ pub(super) async fn run_attempt( where M: CompletionModel, { + // An empty tool set already makes `none` the provider default. let completion = CompletionRequestBuilder::from_messages(prompt) .temperature(Some(0.0)) .max_tokens(Some(max_output_tokens)) .reasoning(Some(ReasoningEffort::Off)) - .tool_choice(Some(ToolChoice::None)) .output_schema(Some(schema)) .build(); let response = model diff --git a/crates/kuncode-cli/src/settings.rs b/crates/kuncode-cli/src/settings.rs index ac72084..a00d2e1 100644 --- a/crates/kuncode-cli/src/settings.rs +++ b/crates/kuncode-cli/src/settings.rs @@ -96,6 +96,15 @@ pub(crate) enum ProviderKind { OpenAi, } +impl ProviderKind { + fn as_str(self) -> &'static str { + match self { + Self::DeepSeek => "deepseek", + Self::OpenAi => "openai", + } + } +} + #[derive(Debug, Deserialize)] #[serde(default, rename_all = "camelCase", deny_unknown_fields)] struct AgentSection { @@ -319,9 +328,10 @@ fn resolve_settings( // No cross-provider default exists: falling back to the DeepSeek model // id would send it to the other provider and fail at the first request. (None, None, ProviderKind::OpenAi) => { - return Err(SettingsError::Model( - "provider \"openai\" requires an explicit model name".to_string(), - )); + return Err(SettingsError::Model(format!( + "provider \"{}\" requires an explicit model name", + ProviderKind::OpenAi.as_str() + ))); } }; if model_name.trim().is_empty() { diff --git a/crates/kuncode-core/src/providers/any_chat.rs b/crates/kuncode-core/src/providers/any_chat.rs index e2e8f95..6ca3d24 100644 --- a/crates/kuncode-core/src/providers/any_chat.rs +++ b/crates/kuncode-core/src/providers/any_chat.rs @@ -7,7 +7,7 @@ use crate::{ CompletionError, CompletionModel, CompletionRequest, CompletionResponse, CompletionStream, }, providers::{ - deepseek::{DeepSeekClient, DeepSeekCompletionModel}, + deepseek::{DeepSeekClient, DeepSeekCompletionModel, protocol::DeepSeekCompletionResponse}, openai::{OpenAiClient, OpenAiCompletionModel}, }, }; @@ -17,7 +17,7 @@ use crate::{ pub enum AnyChatClient { /// Native DeepSeek protocol behavior. DeepSeek(DeepSeekClient), - /// OpenAI-compatible Chat Completions behavior. + /// Official OpenAI Chat Completions behavior. OpenAi(OpenAiClient), } @@ -26,7 +26,7 @@ pub enum AnyChatClient { pub enum AnyChatCompletionModel { /// Native DeepSeek model. DeepSeek(DeepSeekCompletionModel), - /// OpenAI-compatible model. + /// Official OpenAI model. OpenAi(OpenAiCompletionModel), } @@ -58,13 +58,7 @@ impl CompletionModel for AnyChatCompletionModel { match self { Self::DeepSeek(model) => { let response = model.completion(request).await?; - let raw_response = serde_json::to_value(response.raw_response)?; - Ok(CompletionResponse { - choice: response.choice, - usage: response.usage, - raw_response, - message_id: response.message_id, - }) + erase_deepseek_response(response) } Self::OpenAi(model) => model.completion(request).await, } @@ -80,3 +74,80 @@ impl CompletionModel for AnyChatCompletionModel { } } } + +fn erase_deepseek_response( + response: CompletionResponse, +) -> Result, CompletionError> { + let raw_response = serde_json::to_value(response.raw_response)?; + Ok(CompletionResponse { + choice: response.choice, + usage: response.usage, + raw_response, + message_id: response.message_id, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::completion::AssistantContent; + + #[test] + fn make_dispatches_to_the_selected_provider() { + let deepseek = AnyChatClient::DeepSeek( + DeepSeekClient::new("test-key").expect("build DeepSeek test client"), + ); + let openai = + AnyChatClient::OpenAi(OpenAiClient::new("test-key").expect("build OpenAI test client")); + + assert!(matches!( + AnyChatCompletionModel::make(&deepseek, "deepseek-test"), + AnyChatCompletionModel::DeepSeek(_) + )); + assert!(matches!( + AnyChatCompletionModel::make(&openai, "gpt-test"), + AnyChatCompletionModel::OpenAi(_) + )); + } + + #[test] + fn deepseek_response_erasure_serializes_the_typed_raw_response() { + let raw: DeepSeekCompletionResponse = serde_json::from_value(serde_json::json!({ + "id": "chatcmpl-test", + "choices": [{ + "finish_reason": "stop", + "index": 0, + "message": {"role": "assistant", "content": "hello"}, + "logprobs": null + }], + "created": 1, + "model": "deepseek-test", + "system_fingerprint": "fp_test", + "object": "chat.completion", + "usage": { + "prompt_tokens": 4, + "completion_tokens": 2, + "total_tokens": 6, + "unmodeled_extension": true + } + })) + .expect("DeepSeek response fixture"); + let typed: CompletionResponse = + raw.try_into().expect("normalize DeepSeek response"); + + let erased = erase_deepseek_response(typed).expect("erase response type"); + + assert!(matches!( + erased.choice.first(), + AssistantContent::Text(text) if text.text_ref() == "hello" + )); + assert_eq!(erased.usage.total_tokens, 6); + assert_eq!(erased.raw_response["id"], "chatcmpl-test"); + assert!( + erased.raw_response["usage"] + .get("unmodeled_extension") + .is_none(), + "typed DeepSeek responses intentionally drop unmodeled fields" + ); + } +} diff --git a/crates/kuncode-core/src/providers/chat_completions.rs b/crates/kuncode-core/src/providers/chat_completions.rs index c3d92e0..ee65197 100644 --- a/crates/kuncode-core/src/providers/chat_completions.rs +++ b/crates/kuncode-core/src/providers/chat_completions.rs @@ -1,3 +1,14 @@ //! Shared transport primitives for Chat Completions protocol providers. +use std::time::Duration; + pub(crate) mod streaming; + +/// Bound on dialing a Chat Completions endpoint. +pub(crate) const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +/// Maximum idle gap between response-body chunks. +/// +/// This catches stalled streams without imposing a total generation deadline. +pub(crate) const READ_TIMEOUT: Duration = Duration::from_secs(360); +/// Total deadline for a non-streaming Chat Completions request. +pub(crate) const REQUEST_TIMEOUT: Duration = Duration::from_secs(360); diff --git a/crates/kuncode-core/src/providers/deepseek.rs b/crates/kuncode-core/src/providers/deepseek.rs index 75a8298..7d62c63 100644 --- a/crates/kuncode-core/src/providers/deepseek.rs +++ b/crates/kuncode-core/src/providers/deepseek.rs @@ -6,14 +6,16 @@ //! private `protocol` module. use std::env::VarError; -use std::time::Duration; use thiserror::Error; use crate::{ completion::{CompletionError, CompletionModel}, json_utils, - providers::deepseek::protocol::{DeepSeekCompletionRequest, DeepSeekCompletionResponse}, + providers::{ + chat_completions::{CONNECT_TIMEOUT, READ_TIMEOUT, REQUEST_TIMEOUT}, + deepseek::protocol::{DeepSeekCompletionRequest, DeepSeekCompletionResponse}, + }, }; mod model; @@ -27,18 +29,6 @@ const DEEPSEEK_API_BASE_URL: &str = "https://api.deepseek.com"; #[cfg(test)] const DEEPSEEK_V4_FLASH: &str = DEEPSEEK_V4_FLASH_MODEL_ID; -/// Bound on dialing the endpoint. -const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); -/// Maximum idle gap while reading a response body. Resets on each chunk, so it -/// catches a stalled connection *without* capping how long a (streaming) -/// generation may run — a total request deadline would instead abort a long but -/// healthy stream mid-flight. -const READ_TIMEOUT: Duration = Duration::from_secs(360); -/// Total deadline (connect → full body) for a *non-streaming* request. Applied -/// per-request to `completion()` only; a stream's length is unbounded by design, -/// so it relies on [`READ_TIMEOUT`] instead. -const REQUEST_TIMEOUT: Duration = Duration::from_secs(360); - /// Errors produced while constructing a DeepSeek client. #[derive(Debug, Error)] pub enum Error { diff --git a/crates/kuncode-core/src/providers/openai.rs b/crates/kuncode-core/src/providers/openai.rs index 331538f..74a2545 100644 --- a/crates/kuncode-core/src/providers/openai.rs +++ b/crates/kuncode-core/src/providers/openai.rs @@ -1,6 +1,6 @@ //! Official OpenAI Chat Completions provider. -use std::{env::VarError, time::Duration}; +use std::env::VarError; use reqwest::header::CONTENT_TYPE; use serde::Deserialize; @@ -10,7 +10,7 @@ use thiserror::Error; use crate::{ completion::{CompletionError, CompletionModel, CompletionRequest, CompletionResponse}, json_utils, - providers::chat_completions::streaming, + providers::chat_completions::{CONNECT_TIMEOUT, READ_TIMEOUT, REQUEST_TIMEOUT, streaming}, }; use self::protocol::{OpenAiCompletionRequest, OpenAiCompletionResponse, Usage}; @@ -18,9 +18,6 @@ use self::protocol::{OpenAiCompletionRequest, OpenAiCompletionResponse, Usage}; mod protocol; const OPENAI_COMPLETIONS_URL: &str = "https://api.openai.com/v1/chat/completions"; -const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); -const READ_TIMEOUT: Duration = Duration::from_secs(360); -const REQUEST_TIMEOUT: Duration = Duration::from_secs(360); /// Errors produced while constructing an OpenAI client. #[derive(Debug, Error)] @@ -152,8 +149,8 @@ impl CompletionModel for OpenAiCompletionModel { } /// Projects the server's JSON into the domain response while handing the -/// untouched original through as `raw_response`. Deserializes by reference so -/// the projection does not deep-copy the body. +/// untouched original through as `raw_response`. Deserializing from `&Value` +/// avoids cloning the full JSON tree before building the typed projection. fn normalize_response(raw: Value) -> Result, CompletionError> { let response = OpenAiCompletionResponse::deserialize(&raw)?; let normalized: CompletionResponse = response.try_into()?; diff --git a/crates/kuncode-core/src/providers/openai/protocol.rs b/crates/kuncode-core/src/providers/openai/protocol.rs index 7dd4dd6..3cf171b 100644 --- a/crates/kuncode-core/src/providers/openai/protocol.rs +++ b/crates/kuncode-core/src/providers/openai/protocol.rs @@ -189,15 +189,30 @@ impl TryFrom for OpenAiCompletionRequest { .into_iter() .flat_map(Vec::::from) .collect(); + let tools = request + .tools + .into_iter() + .map(ToolDefinition::from) + .collect::>(); + let tool_choice = match request.tool_choice { + // OpenAI already defaults to `none` when no tools are present. + Some(message::ToolChoice::None) if tools.is_empty() => None, + value => value.map(ToolChoice::from), + }; + let capabilities = ModelCapabilities::for_model(&model); + let reasoning_effort = request + .reasoning + .and_then(|value| ReasoningEffort::from_domain(value, capabilities)); + let temperature = if capabilities.requires_default_temperature { + None + } else { + request.temperature + }; Ok(Self { model, messages, - tools: request - .tools - .into_iter() - .map(ToolDefinition::from) - .collect(), - tool_choice: request.tool_choice.map(ToolChoice::from), + tools, + tool_choice, max_completion_tokens: request .max_tokens .map(|value| { @@ -208,10 +223,10 @@ impl TryFrom for OpenAiCompletionRequest { }) }) .transpose()?, - temperature: request.temperature, + temperature, top_p: request.top_p, stop: request.stop.filter(|value| !value.is_empty()), - reasoning_effort: request.reasoning.and_then(ReasoningEffort::from_domain), + reasoning_effort, response_format: request.output_schema.map(ResponseFormat::json_schema), stream: None, stream_options: None, @@ -232,6 +247,7 @@ impl OpenAiCompletionRequest { #[derive(Debug, Serialize)] #[serde(rename_all = "snake_case")] enum ReasoningEffort { + None, Minimal, Low, Medium, @@ -239,16 +255,43 @@ enum ReasoningEffort { Xhigh, } +#[derive(Clone, Copy)] +struct ModelCapabilities { + supports_none_reasoning_effort: bool, + requires_default_temperature: bool, +} + +impl ModelCapabilities { + fn for_model(model: &str) -> Self { + let is_gpt_5 = + model == "gpt-5" || model.starts_with("gpt-5-") || model.starts_with("gpt-5."); + let is_o_series = ["o1", "o3", "o4"].iter().any(|family| { + model == *family + || model + .strip_prefix(family) + .is_some_and(|suffix| suffix.starts_with('-')) + }); + Self { + supports_none_reasoning_effort: supports_none_reasoning_effort(model), + requires_default_temperature: is_gpt_5 || is_o_series, + } + } +} + impl ReasoningEffort { - /// Maps the domain effort onto the wire value; [`Off`] maps to omitting the - /// field entirely. Non-reasoning models (gpt-4o family) reject the parameter - /// outright, and reasoning models reject `"none"` — leaving the field out is - /// the only spelling every Chat Completions model accepts, mirroring the - /// DeepSeek mapper's treatment of [`Off`]. + /// Maps explicit disablement to `none` only for model families that support + /// it. Older reasoning and non-reasoning models reject that wire value, so + /// omission is the compatible fallback for them. /// /// [`Off`]: completion::ReasoningEffort::Off - fn from_domain(value: completion::ReasoningEffort) -> Option { + fn from_domain( + value: completion::ReasoningEffort, + capabilities: ModelCapabilities, + ) -> Option { match value { + completion::ReasoningEffort::Off if capabilities.supports_none_reasoning_effort => { + Some(Self::None) + } completion::ReasoningEffort::Off => None, completion::ReasoningEffort::Minimal => Some(Self::Minimal), completion::ReasoningEffort::Low => Some(Self::Low), @@ -259,6 +302,14 @@ impl ReasoningEffort { } } +fn supports_none_reasoning_effort(model: &str) -> bool { + model + .strip_prefix("gpt-5.") + .and_then(|suffix| suffix.split('-').next()) + .and_then(|minor| minor.parse::().ok()) + .is_some_and(|minor| minor >= 1) +} + #[derive(Debug, Serialize)] #[serde(tag = "type", rename_all = "snake_case")] enum ResponseFormat { @@ -406,14 +457,18 @@ impl TryFrom )); }; let mut blocks = Vec::new(); + let mut answer = String::new(); if !content.trim().is_empty() { - blocks.push(AssistantContent::text(content)); + answer.push_str(content); } // A refusal is OpenAI's wire spelling for "the answer text is a // decline"; the agent has no refusal-aware branching, so it flattens to // ordinary text. The verbatim field survives in `raw_response`. if let Some(refusal) = refusal.as_ref().filter(|value| !value.is_empty()) { - blocks.push(AssistantContent::text(refusal)); + answer.push_str(refusal); + } + if !answer.is_empty() { + blocks.push(AssistantContent::text(answer)); } blocks.extend(tool_calls.iter().map(|call| { AssistantContent::tool_call( @@ -441,7 +496,14 @@ impl TryFrom #[cfg(test)] mod tests { use super::*; - use crate::completion::{CompletionRequestBuilder, Message as DomainMessage, ReasoningEffort}; + use crate::{ + completion::{ + CompletionRequestBuilder, Message as DomainMessage, ReasoningEffort, + ToolChoice as DomainToolChoice, ToolDefinition as DomainToolDefinition, ToolResult, + ToolResultContent, UserContent, + }, + non_empty_vec::NonEmptyVec, + }; #[test] fn maps_openai_specific_request_fields() { @@ -471,17 +533,164 @@ mod tests { } #[test] - fn reasoning_effort_off_omits_the_field_entirely() { - // gpt-4o-family models reject the parameter and reasoning models reject - // "none"; the only universally-accepted spelling of Off is absence. + fn reasoning_effort_off_maps_to_none_when_the_model_supports_it() { let request = CompletionRequestBuilder::new(DomainMessage::user("test")) - .model("gpt-test") + .model("gpt-5.1") + .temperature(Some(0.0)) + .reasoning(Some(ReasoningEffort::Off)) + .build(); + let wire = OpenAiCompletionRequest::try_from(request).expect("wire request"); + let json = serde_json::to_value(wire).expect("serialize request"); + + assert_eq!(json["reasoning_effort"], "none"); + assert!(json.get("temperature").is_none()); + } + + #[test] + fn reasoning_effort_off_is_omitted_for_models_without_none_support() { + let request = CompletionRequestBuilder::new(DomainMessage::user("test")) + .model("gpt-4o") + .temperature(Some(0.0)) .reasoning(Some(ReasoningEffort::Off)) .build(); let wire = OpenAiCompletionRequest::try_from(request).expect("wire request"); let json = serde_json::to_value(wire).expect("serialize request"); assert!(json.get("reasoning_effort").is_none()); + assert_eq!(json["temperature"], 0.0); + } + + #[test] + fn reasoning_model_omits_non_default_temperature() { + let request = CompletionRequestBuilder::new(DomainMessage::user("test")) + .model("o3") + .temperature(Some(0.0)) + .build(); + let wire = OpenAiCompletionRequest::try_from(request).expect("wire request"); + let json = serde_json::to_value(wire).expect("serialize request"); + + assert!(json.get("temperature").is_none()); + } + + #[test] + fn no_tools_omits_redundant_none_tool_choice() { + let request = CompletionRequestBuilder::new(DomainMessage::user("test")) + .model("gpt-test") + .tool_choice(Some(DomainToolChoice::None)) + .build(); + let wire = OpenAiCompletionRequest::try_from(request).expect("wire request"); + let json = serde_json::to_value(wire).expect("serialize request"); + + assert!(json.get("tools").is_none()); + assert!(json.get("tool_choice").is_none()); + } + + #[test] + fn tool_definition_and_specific_choice_use_openai_wire_shapes() { + let request = CompletionRequestBuilder::new(DomainMessage::user("test")) + .model("gpt-test") + .tool(DomainToolDefinition { + name: "lookup".to_string(), + description: "Look up a value".to_string(), + parameters: serde_json::json!({ + "type": "object", + "properties": { "key": { "type": "string" } }, + "required": ["key"] + }), + }) + .tool_choice(Some(DomainToolChoice::Specific { + function_name: "lookup".to_string(), + })) + .build(); + let wire = OpenAiCompletionRequest::try_from(request).expect("wire request"); + let json = serde_json::to_value(wire).expect("serialize request"); + + assert_eq!( + json["tools"], + serde_json::json!([{ + "type": "function", + "function": { + "name": "lookup", + "description": "Look up a value", + "parameters": { + "type": "object", + "properties": { "key": { "type": "string" } }, + "required": ["key"] + } + } + }]) + ); + assert_eq!( + json["tool_choice"], + serde_json::json!({ + "type": "function", + "function": { "name": "lookup" } + }) + ); + } + + #[test] + fn mixed_user_content_emits_tool_results_before_joined_text() { + let domain = DomainMessage::User { + content: NonEmptyVec::from_first_rest( + UserContent::text("first"), + vec![ + UserContent::ToolResult(ToolResult { + id: "call_1".to_string(), + call_id: None, + content: NonEmptyVec::new(ToolResultContent::text("tool output")), + }), + UserContent::text("second"), + ], + ), + }; + + let wire = Vec::::from(domain); + + assert_eq!(wire.len(), 2); + assert!(matches!( + &wire[0], + Message::ToolResult { tool_call_id, content } + if tool_call_id == "call_1" && content == "tool output" + )); + assert!(matches!( + &wire[1], + Message::User { content } if content == "first\nsecond" + )); + } + + #[test] + fn pure_tool_call_assistant_turn_serializes_empty_content() { + let domain = DomainMessage::Assistant { + id: None, + content: NonEmptyVec::from_first_rest( + AssistantContent::tool_call( + "call_1", + "lookup", + serde_json::json!({"key": "value"}), + ), + vec![AssistantContent::reasoning("not replayed")], + ), + }; + + let wire = Vec::::from(domain); + let json = serde_json::to_value(&wire).expect("serialize messages"); + + assert_eq!( + json, + serde_json::json!([{ + "role": "assistant", + "content": "", + "tool_calls": [{ + "id": "call_1", + "type": "function", + "function": { + "name": "lookup", + "arguments": "{\"key\":\"value\"}" + } + }] + }]) + ); } #[test] @@ -524,6 +733,36 @@ mod tests { )); } + #[test] + fn content_and_refusal_flatten_to_one_text_block() { + let response: OpenAiCompletionResponse = serde_json::from_value(serde_json::json!({ + "id": "chatcmpl-test", + "choices": [{ + "finish_reason": "stop", + "index": 0, + "message": { + "role": "assistant", + "content": "Cannot ", + "refusal": "comply" + }, + "logprobs": null + }], + "created": 1, + "model": "gpt-test", + "object": "chat.completion", + "usage": {"prompt_tokens": 4, "completion_tokens": 2, "total_tokens": 6} + })) + .expect("response fixture"); + let normalized: completion::CompletionResponse<_> = + response.try_into().expect("normalize response"); + + assert_eq!(normalized.choice.len(), 1); + assert!(matches!( + normalized.choice.first(), + AssistantContent::Text(value) if value.text_ref() == "Cannot comply" + )); + } + #[test] fn tool_calls_map_with_ids_names_and_parsed_arguments() { let response: OpenAiCompletionResponse = serde_json::from_value(serde_json::json!({ From 49937af57059d0a8ee304140f52d9385f0535d8f Mon Sep 17 00:00:00 2001 From: wenzr Date: Fri, 31 Jul 2026 11:49:56 +0800 Subject: [PATCH 7/8] =?UTF-8?q?Provider:=20=E8=A1=A5=E9=BD=90=20OpenAI=20?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E5=86=99=E5=85=A5=E7=94=A8=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/providers/openai/protocol.rs | 40 +++++++++++++++++-- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/crates/kuncode-core/src/providers/openai/protocol.rs b/crates/kuncode-core/src/providers/openai/protocol.rs index 3cf171b..d2f798a 100644 --- a/crates/kuncode-core/src/providers/openai/protocol.rs +++ b/crates/kuncode-core/src/providers/openai/protocol.rs @@ -418,18 +418,26 @@ struct CompletionTokenDetails { struct PromptTokenDetails { #[serde(default)] cached_tokens: u32, + #[serde(default)] + cache_write_tokens: u32, } impl From for completion::Usage { fn from(value: Usage) -> Self { + let (cached_input_tokens, cache_creation_input_tokens) = + value.prompt_tokens_details.map_or((0, 0), |details| { + ( + u64::from(details.cached_tokens), + u64::from(details.cache_write_tokens), + ) + }); + Self { input_tokens: u64::from(value.prompt_tokens), output_tokens: u64::from(value.completion_tokens), total_tokens: u64::from(value.total_tokens), - cached_input_tokens: value - .prompt_tokens_details - .map_or(0, |details| u64::from(details.cached_tokens)), - cache_creation_input_tokens: 0, + cached_input_tokens, + cache_creation_input_tokens, reasoning_tokens: value .completion_tokens_details .map_or(0, |details| u64::from(details.reasoning_tokens)), @@ -706,6 +714,30 @@ mod tests { )); } + #[test] + fn usage_maps_cache_reads_and_writes() { + let usage: Usage = serde_json::from_value(serde_json::json!({ + "prompt_tokens": 100, + "completion_tokens": 20, + "total_tokens": 120, + "prompt_tokens_details": { + "cached_tokens": 40, + "cache_write_tokens": 60 + }, + "completion_tokens_details": {"reasoning_tokens": 10} + })) + .expect("usage fixture"); + + let normalized: completion::Usage = usage.into(); + + assert_eq!(normalized.input_tokens, 100); + assert_eq!(normalized.output_tokens, 20); + assert_eq!(normalized.total_tokens, 120); + assert_eq!(normalized.cached_input_tokens, 40); + assert_eq!(normalized.cache_creation_input_tokens, 60); + assert_eq!(normalized.reasoning_tokens, 10); + } + #[test] fn refusal_flattens_to_assistant_text() { let response: OpenAiCompletionResponse = serde_json::from_value(serde_json::json!({ From 7a9d0b0e772618f89a738941f4230332eb8d21e9 Mon Sep 17 00:00:00 2001 From: wenzr Date: Fri, 31 Jul 2026 12:25:34 +0800 Subject: [PATCH 8/8] =?UTF-8?q?Provider:=20=E5=9C=A8=20OpenAI=20=E5=87=AD?= =?UTF-8?q?=E6=8D=AE=E9=94=99=E8=AF=AF=E4=B8=AD=E4=BF=9D=E7=95=99=E5=8F=98?= =?UTF-8?q?=E9=87=8F=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/kuncode-core/src/providers/openai.rs | 32 ++++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/crates/kuncode-core/src/providers/openai.rs b/crates/kuncode-core/src/providers/openai.rs index 74a2545..7ef3ba6 100644 --- a/crates/kuncode-core/src/providers/openai.rs +++ b/crates/kuncode-core/src/providers/openai.rs @@ -18,6 +18,7 @@ use self::protocol::{OpenAiCompletionRequest, OpenAiCompletionResponse, Usage}; mod protocol; const OPENAI_COMPLETIONS_URL: &str = "https://api.openai.com/v1/chat/completions"; +const OPENAI_API_KEY_ENV: &str = "OPENAI_API_KEY"; /// Errors produced while constructing an OpenAI client. #[derive(Debug, Error)] @@ -25,9 +26,15 @@ pub enum Error { /// The underlying HTTP client could not be built. #[error("HTTP client error: {0}")] 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), + /// Required environment variable was missing or invalid. + #[error("environment variable `{name}` is not set or is invalid")] + EnvironmentVariable { + /// Environment variable name that was read. + name: String, + #[source] + /// Original environment lookup error. + source: VarError, + }, } /// Authenticated client for the official OpenAI API. @@ -61,7 +68,11 @@ impl OpenAiClient { /// Returns [`Error::EnvironmentVariable`] when the credential is unavailable, /// or [`Error::Client`] when the HTTP client cannot be configured. pub fn from_env() -> Result { - let api_key = std::env::var("OPENAI_API_KEY").map_err(Error::EnvironmentVariable)?; + let api_key = + std::env::var(OPENAI_API_KEY_ENV).map_err(|source| Error::EnvironmentVariable { + name: OPENAI_API_KEY_ENV.to_string(), + source, + })?; Self::new(api_key) } @@ -167,6 +178,19 @@ mod tests { use super::*; use crate::completion::AssistantContent; + #[test] + fn environment_variable_error_debug_names_openai_api_key() { + let error: Box = Box::new(Error::EnvironmentVariable { + name: OPENAI_API_KEY_ENV.to_string(), + source: VarError::NotPresent, + }); + + assert_eq!( + format!("Error: {error:?}"), + r#"Error: EnvironmentVariable { name: "OPENAI_API_KEY", source: NotPresent }"# + ); + } + #[test] fn normalize_response_projects_content_and_keeps_the_original_json() { let raw = serde_json::json!({