diff --git a/README.md b/README.md index 6aca9aa..bb2bd10 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 Provider。 - `kuncode-agent`:Agent 运行时、工具调度、权限、Hook、Todo、会话持久化和上下文压缩。 - `kuncode-cli`:命令行参数、项目配置、终端审批、普通输出和交互式 TUI。 ## 环境要求 - Rust stable,项目使用 Rust 2024 edition。 -- DeepSeek API Key。 +- DeepSeek 或 OpenAI API Key。 - 支持 ANSI 终端;交互模式需要 stdin 和 stdout 都连接到真实终端。 ## 快速开始 @@ -40,6 +41,24 @@ export DEEPSEEK_API_KEY="your-api-key" DEEPSEEK_API_KEY=your-api-key ``` +使用 OpenAI 官方接口时,在 `.kuncode/settings.json` 配置: + +```json +{ + "model": { + "provider": "openai", + "name": "gpt-5.1", + "maxTokens": 16384 + } +} +``` + +并设置对应环境变量: + +```bash +export OPENAI_API_KEY="your-api-key" +``` + 一次性执行任务: ```bash @@ -76,6 +95,7 @@ cargo build --release -p kuncode-cli "defaultMode": "default" }, "model": { + "provider": "deepseek", "name": "deepseek-v4-pro", "maxTokens": 65536 }, @@ -94,8 +114,14 @@ cargo build --release -p kuncode-cli 补充说明: -- `DEEPSEEK_MODEL` 可以覆盖配置文件中的模型名称。 +- `KUNCODE_MODEL` 可以覆盖配置文件中的模型名称;`DEEPSEEK_MODEL` 作为兼容别名保留。 +- `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` 只计算和报告压缩候选,不替换当前上下文。 - `enabled` 会在达到预算阈值时执行压缩,并要求会话持久化状态保持健康。 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/runtime.rs b/crates/kuncode-cli/src/runtime.rs index bb04f12..76ab16a 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::OpenAiClient, +}; 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 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?; 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,13 @@ impl CliRuntime> { } } +fn provider_client(project: &ProjectSettings) -> Result> { + match project.provider { + ProviderKind::DeepSeek => Ok(AnyChatClient::DeepSeek(DeepSeekClient::from_env()?)), + ProviderKind::OpenAi => Ok(AnyChatClient::OpenAi(OpenAiClient::from_env()?)), + } +} + fn agent_config(project: &ProjectSettings) -> Result { let compaction = project .compaction @@ -282,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 { @@ -301,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 843ebb7..a00d2e1 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,18 +73,34 @@ struct PermissionsSection { default_mode: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Default, Deserialize)] #[serde(default, rename_all = "camelCase", deny_unknown_fields)] struct ModelSection { - name: String, + provider: ProviderKind, + /// 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 { - 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 { + /// Native DeepSeek behavior and environment defaults. + #[default] + #[serde(rename = "deepseek")] + DeepSeek, + /// Official OpenAI Chat Completions protocol and endpoint. + #[serde(rename = "openai")] + OpenAi, +} + +impl ProviderKind { + fn as_str(self) -> &'static str { + match self { + Self::DeepSeek => "deepseek", + Self::OpenAi => "openai", } } } @@ -166,6 +182,8 @@ 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, /// Effective model identifier after file and environment precedence. pub(crate) model_name: String, /// Effective provider output budget for an ordinary turn. @@ -180,14 +198,15 @@ 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 { policy: None, default_mode: None, trust: ProjectTrust::Untrusted, + provider: ProviderKind::DeepSeek, model_name: DEEPSEEK_V4_PRO_MODEL_ID.to_string(), max_tokens, max_iterations: DEFAULT_MAX_ITERATIONS, @@ -199,9 +218,11 @@ 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 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 /// @@ -213,18 +234,48 @@ pub(crate) fn load_project_settings( root: &Path, trust: ProjectTrust, ) -> Result { - let model_override = 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. @@ -256,27 +307,56 @@ fn read_settings_file(root: &Path) -> Result { fn resolve_settings( file: SettingsFile, - model_override: Option<&str>, + overrides: ModelOverrides<'_>, root: &Path, trust: ProjectTrust, ) -> Result { - let model_name = model_override.unwrap_or(&file.model.name).to_string(); + 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(), + (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(format!( + "provider \"{}\" requires an explicit model name", + ProviderKind::OpenAi.as_str() + ))); + } + }; if model_name.trim().is_empty() { return Err(SettingsError::Model( "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, + 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, ) }); validate_model_max_tokens(max_tokens, profile)?; validate_agent(&file.agent)?; validate_log_level(&file.logging.level)?; - let canonical_root = std::fs::canonicalize(root).map_err(|error| SettingsError::Workspace(error.to_string()))?; let canonical_root = CanonicalPath::from_absolute(&canonical_root) @@ -294,12 +374,20 @@ 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), default_mode, trust, + provider: file.model.provider, model_name, max_tokens, max_iterations: file.agent.max_iterations, @@ -312,6 +400,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), @@ -340,7 +429,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( @@ -443,9 +532,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, @@ -541,7 +633,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 } @@ -551,10 +644,12 @@ 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); assert!(loaded.policy.expect("resolved policy").rules().is_empty()); assert!(loaded.default_mode.is_none()); assert!(loaded.compaction.is_none()); @@ -564,6 +659,123 @@ mod tests { assert_eq!(loaded.todo_reminder_interval, Some(3)); } + #[test] + fn loads_official_openai_provider_settings() { + let loaded = load_json( + "openai", + r#"{ "model": { + "provider": "openai", + "name": "gpt-test", + "maxTokens": 8192 + } }"#, + ) + .expect("loads"); + + assert_eq!(loaded.provider, ProviderKind::OpenAi); + assert_eq!(loaded.model_name, "gpt-test"); + 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 + // 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 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( + "deepseek-provider", + r#"{ "model": { "provider": "deepseek" } }"#, + ) + .expect("loads"); + + assert_eq!(loaded.provider, ProviderKind::DeepSeek); + } + #[test] fn logging_level_defaults_to_info() { let dir = std::env::temp_dir().join(format!("kuncode-log-absent-{}", std::process::id())); @@ -812,9 +1024,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"); 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..5f50135 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`] implements the official OpenAI protocol. +pub mod any_chat; +pub(crate) mod chat_completions; pub mod deepseek; +pub mod openai; 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..6ca3d24 --- /dev/null +++ b/crates/kuncode-core/src/providers/any_chat.rs @@ -0,0 +1,153 @@ +//! 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, protocol::DeepSeekCompletionResponse}, + openai::{OpenAiClient, OpenAiCompletionModel}, + }, +}; + +/// Provider client selected by project configuration. +#[derive(Clone)] +pub enum AnyChatClient { + /// Native DeepSeek protocol behavior. + DeepSeek(DeepSeekClient), + /// Official OpenAI Chat Completions behavior. + OpenAi(OpenAiClient), +} + +/// Model handle that keeps the agent runtime independent of provider choice. +#[derive(Clone)] +pub enum AnyChatCompletionModel { + /// Native DeepSeek model. + DeepSeek(DeepSeekCompletionModel), + /// Official OpenAI model. + OpenAi(OpenAiCompletionModel), +} + +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::OpenAi(client) => { + Self::OpenAi(OpenAiCompletionModel::make(client, model)) + } + } + } + + /// `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, + ) -> Result, CompletionError> { + match self { + Self::DeepSeek(model) => { + let response = model.completion(request).await?; + erase_deepseek_response(response) + } + Self::OpenAi(model) => model.completion(request).await, + } + } + + async fn stream( + &self, + request: CompletionRequest, + ) -> Result { + match self { + Self::DeepSeek(model) => model.stream(request).await, + Self::OpenAi(model) => model.stream(request).await, + } + } +} + +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 new file mode 100644 index 0000000..ee65197 --- /dev/null +++ b/crates/kuncode-core/src/providers/chat_completions.rs @@ -0,0 +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/protocol/streaming.rs b/crates/kuncode-core/src/providers/chat_completions/streaming.rs similarity index 82% rename from crates/kuncode-core/src/providers/deepseek/protocol/streaming.rs rename to crates/kuncode-core/src/providers/chat_completions/streaming.rs index 40cbe30..85a11c5 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,10 @@ 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>, } @@ -148,9 +151,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 +168,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.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); } @@ -251,7 +261,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 +276,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 { @@ -278,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`]. /// @@ -286,7 +317,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 +329,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 +350,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 +364,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 +465,35 @@ data: [DONE] ); } + #[test] + 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 \"}}]} + +data: {\"choices\":[{\"delta\":{\"refusal\":\"comply\"},\"finish_reason\":\"stop\"}]} + +data: [DONE] + +"; + let events = run(sse, 4096); + let text: String = events + .iter() + .filter_map(|event| match event { + StreamEvent::TextDelta(text) => Some(text.clone()), + _ => None, + }) + .collect(); + assert_eq!(text, "Cannot comply"); + + let (content, _) = completed(events); + assert!(matches!( + content.first(), + AssistantContent::Text(value) if value.text_ref() == "Cannot comply" + )); + } + #[test] fn tool_call_arguments_assemble_across_fragments() { let sse = "\ @@ -480,7 +544,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 +574,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)); @@ -529,15 +593,31 @@ 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 // 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..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 { @@ -205,7 +195,18 @@ impl CompletionModel for DeepSeekCompletionModel { }); } - Ok(protocol::streaming::stream_events(response)) + 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 c97df9d..0740b9e 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 @@ -50,7 +48,9 @@ pub enum Message { /// Assistant output: visible text plus optional tool calls and reasoning. Assistant { - /// Visible assistant text. + /// 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")] diff --git a/crates/kuncode-core/src/providers/openai.rs b/crates/kuncode-core/src/providers/openai.rs new file mode 100644 index 0000000..7ef3ba6 --- /dev/null +++ b/crates/kuncode-core/src/providers/openai.rs @@ -0,0 +1,234 @@ +//! Official OpenAI Chat Completions provider. + +use std::env::VarError; + +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::{CONNECT_TIMEOUT, READ_TIMEOUT, REQUEST_TIMEOUT, streaming}, +}; + +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)] +pub enum Error { + /// The underlying HTTP client could not be built. + #[error("HTTP client error: {0}")] + Client(#[from] reqwest::Error), + /// 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. +#[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_ENV).map_err(|source| Error::EnvironmentVariable { + name: OPENAI_API_KEY_ENV.to_string(), + source, + })?; + 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(), + }); + } + streaming::validate_stream_content_type( + response + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + )?; + Ok(streaming::stream_events::(response)) + } +} + +/// Projects the server's JSON into the domain response while handing the +/// 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()?; + Ok(CompletionResponse { + choice: normalized.choice, + usage: normalized.usage, + raw_response: raw, + message_id: normalized.message_id, + }) +} + +#[cfg(test)] +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!({ + "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 new file mode 100644 index 0000000..d2f798a --- /dev/null +++ b/crates/kuncode-core/src/providers/openai/protocol.rs @@ -0,0 +1,833 @@ +//! 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 tool_calls = Vec::new(); + for block in content { + match block { + message::AssistantContent::Text(value) => text.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, + // Inbound-only: refusals are flattened to text on receipt, + // so replayed history never carries the field. + refusal: None, + 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(); + 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, + tool_choice, + 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, + top_p: request.top_p, + stop: request.stop.filter(|value| !value.is_empty()), + reasoning_effort, + 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, +} + +#[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 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, + 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), + completion::ReasoningEffort::Medium => Some(Self::Medium), + completion::ReasoningEffort::High => Some(Self::High), + completion::ReasoningEffort::Xhigh => Some(Self::Xhigh), + } + } +} + +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 { + JsonSchema { json_schema: JsonSchema }, +} + +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: false, + }, + } + } +} + +#[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, + #[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, + cache_creation_input_tokens, + 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(); + let mut answer = String::new(); + if !content.trim().is_empty() { + 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()) { + answer.push_str(refusal); + } + if !answer.is_empty() { + blocks.push(AssistantContent::text(answer)); + } + blocks.extend(tool_calls.iter().map(|call| { + AssistantContent::tool_call( + &call.id, + &call.function.name, + call.function.arguments.clone(), + ) + })); + 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, + // `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, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + 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() { + 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::Medium)) + .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"], "medium"); + assert_eq!(json["response_format"]["type"], "json_schema"); + // 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 reasoning_effort_off_maps_to_none_when_the_model_supports_it() { + let request = CompletionRequestBuilder::new(DomainMessage::user("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] + 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 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!({ + "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"); + + // 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::Text(value) if value.text_ref() == "Cannot comply" + )); + } + + #[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!({ + "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"})); + } +}