diff --git a/CHANGELOG.md b/CHANGELOG.md index 021c20a..8ac90a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,40 @@ All notable changes to enowX Coder are documented here. --- +## [0.2.6] — 2026-04-25 + +### Token Optimization — 99% Reduction for Anthropic-format Gateways +- **Prompt caching now works for custom providers**: Added `api_format` field (`openai` | `anthropic`) to providers — custom gateways using Anthropic Messages API now get prompt caching, reducing prompt tokens from ~11,700 to ~0 on cache hits +- **Chat history sliding window**: Chat path now applies the same context trimming as agent path — max 20 message pairs, 32K char budget, per-message truncation, `html:preview` blocks stripped. Prevents token bloat on long sessions +- **`uses_anthropic_format()` method**: Centralized routing logic replaces scattered `provider_type == "anthropic" || provider_type == "enowxlabs"` checks across chat service and agent runner + +### Gateway SSE Compatibility Fix +- **Event-line fallback for SSE parsing**: Some Anthropic-compatible gateways omit the `"type"` field from SSE data payloads. Parser now tracks the preceding `event:` line and uses it as fallback — fixes empty responses from proxies like LiteLLM, Claude Desktop gateway, and enowX Labs gateway +- Applied to both chat SSE parser (`chat_service.rs`) and agent tool SSE parser (`runner.rs`) + +### Non-Streaming Fallback for Unsupported Models +- **Auto-retry without streaming**: When a gateway returns an empty stream (message_start → message_stop with no content blocks), the request is automatically retried with `stream: false` and the full response is parsed synchronously +- Fixes blank responses for models where the gateway doesn't support streaming (e.g. `claude-opus-4.6` on certain proxies) +- Applied to both chat path and agent path (with tool call support) + +### Endpoint Resolution Fix for Custom Gateways +- **Preserve `/v1` path for custom providers**: Previously, all non-Anthropic providers had `/v1` stripped from their base URL when building the Anthropic endpoint, resulting in `host/messages` instead of `host/v1/messages`. Now only the built-in `enowxlabs` provider strips `/v1`; custom gateways keep their full path +- Fixed in chat service, title generation, and agent runner + +### Model Listing for Custom Providers +- **Custom providers can now list models**: Previously, unknown `provider_type` slugs (e.g. user-created `"my-gateway"`) returned "Unknown provider type" error. Now routes by `api_format` — Anthropic-format providers hit `{base_url}/models` with correct auth headers +- `fetch_anthropic_models` now accepts a configurable base URL and auth scheme instead of hardcoding `api.anthropic.com` + +### Provider Settings UI +- **API Format selector**: New toggle (OpenAI / Anthropic) in Settings for custom providers — choose Anthropic for Claude-compatible gateways to enable prompt caching and correct message serialization +- Selector shown in both "Add Provider" form and existing provider detail panel +- Built-in providers (`enowxlabs`, `anthropic`) auto-set to Anthropic format + +### Database +- **Migration `20260424000_provider_api_format.sql`**: Adds `api_format TEXT NOT NULL DEFAULT 'openai'` column to providers table. Existing `anthropic` and `enowxlabs` providers auto-updated to `'anthropic'` format + +--- + ## [0.2.5] — 2026-04-23 ### Excalidraw Canvas — Collaborative Whiteboard diff --git a/README.md b/README.md index 73e083a..48c4406 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,24 @@ ![Settings](screenshots/Providers.png) +### Token Optimization — Before & After + +Prompt caching with Anthropic-format routing reduces token usage by **99.87%** on repeated requests. + +| | Before | After | +|---|---|---| +| **Total Tokens** | 19,819 | 26 | +| **Prompt Tokens** | 19,779 | 0 (cache hit) | +| **Completion Tokens** | 40 | 26 | + +**Before** — Every request sends the full system prompt (~19K prompt tokens): + +![Before Optimization](screenshots/before.png) + +**After** — Prompt caching enabled via Anthropic Messages API format (0 prompt tokens on cache hit): + +![After Optimization](screenshots/after.png) + --- ## 🚀 Installation diff --git a/screenshots/after.png b/screenshots/after.png new file mode 100644 index 0000000..c92db06 Binary files /dev/null and b/screenshots/after.png differ diff --git a/screenshots/before.png b/screenshots/before.png new file mode 100644 index 0000000..4bbc261 Binary files /dev/null and b/screenshots/before.png differ diff --git a/src-tauri/migrations/20260424000_provider_api_format.sql b/src-tauri/migrations/20260424000_provider_api_format.sql new file mode 100644 index 0000000..c73dc1c --- /dev/null +++ b/src-tauri/migrations/20260424000_provider_api_format.sql @@ -0,0 +1,8 @@ +-- Add api_format column to providers. +-- Values: 'openai' (default) or 'anthropic'. +-- This lets custom/gateway providers opt into the Anthropic message format +-- which enables prompt caching and correct content-block serialisation. +ALTER TABLE providers ADD COLUMN api_format TEXT NOT NULL DEFAULT 'openai'; + +-- Built-in providers that already use Anthropic format +UPDATE providers SET api_format = 'anthropic' WHERE provider_type IN ('anthropic', 'enowxlabs'); diff --git a/src-tauri/src/agents/runner.rs b/src-tauri/src/agents/runner.rs index ff92ba2..08678ce 100644 --- a/src-tauri/src/agents/runner.rs +++ b/src-tauri/src/agents/runner.rs @@ -25,6 +25,18 @@ const SYNTHESIS_REACT_ITERATIONS: usize = 8; const LANGUAGE_GUARD: &str = "IMPORTANT: Reply using the same language as the user's latest message. If user writes Indonesian, answer in Indonesian. Never switch to another language unless the user explicitly asks you to."; +/// Short hint appended when flux is enabled but user didn't ask for visuals (~50 tokens vs ~4500) +const PREVIEW_HINT: &str = "You can create interactive visualizations (charts, diagrams, widgets) by outputting a fenced code block with the language tag `html:preview`. The preview iframe has a full design system pre-loaded with CSS variables, SVG color ramp classes, and light/dark mode support. Use this when the user asks for any visual or interactive content."; + +/// Keywords that trigger the full PREVIEW_GUIDE injection +const VISUAL_KEYWORDS: &[&str] = &[ + "chart", "diagram", "graph", "visuali", "svg", "plot", "widget", + "mockup", "wireframe", "flowchart", "draw", "gambar", "buat grafik", + "bikin chart", "bikin diagram", "buatkan", "tampilkan", "tabel", + "html:preview", "interactive", "infographic", "dashboard", "canvas", + "pie chart", "bar chart", "line chart", "perbandingan", "statistik", +]; + const PREVIEW_GUIDE: &str = r#"INTERACTIVE PREVIEW — VISUAL CREATION SYSTEM When the user asks for a visualization, diagram, chart, interactive demo, UI mockup, or any visual/interactive HTML content, output it directly in your response as a fenced code block with the language tag `html:preview`. Do NOT use write_file — the app renders it as a live interactive preview inline. Only use write_file when the user explicitly asks to save a file on disk. @@ -586,8 +598,14 @@ impl AgentRunner { let model = ctx.model_id.unwrap_or(&provider.model); let tool_executor = ToolExecutor::new(PathBuf::from(ctx.project_path)); + // Solusi 1: Lazy PREVIEW_GUIDE — only inject full guide when user asks for visuals let system_content = if ctx.flux_enabled { - format!("{}\n\n{}\n\n{}", system_prompt, LANGUAGE_GUARD, PREVIEW_GUIDE) + if needs_full_preview_guide(ctx.task) { + format!("{}\n\n{}\n\n{}", system_prompt, LANGUAGE_GUARD, PREVIEW_GUIDE) + } else { + // Mini hint only (~50 tokens vs ~4500) + format!("{}\n\n{}\n\n{}", system_prompt, LANGUAGE_GUARD, PREVIEW_HINT) + } } else { format!("{}\n\n{}", system_prompt, LANGUAGE_GUARD) }; @@ -722,7 +740,11 @@ impl AgentRunner { let tool_executor = ToolExecutor::new(PathBuf::from(ctx.project_path)); let system_content = if ctx.flux_enabled { - format!("{}\n\n{}\n\n{}", system_prompt, LANGUAGE_GUARD, PREVIEW_GUIDE) + if needs_full_preview_guide(ctx.task) { + format!("{}\n\n{}\n\n{}", system_prompt, LANGUAGE_GUARD, PREVIEW_GUIDE) + } else { + format!("{}\n\n{}\n\n{}", system_prompt, LANGUAGE_GUARD, PREVIEW_HINT) + } } else { format!("{}\n\n{}", system_prompt, LANGUAGE_GUARD) }; @@ -772,9 +794,11 @@ impl AgentRunner { return Err(AppError::Cancelled); } - let turn = if provider.provider_type == "anthropic" { + let turn = if provider.uses_anthropic_format() { self.send_anthropic_with_tools( + &provider.base_url, &provider.api_key, + &provider.provider_type, model, messages, agent_run_id, @@ -819,9 +843,12 @@ impl AgentRunner { ) .await?; + // Solusi 3: Truncate large tool results to reduce token accumulation + let truncated_output = truncate_tool_result(&execution.output); + messages.push(ConversationMessage::tool( &tool_call.id, - &execution.output, + &truncated_output, execution.is_error, )); } @@ -1092,47 +1119,129 @@ impl AgentRunner { .await } + #[allow(clippy::too_many_arguments)] async fn send_anthropic_with_tools( &self, + base_url: &str, api_key: &Option, + provider_type: &str, model: &str, messages: &[ConversationMessage], agent_run_id: &str, token_sink: &S, ) -> AppResult { - let (system, anthropic_messages) = to_anthropic_messages(messages)?; + let (system, mut anthropic_messages) = to_anthropic_messages(messages)?; + + // Prompt caching: mark last user message with cache_control (like Claude Desktop) + if let Some(last_msg) = anthropic_messages.last_mut() { + if last_msg.get("role").and_then(Value::as_str) == Some("user") { + if let Some(content) = last_msg.get_mut("content").and_then(Value::as_array_mut) { + if let Some(last_block) = content.last_mut() { + last_block["cache_control"] = json!({"type": "ephemeral"}); + } + } + } + } + + // Prompt caching: tools cached + let tools_with_cache = { + let mut tools = anthropic_tool_definitions(); + if let Some(last_tool) = tools.last_mut() { + last_tool["cache_control"] = json!({"type": "ephemeral"}); + } + tools + }; + let mut payload = json!({ "model": model, "max_tokens": 8096, "messages": anthropic_messages, - "tools": anthropic_tool_definitions(), + "tools": tools_with_cache, "stream": true, }); + // System prompt as cached content block array if let Some(system_prompt) = system { - payload["system"] = Value::String(system_prompt); + payload["system"] = json!([ + { + "type": "text", + "text": system_prompt, + "cache_control": {"type": "ephemeral"} + } + ]); } + // Resolve endpoint: same logic as chat_service::send_anthropic + let endpoint = if provider_type == "anthropic" { + "https://api.anthropic.com/v1/messages".to_string() + } else if provider_type == "enowxlabs" { + format!("{}/messages", base_url.trim_end_matches('/').trim_end_matches("/v1")) + } else { + // Custom gateway: preserve full base_url path (e.g. /v1/messages) + format!("{}/messages", base_url.trim_end_matches('/')) + }; + let client = reqwest::Client::new(); let mut request = client - .post("https://api.anthropic.com/v1/messages") + .post(&endpoint) .header(CONTENT_TYPE, "application/json") .header("anthropic-version", "2023-06-01") - .json(&payload); + .header("anthropic-beta", "prompt-caching-2024-07-31"); + // Auth: x-api-key for Anthropic direct, Bearer for gateways if let Some(key) = api_key.as_deref().filter(|k| !k.trim().is_empty()) { - request = request.header("x-api-key", key); + if provider_type == "anthropic" { + request = request.header("x-api-key", key); + } else { + request = request.header(AUTHORIZATION, format!("Bearer {key}")); + } } - let response = request.send().await?; + let response = request.json(&payload).send().await?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); return Err(AppError::Http(format!("Anthropic {status}: {body}"))); } - self.stream_anthropic_tool_sse(response, agent_run_id, token_sink) - .await + let turn = self + .stream_anthropic_tool_sse(response, agent_run_id, token_sink) + .await?; + + // Fallback: some gateways return message_start → message_stop without + // any content_block events when streaming certain models. When that + // happens, retry the request with `stream: false` and parse the full + // response synchronously. + if turn.text.is_empty() && turn.tool_calls.is_empty() { + log::warn!("anthropic stream returned empty — retrying non-streaming"); + payload["stream"] = json!(false); + + let mut retry_req = client + .post(&endpoint) + .header(CONTENT_TYPE, "application/json") + .header("anthropic-version", "2023-06-01") + .json(&payload); + + if let Some(key) = api_key.as_deref().filter(|k| !k.trim().is_empty()) { + if provider_type == "anthropic" { + retry_req = retry_req.header("x-api-key", key); + } else { + retry_req = retry_req.header(AUTHORIZATION, format!("Bearer {key}")); + } + } + + let retry_resp = retry_req.send().await?; + if !retry_resp.status().is_success() { + let status = retry_resp.status(); + let body = retry_resp.text().await.unwrap_or_default(); + return Err(AppError::Http(format!("Anthropic non-stream {status}: {body}"))); + } + + let body: Value = retry_resp.json().await?; + return parse_anthropic_non_stream_response(&body, agent_run_id, token_sink, &self.app_handle); + } + + Ok(turn) } async fn stream_openai_tool_sse( @@ -1292,6 +1401,9 @@ impl AgentRunner { let mut output = String::new(); let mut stop_reason: Option = None; let mut pending_calls: HashMap = HashMap::new(); + // Track the most recent `event:` line so we can fall back to it when + // the JSON payload omits the top-level `"type"` field (some gateways). + let mut current_event = String::new(); while let Some(chunk) = stream.next().await { line_buffer.push_str(&String::from_utf8_lossy(&chunk?)); @@ -1305,6 +1417,7 @@ impl AgentRunner { let should_stop = self.parse_anthropic_sse_line( &line, + &mut current_event, agent_run_id, token_sink, &mut output, @@ -1325,6 +1438,7 @@ impl AgentRunner { fn parse_anthropic_sse_line( &self, line: &str, + current_event: &mut String, agent_run_id: &str, token_sink: &S, output: &mut String, @@ -1337,7 +1451,9 @@ impl AgentRunner { } if let Some(event_name) = trimmed.strip_prefix("event:") { - if event_name.trim() == "message_stop" { + let event_name = event_name.trim(); + *current_event = event_name.to_string(); + if event_name == "message_stop" { return Ok(true); } return Ok(false); @@ -1353,7 +1469,12 @@ impl AgentRunner { Err(_) => return Ok(false), }; - let event_type = value.get("type").and_then(Value::as_str).unwrap_or_default(); + // Prefer `"type"` from JSON payload; fall back to the preceding + // `event:` line when the gateway strips it. + let event_type = value + .get("type") + .and_then(Value::as_str) + .unwrap_or(current_event.as_str()); match event_type { "content_block_start" => { @@ -1490,6 +1611,51 @@ struct StreamingToolCall { arguments: String, } +/// Parse a non-streaming Anthropic Messages API response into an `LLMTurn`. +/// +/// Used as a fallback when the gateway returns an empty stream (some proxies +/// don't support streaming for certain models). +fn parse_anthropic_non_stream_response( + body: &Value, + agent_run_id: &str, + token_sink: &S, + app_handle: &tauri::AppHandle, +) -> AppResult { + let mut text = String::new(); + let mut tool_calls = Vec::new(); + + if let Some(content) = body.get("content").and_then(Value::as_array) { + for block in content { + let block_type = block.get("type").and_then(Value::as_str).unwrap_or(""); + match block_type { + "text" => { + if let Some(t) = block.get("text").and_then(Value::as_str) { + text.push_str(t); + // Send tokens to UI so the user sees the response + token_sink.send(t); + let _ = app_handle.emit( + "agent-token", + AgentTokenEvent { + agent_run_id: agent_run_id.to_string(), + token: t.to_string(), + }, + ); + } + } + "tool_use" => { + let id = block.get("id").and_then(Value::as_str).unwrap_or("").to_string(); + let name = block.get("name").and_then(Value::as_str).unwrap_or("").to_string(); + let input = block.get("input").cloned().unwrap_or(Value::Object(Default::default())); + tool_calls.push(ParsedToolCall { id, name, input }); + } + _ => {} + } + } + } + + Ok(LLMTurn { text, tool_calls }) +} + #[derive(Debug, Clone)] struct ToolExecutionOutcome { output: String, @@ -1684,6 +1850,35 @@ fn summarize_html_widget(html: &str) -> String { summary } +/// Truncate tool results to prevent token bloat in ReAct loop. +/// File contents and large outputs are capped; short results pass through unchanged. +const MAX_TOOL_RESULT_CHARS: usize = 3000; + +fn truncate_tool_result(output: &str) -> String { + if output.len() <= MAX_TOOL_RESULT_CHARS { + return output.to_string(); + } + + // Keep first and last portions for context + let head_size = MAX_TOOL_RESULT_CHARS * 2 / 3; // ~2000 chars from start + let tail_size = MAX_TOOL_RESULT_CHARS / 3; // ~1000 chars from end + + let head = &output[..head_size]; + let tail = &output[output.len() - tail_size..]; + let omitted = output.len() - head_size - tail_size; + + format!( + "{}\n\n… [{} chars omitted] …\n\n{}", + head, omitted, tail + ) +} + +/// Check if the user's message contains keywords that need the full PREVIEW_GUIDE +fn needs_full_preview_guide(task: &str) -> bool { + let lower = task.to_lowercase(); + VISUAL_KEYWORDS.iter().any(|kw| lower.contains(kw)) +} + fn finalize_llm_turn( output: String, pending_calls: HashMap, diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index 2e17f9a..98c10d2 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -15,6 +15,7 @@ pub async fn create_provider( base_url: String, api_key: Option, model: String, + api_format: Option, ) -> AppResult { provider_service::create_provider( state.pool(), @@ -23,6 +24,7 @@ pub async fn create_provider( &base_url, api_key.as_deref(), &model, + api_format.as_deref(), ) .await } @@ -35,6 +37,7 @@ pub async fn update_provider( base_url: String, api_key: Option, model: String, + api_format: Option, ) -> AppResult<()> { provider_service::update_provider( state.pool(), @@ -43,6 +46,7 @@ pub async fn update_provider( &base_url, api_key.as_deref(), &model, + api_format.as_deref(), ) .await } @@ -75,12 +79,7 @@ pub async fn list_models( crate::services::provider_service::get_provider_for_chat(state.pool(), Some(&provider_id)) .await?; - crate::services::model_service::list_models( - &provider.provider_type, - &provider.base_url, - provider.api_key.as_deref(), - ) - .await + crate::services::model_service::list_models(&provider).await } #[tauri::command] diff --git a/src-tauri/src/models/provider.rs b/src-tauri/src/models/provider.rs index 915c455..c22a489 100644 --- a/src-tauri/src/models/provider.rs +++ b/src-tauri/src/models/provider.rs @@ -13,10 +13,29 @@ pub struct Provider { pub is_default: bool, pub is_builtin: bool, pub is_enabled: bool, + /// Wire format: `"openai"` (default) or `"anthropic"`. + /// Determines which serialisation path (and prompt caching) is used. + #[serde(default = "default_api_format")] + #[sqlx(default)] + pub api_format: String, pub created_at: String, pub updated_at: String, } +fn default_api_format() -> String { + "openai".to_string() +} + +impl Provider { + /// Returns `true` when the provider should use the Anthropic Messages API + /// format (content blocks, system-as-top-level, prompt caching). + pub fn uses_anthropic_format(&self) -> bool { + self.api_format == "anthropic" + || self.provider_type == "anthropic" + || self.provider_type == "enowxlabs" + } +} + pub fn fixed_base_url(provider_type: &str) -> Option<&'static str> { match provider_type { "enowxlabs" => Some("https://api.enowxlabs.com/v1"), diff --git a/src-tauri/src/services/chat_service.rs b/src-tauri/src/services/chat_service.rs index 36f09bd..3013c01 100644 --- a/src-tauri/src/services/chat_service.rs +++ b/src-tauri/src/services/chat_service.rs @@ -14,6 +14,20 @@ use crate::{ use super::{now_rfc3339, provider_service}; +/// Keywords that trigger full visual/preview system prompt injection +const VISUAL_KEYWORDS: &[&str] = &[ + "chart", "diagram", "graph", "visuali", "svg", "plot", "widget", + "mockup", "wireframe", "flowchart", "draw", "gambar", "buat grafik", + "bikin chart", "bikin diagram", "buatkan", "tampilkan", "tabel", + "html:preview", "interactive", "infographic", "dashboard", "canvas", + "pie chart", "bar chart", "line chart", "perbandingan", "statistik", +]; + +fn needs_visual_guide(content: &str) -> bool { + let lower = content.to_lowercase(); + VISUAL_KEYWORDS.iter().any(|kw| lower.contains(kw)) +} + pub async fn get_messages(db: &SqlitePool, session_id: &str) -> AppResult> { let messages = sqlx::query_as::<_, Message>( "SELECT id, session_id, role, content, created_at FROM messages \ @@ -26,6 +40,129 @@ pub async fn get_messages(db: &SqlitePool, session_id: &str) -> AppResult Vec { + const MAX_HISTORY_PAIRS: usize = 20; + const MAX_TOTAL_CHARS: usize = 32_000; // ≈ 8 K tokens + const MAX_USER_MSG_CHARS: usize = 2_000; + const MAX_ASSISTANT_MSG_CHARS: usize = 4_000; + + // Separate system messages (always kept) from chat messages + let system_msgs: Vec<&Message> = history.iter().filter(|m| m.role == "system").collect(); + let chat_msgs: Vec<&Message> = history.iter().filter(|m| m.role != "system").collect(); + + // Sliding window: keep only the most recent N*2 chat messages + let window_start = chat_msgs.len().saturating_sub(MAX_HISTORY_PAIRS * 2); + let windowed = &chat_msgs[window_start..]; + + let mut result: Vec = Vec::with_capacity(system_msgs.len() + windowed.len()); + let mut total_chars: usize = 0; + + // Always include system messages first (they're tiny) + for msg in &system_msgs { + total_chars += msg.content.len(); + result.push((*msg).clone()); + } + + for msg in windowed { + if total_chars >= MAX_TOTAL_CHARS { + break; + } + + let cleaned = if msg.role == "assistant" { + strip_preview_blocks_chat(&msg.content) + } else { + msg.content.clone() + }; + + let max_len = if msg.role == "user" { + MAX_USER_MSG_CHARS + } else { + MAX_ASSISTANT_MSG_CHARS + }; + + let truncated = if cleaned.len() > max_len { + let cut = &cleaned[..max_len]; + let last_space = cut.rfind(' ').unwrap_or(max_len); + format!("{}… [truncated]", &cleaned[..last_space]) + } else { + cleaned + }; + + total_chars += truncated.len(); + + result.push(Message { + id: msg.id.clone(), + session_id: msg.session_id.clone(), + role: msg.role.clone(), + content: truncated, + created_at: msg.created_at.clone(), + }); + } + + result +} + +/// Strip ```html:preview … ``` fenced blocks from assistant output. +/// These are rendered widgets that can be thousands of tokens and add no +/// conversational value when sent back as context. +fn strip_preview_blocks_chat(content: &str) -> String { + let mut result = String::with_capacity(content.len()); + let mut chars = content.char_indices().peekable(); + let fence_tag = "```html:preview"; + + while let Some(&(i, _)) = chars.peek() { + if content[i..].starts_with(fence_tag) { + // Skip past the opening fence line + while let Some(&(_, c)) = chars.peek() { + chars.next(); + if c == '\n' { + break; + } + } + // Skip until closing ``` + let mut found_close = false; + while let Some(&(j, _)) = chars.peek() { + if content[j..].starts_with("```") { + // consume the closing ``` + for _ in 0..3 { + chars.next(); + } + // consume rest of line + while let Some(&(_, c)) = chars.peek() { + chars.next(); + if c == '\n' { + break; + } + } + found_close = true; + break; + } + chars.next(); + } + result.push_str("[interactive preview]"); + if !found_close { + break; + } + } else { + let (_, c) = chars.next().unwrap(); + result.push(c); + } + } + + result +} + #[allow(clippy::too_many_arguments)] pub async fn send_message( db: &SqlitePool, @@ -113,13 +250,42 @@ async fn send_message_inner( ))); } - let history = get_messages(db, session_id).await?; + let raw_history = get_messages(db, session_id).await?; + let history = trim_history_for_llm(&raw_history); + + // Debug: log context size so token bloat is easy to spot + let total_chars: usize = history.iter().map(|m| m.content.len()).sum(); + let est_tokens = total_chars / 4; // rough estimate: 1 token ≈ 4 chars + log::debug!( + "chat context: {} msgs (raw {}), ~{} chars (~{} tokens)", + history.len(), + raw_history.len(), + total_chars, + est_tokens, + ); // Use caller-supplied model_id if provided, otherwise fall back to provider default let model = model_id.unwrap_or(&provider.model); - let assistant_output = if provider.provider_type == "anthropic" { - send_anthropic(history, model, provider.api_key.as_deref(), &on_token, &cancel_token).await? + log::info!( + "chat route: provider={} type={} api_format={} → {}", + provider.name, + provider.provider_type, + provider.api_format, + if provider.uses_anthropic_format() { "anthropic" } else { "openai" }, + ); + + let assistant_output = if provider.uses_anthropic_format() { + send_anthropic( + history, + model, + provider.api_key.as_deref(), + &provider.provider_type, + &provider.base_url, + &on_token, + &cancel_token, + ) + .await? } else { send_openai_compatible( &provider.base_url, @@ -180,15 +346,24 @@ async fn send_openai_compatible( "stream": true, }); - let system_instructions = concat!( - "IMPORTANT: Reply using the same language as the user's latest message. If user writes Indonesian, answer in Indonesian. Never switch to another language unless the user explicitly asks you to.\n\n", - "INTERACTIVE PREVIEW: When the user asks for a visualization, diagram, chart, interactive demo, or any visual HTML content, output it as a fenced code block with tag `html:preview`. The app renders it as a live iframe preview with a full design system pre-loaded (CSS variables, SVG color ramp classes, pre-styled form elements, light/dark mode).\n\n", - "Design rules: flat (no gradients/shadows/glow), use CSS vars for colors (var(--color-text-primary), var(--color-background-secondary), etc). system-ui font, 2 weights (400/500), sentence case. Structure: style → content → script last.\n\n", - "SVG diagrams: use pre-loaded classes — `.t` (14px text), `.ts` (12px), `.th` (14px bold), `.box` (neutral), `.node` (clickable), `.arr` (arrow), `.leader` (dashed). Color ramps: `class=\"c-blue\"` on `` wrapping shape+text — auto light/dark. Available: c-purple, c-teal, c-coral, c-blue, c-amber, c-green, c-red, c-gray, c-pink. Max 2-3 ramps per diagram.\n\n", - "Chart.js: wrap canvas in div with position:relative + explicit height. Load UMD from cdnjs.cloudflare.com with onload callback. Disable default legend, build custom HTML legend with 10px colored squares.\n\n", - "Interactive: form elements pre-styled. Use sendPrompt(text) for drill-down. CDN: cdnjs.cloudflare.com, cdn.jsdelivr.net, unpkg.com, esm.sh only.\n\n", - "Always output COMPLETE standalone HTML (DOCTYPE, html, head, body). No titles/prose inside widget — explanations go in your response text." - ); + // Lazy system prompt: only inject full preview guide when user asks for visuals + let last_user_content = history.iter().rev().find(|m| m.role == "user").map(|m| m.content.as_str()).unwrap_or(""); + let system_instructions = if needs_visual_guide(last_user_content) { + concat!( + "IMPORTANT: Reply using the same language as the user's latest message. If user writes Indonesian, answer in Indonesian. Never switch to another language unless the user explicitly asks you to.\n\n", + "INTERACTIVE PREVIEW: When the user asks for a visualization, diagram, chart, interactive demo, or any visual HTML content, output it as a fenced code block with tag `html:preview`. The app renders it as a live iframe preview with a full design system pre-loaded (CSS variables, SVG color ramp classes, pre-styled form elements, light/dark mode).\n\n", + "Design rules: flat (no gradients/shadows/glow), use CSS vars for colors (var(--color-text-primary), var(--color-background-secondary), etc). system-ui font, 2 weights (400/500), sentence case. Structure: style → content → script last.\n\n", + "SVG diagrams: use pre-loaded classes — `.t` (14px text), `.ts` (12px), `.th` (14px bold), `.box` (neutral), `.node` (clickable), `.arr` (arrow), `.leader` (dashed). Color ramps: `class=\"c-blue\"` on `` wrapping shape+text — auto light/dark. Available: c-purple, c-teal, c-coral, c-blue, c-amber, c-green, c-red, c-gray, c-pink. Max 2-3 ramps per diagram.\n\n", + "Chart.js: wrap canvas in div with position:relative + explicit height. Load UMD from cdnjs.cloudflare.com with onload callback. Disable default legend, build custom HTML legend with 10px colored squares.\n\n", + "Interactive: form elements pre-styled. Use sendPrompt(text) for drill-down. CDN: cdnjs.cloudflare.com, cdn.jsdelivr.net, unpkg.com, esm.sh only.\n\n", + "Always output COMPLETE standalone HTML (DOCTYPE, html, head, body). No titles/prose inside widget — explanations go in your response text." + ) + } else { + concat!( + "IMPORTANT: Reply using the same language as the user's latest message. If user writes Indonesian, answer in Indonesian. Never switch to another language unless the user explicitly asks you to.\n\n", + "You can create interactive visualizations (charts, diagrams, widgets) by outputting a fenced code block with the language tag `html:preview`. The preview iframe has a full design system pre-loaded with CSS variables, SVG color ramp classes, and light/dark mode support. Use this when the user asks for any visual or interactive content." + ) + }; let payload_with_system = if let Some(arr) = payload.get("messages").and_then(Value::as_array) { let mut updated = arr.clone(); updated.insert( @@ -228,6 +403,8 @@ async fn send_anthropic( history: Vec, model: &str, api_key: Option<&str>, + provider_type: &str, + base_url: &str, on_token: &Channel, cancel_token: &CancellationToken, ) -> AppResult { @@ -236,12 +413,30 @@ async fn send_anthropic( let (system_msgs, chat_msgs): (Vec<_>, Vec<_>) = history.iter().partition(|m| m.role == "system"); - let messages: Vec = chat_msgs + // Build messages as Anthropic content-block format for cache_control support + let mut messages: Vec = chat_msgs .iter() .filter(|m| m.role == "user" || m.role == "assistant") - .map(|m| serde_json::json!({ "role": m.role, "content": m.content })) + .map(|m| { + serde_json::json!({ + "role": m.role, + "content": [{ "type": "text", "text": m.content }] + }) + }) .collect(); + // Prompt caching: mark last user message with cache_control so the entire + // conversation prefix is cached across turns (like Claude Desktop does). + if let Some(last_msg) = messages.last_mut() { + if last_msg.get("role").and_then(Value::as_str) == Some("user") { + if let Some(content) = last_msg.get_mut("content").and_then(Value::as_array_mut) { + if let Some(last_block) = content.last_mut() { + last_block["cache_control"] = serde_json::json!({"type": "ephemeral"}); + } + } + } + } + let mut payload = serde_json::json!({ "model": model, "max_tokens": 8096, @@ -250,29 +445,78 @@ async fn send_anthropic( "stream": true, }); - let system_instructions_anthropic = concat!( - "IMPORTANT: Reply using the same language as the user's latest message. If user writes Indonesian, answer in Indonesian. Never switch to another language unless the user explicitly asks you to.\n\n", - "INTERACTIVE PREVIEW: When the user asks for a visualization, diagram, chart, interactive demo, or any visual HTML content, output it as a fenced code block with tag `html:preview`. The app renders it as a live iframe preview with a full design system pre-loaded (CSS variables, SVG color ramp classes, pre-styled form elements, light/dark mode).\n\n", - "Design rules: flat (no gradients/shadows/glow), use CSS vars for colors (var(--color-text-primary), var(--color-background-secondary), etc). system-ui font, 2 weights (400/500), sentence case. Structure: style → content → script last.\n\n", - "SVG diagrams: use pre-loaded classes — `.t` (14px text), `.ts` (12px), `.th` (14px bold), `.box` (neutral), `.node` (clickable), `.arr` (arrow), `.leader` (dashed). Color ramps: `class=\"c-blue\"` on `` wrapping shape+text — auto light/dark. Available: c-purple, c-teal, c-coral, c-blue, c-amber, c-green, c-red, c-gray, c-pink. Max 2-3 ramps per diagram.\n\n", - "Chart.js: wrap canvas in div with position:relative + explicit height. Load UMD from cdnjs.cloudflare.com with onload callback. Disable default legend, build custom HTML legend with 10px colored squares.\n\n", - "Interactive: form elements pre-styled. Use sendPrompt(text) for drill-down. CDN: cdnjs.cloudflare.com, cdn.jsdelivr.net, unpkg.com, esm.sh only.\n\n", - "Always output COMPLETE standalone HTML (DOCTYPE, html, head, body). No titles/prose inside widget — explanations go in your response text." - ); - if let Some(sys) = system_msgs.first() { - payload["system"] = serde_json::json!(format!("{}\n\n{}", sys.content, system_instructions_anthropic)); + // Lazy system prompt for Anthropic: same logic as OpenAI path + let last_user_content_anthropic = chat_msgs.iter().rev().find(|m| m.role == "user").map(|m| m.content.as_str()).unwrap_or(""); + let system_instructions_anthropic = if needs_visual_guide(last_user_content_anthropic) { + concat!( + "IMPORTANT: Reply using the same language as the user's latest message. If user writes Indonesian, answer in Indonesian. Never switch to another language unless the user explicitly asks you to.\n\n", + "INTERACTIVE PREVIEW: When the user asks for a visualization, diagram, chart, interactive demo, or any visual HTML content, output it as a fenced code block with tag `html:preview`. The app renders it as a live iframe preview with a full design system pre-loaded (CSS variables, SVG color ramp classes, pre-styled form elements, light/dark mode).\n\n", + "Design rules: flat (no gradients/shadows/glow), use CSS vars for colors (var(--color-text-primary), var(--color-background-secondary), etc). system-ui font, 2 weights (400/500), sentence case. Structure: style → content → script last.\n\n", + "SVG diagrams: use pre-loaded classes — `.t` (14px text), `.ts` (12px), `.th` (14px bold), `.box` (neutral), `.node` (clickable), `.arr` (arrow), `.leader` (dashed). Color ramps: `class=\"c-blue\"` on `` wrapping shape+text — auto light/dark. Available: c-purple, c-teal, c-coral, c-blue, c-amber, c-green, c-red, c-gray, c-pink. Max 2-3 ramps per diagram.\n\n", + "Chart.js: wrap canvas in div with position:relative + explicit height. Load UMD from cdnjs.cloudflare.com with onload callback. Disable default legend, build custom HTML legend with 10px colored squares.\n\n", + "Interactive: form elements pre-styled. Use sendPrompt(text) for drill-down. CDN: cdnjs.cloudflare.com, cdn.jsdelivr.net, unpkg.com, esm.sh only.\n\n", + "Always output COMPLETE standalone HTML (DOCTYPE, html, head, body). No titles/prose inside widget — explanations go in your response text." + ) } else { - payload["system"] = serde_json::json!(system_instructions_anthropic); - } + concat!( + "IMPORTANT: Reply using the same language as the user's latest message. If user writes Indonesian, answer in Indonesian. Never switch to another language unless the user explicitly asks you to.\n\n", + "You can create interactive visualizations (charts, diagrams, widgets) by outputting a fenced code block with the language tag `html:preview`. The preview iframe has a full design system pre-loaded with CSS variables, SVG color ramp classes, and light/dark mode support. Use this when the user asks for any visual or interactive content." + ) + }; + // Prompt caching: system prompt as cached content block + let system_text = if let Some(sys) = system_msgs.first() { + format!("{}\n\n{}", sys.content, system_instructions_anthropic) + } else { + system_instructions_anthropic.to_string() + }; + payload["system"] = serde_json::json!([ + { + "type": "text", + "text": system_text, + "cache_control": {"type": "ephemeral"} + } + ]); + + // Resolve endpoint for Anthropic-format requests. + // + // - Anthropic direct: always use the canonical URL. + // - enowxlabs built-in: base_url ends with /v1 → strip it, append /messages + // (enowxlabs gateway expects /messages at the root). + // - Custom gateways: keep the base_url as-is and append /messages. + // If the user stored "http://host:port/v1" we keep /v1 so the final + // endpoint is "http://host:port/v1/messages" — most Anthropic-compatible + // proxies (e.g. LiteLLM, Claude Desktop gateway) expect this. + let endpoint = if provider_type == "anthropic" { + "https://api.anthropic.com/v1/messages".to_string() + } else if provider_type == "enowxlabs" { + // enowxlabs own gateway: strip /v1 suffix + format!( + "{}/messages", + base_url + .trim_end_matches('/') + .trim_end_matches("/v1") + ) + } else { + // Custom / third-party gateway: preserve the full base_url path + format!("{}/messages", base_url.trim_end_matches('/')) + }; + + log::info!("anthropic endpoint: {} (base_url={}, provider_type={})", endpoint, base_url, provider_type); let mut request = client - .post("https://api.anthropic.com/v1/messages") + .post(&endpoint) .header(CONTENT_TYPE, "application/json") .header("anthropic-version", "2023-06-01") + .header("anthropic-beta", "prompt-caching-2024-07-31") .json(&payload); + // Auth: x-api-key for Anthropic direct, Bearer for gateways if let Some(key) = api_key.filter(|k| !k.trim().is_empty()) { - request = request.header("x-api-key", key); + if provider_type == "anthropic" { + request = request.header("x-api-key", key); + } else { + request = request.header(AUTHORIZATION, format!("Bearer {key}")); + } } let response = request.send().await?; @@ -282,7 +526,51 @@ async fn send_anthropic( return Err(AppError::Http(format!("Anthropic {status}: {body}"))); } - stream_anthropic_sse(response, on_token, cancel_token).await + let output = stream_anthropic_sse(response, on_token, cancel_token).await?; + + // Fallback: some gateways return message_start → message_stop without any + // content_block events for certain models. Retry non-streaming. + if output.is_empty() { + log::warn!("anthropic chat stream returned empty — retrying non-streaming"); + payload["stream"] = serde_json::json!(false); + + let mut retry_req = client + .post(&endpoint) + .header(CONTENT_TYPE, "application/json") + .header("anthropic-version", "2023-06-01") + .json(&payload); + + if let Some(key) = api_key.filter(|k| !k.trim().is_empty()) { + if provider_type == "anthropic" { + retry_req = retry_req.header("x-api-key", key); + } else { + retry_req = retry_req.header(AUTHORIZATION, format!("Bearer {key}")); + } + } + + let retry_resp = retry_req.send().await?; + if !retry_resp.status().is_success() { + let status = retry_resp.status(); + let body = retry_resp.text().await.unwrap_or_default(); + return Err(AppError::Http(format!("Anthropic non-stream {status}: {body}"))); + } + + let body: Value = retry_resp.json().await?; + if let Some(text) = body + .get("content") + .and_then(Value::as_array) + .and_then(|arr| arr.first()) + .and_then(|block| block.get("text")) + .and_then(Value::as_str) + { + let _ = on_token.send(text.to_string()); + return Ok(text.to_string()); + } + + return Ok(String::new()); + } + + Ok(output) } async fn stream_openai_sse( @@ -372,6 +660,10 @@ async fn stream_anthropic_sse( let mut stream = response.bytes_stream(); let mut line_buffer = String::new(); let mut output = String::new(); + // Track the most recent `event:` line so we can use it when parsing the + // subsequent `data:` line. Some gateways omit the `"type"` field from + // the JSON payload, so we fall back to the SSE event name. + let mut current_event = String::new(); loop { tokio::select! { @@ -390,7 +682,7 @@ async fn stream_anthropic_sse( line.pop(); } - if parse_anthropic_sse_line(&line, on_token, &mut output)? { + if parse_anthropic_sse_line(&line, &mut current_event, on_token, &mut output)? { return Ok(output); } } @@ -405,8 +697,14 @@ async fn stream_anthropic_sse( Ok(output) } +/// Parse a single SSE line from an Anthropic-format stream. +/// +/// `current_event` carries the most recent `event:` value across calls so that +/// the `data:` handler can fall back to it when the JSON payload lacks a +/// top-level `"type"` field (common with third-party gateways / proxies). fn parse_anthropic_sse_line( line: &str, + current_event: &mut String, on_token: &Channel, output: &mut String, ) -> AppResult { @@ -415,14 +713,17 @@ fn parse_anthropic_sse_line( return Ok(false); } + // ── event: line ────────────────────────────────────────────────── if let Some(event) = trimmed.strip_prefix("event:") { let event = event.trim(); + *current_event = event.to_string(); if event == "message_stop" { return Ok(true); } return Ok(false); } + // ── data: line ─────────────────────────────────────────────────── let Some(payload) = trimmed.strip_prefix("data:") else { return Ok(false); }; @@ -433,7 +734,12 @@ fn parse_anthropic_sse_line( Err(_) => return Ok(false), }; - let event_type = value.get("type").and_then(Value::as_str).unwrap_or(""); + // Prefer `"type"` from the JSON payload; fall back to the preceding + // `event:` line when the gateway strips it. + let event_type = value + .get("type") + .and_then(Value::as_str) + .unwrap_or(current_event.as_str()); match event_type { "content_block_delta" => { @@ -493,7 +799,7 @@ pub async fn generate_title( "content": "Generate a short title for the conversation above." })); - let title = if provider.provider_type == "anthropic" { + let title = if provider.uses_anthropic_format() { generate_title_anthropic(&provider, model, &messages).await? } else { generate_title_openai(&provider, model, &messages).await? @@ -589,15 +895,29 @@ async fn generate_title_anthropic( "temperature": 0.3, }); + // Resolve endpoint: same logic as send_anthropic + let endpoint = if provider.provider_type == "anthropic" { + "https://api.anthropic.com/v1/messages".to_string() + } else if provider.provider_type == "enowxlabs" { + format!("{}/messages", provider.base_url.trim_end_matches('/').trim_end_matches("/v1")) + } else { + format!("{}/messages", provider.base_url.trim_end_matches('/')) + }; + let client = reqwest::Client::new(); let mut request = client - .post("https://api.anthropic.com/v1/messages") + .post(&endpoint) .header(CONTENT_TYPE, "application/json") .header("anthropic-version", "2023-06-01") .json(&payload); + // Auth: x-api-key for Anthropic direct, Bearer for gateways if let Some(key) = provider.api_key.as_deref().filter(|k| !k.trim().is_empty()) { - request = request.header("x-api-key", key); + if provider.provider_type == "anthropic" { + request = request.header("x-api-key", key); + } else { + request = request.header(AUTHORIZATION, format!("Bearer {key}")); + } } let response = request.send().await?; diff --git a/src-tauri/src/services/model_service.rs b/src-tauri/src/services/model_service.rs index 3dda487..08be3bf 100644 --- a/src-tauri/src/services/model_service.rs +++ b/src-tauri/src/services/model_service.rs @@ -1,42 +1,56 @@ use reqwest::Client; use serde::Deserialize; -use crate::error::{AppError, AppResult}; +use crate::{ + error::{AppError, AppResult}, + models::Provider, +}; #[derive(Debug, Deserialize)] -struct OpenAiModelList { - data: Vec, +struct ModelList { + data: Vec, } #[derive(Debug, Deserialize)] -struct OpenAiModel { +struct ModelEntry { id: String, } -#[derive(Debug, Deserialize)] -struct AnthropicModelList { - data: Vec, -} - -#[derive(Debug, Deserialize)] -struct AnthropicModel { - id: String, -} - -pub async fn list_models( - provider_type: &str, - base_url: &str, - api_key: Option<&str>, -) -> AppResult> { - match provider_type { - "enowxlabs" | "openai" | "ollama" | "custom" => { - fetch_openai_models(base_url, api_key).await +/// Fetch available models for a provider. +/// +/// Routing logic: +/// 1. Known built-in types (`openai`, `anthropic`, `enowxlabs`, …) use their +/// canonical endpoints. +/// 2. Custom providers use `api_format` to decide the wire format: +/// - `"anthropic"` → Anthropic `/models` endpoint on the gateway. +/// - anything else → OpenAI `/models` endpoint on the gateway. +pub async fn list_models(provider: &Provider) -> AppResult> { + match provider.provider_type.as_str() { + // Built-in types with known behaviour + "openai" | "ollama" | "gemini" => { + fetch_openai_models(&provider.base_url, provider.api_key.as_deref()).await + } + "anthropic" => { + fetch_anthropic_models("https://api.anthropic.com/v1", provider.api_key.as_deref(), true).await + } + "enowxlabs" => { + // enowxlabs gateway: strip /v1 for the models endpoint + let base = provider.base_url.trim_end_matches('/').trim_end_matches("/v1"); + fetch_anthropic_models(base, provider.api_key.as_deref(), false).await + } + // Custom / unknown provider types — decide by api_format + _ => { + if provider.uses_anthropic_format() { + fetch_anthropic_models( + provider.base_url.trim_end_matches('/'), + provider.api_key.as_deref(), + false, + ) + .await + } else { + fetch_openai_models(&provider.base_url, provider.api_key.as_deref()).await + } } - "anthropic" => fetch_anthropic_models(api_key).await, - "gemini" => fetch_openai_models(base_url, api_key).await, - _ => Err(AppError::Validation(format!( - "Unknown provider type: {provider_type}" - ))), } } @@ -64,7 +78,7 @@ async fn fetch_openai_models(base_url: &str, api_key: Option<&str>) -> AppResult ))); } - let list: OpenAiModelList = resp + let list: ModelList = resp .json() .await .map_err(|e| AppError::Internal(format!("Failed to parse models response: {e}")))?; @@ -74,33 +88,46 @@ async fn fetch_openai_models(base_url: &str, api_key: Option<&str>) -> AppResult Ok(ids) } -async fn fetch_anthropic_models(api_key: Option<&str>) -> AppResult> { +/// Fetch models from an Anthropic-format endpoint. +/// +/// `use_x_api_key`: when `true`, send the key as `x-api-key` header (Anthropic +/// direct). When `false`, send as `Authorization: Bearer` (gateways). +async fn fetch_anthropic_models( + base_url: &str, + api_key: Option<&str>, + use_x_api_key: bool, +) -> AppResult> { + let url = format!("{}/models", base_url.trim_end_matches('/')); let client = Client::new(); let mut req = client - .get("https://api.anthropic.com/v1/models") + .get(&url) .header("anthropic-version", "2023-06-01"); if let Some(key) = api_key { if !key.is_empty() { - req = req.header("x-api-key", key); + if use_x_api_key { + req = req.header("x-api-key", key); + } else { + req = req.bearer_auth(key); + } } } let resp = req .send() .await - .map_err(|e| AppError::Internal(format!("Failed to fetch Anthropic models: {e}")))?; + .map_err(|e| AppError::Internal(format!("Failed to fetch models: {e}")))?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); return Err(AppError::Internal(format!( - "Anthropic models endpoint returned {status}: {body}" + "Models endpoint returned {status}: {body}" ))); } - let list: AnthropicModelList = resp.json().await.map_err(|e| { - AppError::Internal(format!("Failed to parse Anthropic models response: {e}")) + let list: ModelList = resp.json().await.map_err(|e| { + AppError::Internal(format!("Failed to parse models response: {e}")) })?; let mut ids: Vec = list.data.into_iter().map(|m| m.id).collect(); diff --git a/src-tauri/src/services/provider_service.rs b/src-tauri/src/services/provider_service.rs index 12bdae8..c218bc6 100644 --- a/src-tauri/src/services/provider_service.rs +++ b/src-tauri/src/services/provider_service.rs @@ -9,7 +9,7 @@ use crate::{ use super::now_rfc3339; const SELECT_COLS: &str = - "id, name, provider_type, base_url, api_key, model, is_default, is_builtin, is_enabled, created_at, updated_at"; + "id, name, provider_type, base_url, api_key, model, is_default, is_builtin, is_enabled, api_format, created_at, updated_at"; pub async fn list_providers(db: &SqlitePool) -> AppResult> { let providers = sqlx::query_as::<_, Provider>(&format!( @@ -28,6 +28,7 @@ pub async fn create_provider( base_url: &str, api_key: Option<&str>, model: &str, + api_format: Option<&str>, ) -> AppResult { let normalized_name = name.trim(); let normalized_provider_type = provider_type.trim(); @@ -54,6 +55,15 @@ pub async fn create_provider( u.to_string() }; + // Infer api_format from provider_type when not explicitly set + let resolved_api_format = match api_format { + Some(f) if f == "anthropic" || f == "openai" => f.to_string(), + _ => match normalized_provider_type { + "anthropic" | "enowxlabs" => "anthropic".to_string(), + _ => "openai".to_string(), + }, + }; + let now = now_rfc3339(); let provider = Provider { id: Uuid::new_v4().to_string(), @@ -65,12 +75,14 @@ pub async fn create_provider( is_default: false, is_builtin: false, is_enabled: true, + api_format: resolved_api_format, created_at: now.clone(), updated_at: now, }; sqlx::query( - "INSERT INTO providers (id, name, provider_type, base_url, api_key, model, is_default, is_builtin, is_enabled, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + "INSERT INTO providers (id, name, provider_type, base_url, api_key, model, is_default, is_builtin, is_enabled, api_format, created_at, updated_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", ) .bind(&provider.id) .bind(&provider.name) @@ -81,6 +93,7 @@ pub async fn create_provider( .bind(provider.is_default) .bind(provider.is_builtin) .bind(provider.is_enabled) + .bind(&provider.api_format) .bind(&provider.created_at) .bind(&provider.updated_at) .execute(db) @@ -96,6 +109,7 @@ pub async fn update_provider( base_url: &str, api_key: Option<&str>, model: &str, + api_format: Option<&str>, ) -> AppResult<()> { let existing = sqlx::query_as::<_, Provider>(&format!( "SELECT {SELECT_COLS} FROM providers WHERE id = ?1" @@ -126,14 +140,21 @@ pub async fn update_provider( u.to_string() }; + // Only update api_format if explicitly provided, otherwise keep existing + let resolved_api_format = match api_format { + Some(f) if f == "anthropic" || f == "openai" => f.to_string(), + _ => existing.api_format, + }; + let now = now_rfc3339(); sqlx::query( - "UPDATE providers SET name = ?1, base_url = ?2, api_key = ?3, model = ?4, updated_at = ?5 WHERE id = ?6", + "UPDATE providers SET name = ?1, base_url = ?2, api_key = ?3, model = ?4, api_format = ?5, updated_at = ?6 WHERE id = ?7", ) .bind(normalized_name) .bind(resolved_base_url) .bind(api_key) .bind(normalized_model) + .bind(&resolved_api_format) .bind(&now) .bind(id) .execute(db) diff --git a/src/components/onboarding/OnboardingWizard.tsx b/src/components/onboarding/OnboardingWizard.tsx index 54294eb..665c8b6 100644 --- a/src/components/onboarding/OnboardingWizard.tsx +++ b/src/components/onboarding/OnboardingWizard.tsx @@ -45,6 +45,7 @@ export const OnboardingWizard: React.FC = ({ onComplete } isDefault: true, isBuiltin: false, isEnabled: true, + apiFormat: (preset.type === 'anthropic' || preset.type === 'enowxlabs') ? 'anthropic' : 'openai', createdAt: now, updatedAt: now, }; diff --git a/src/components/settings/AgentsTab.tsx b/src/components/settings/AgentsTab.tsx index e00beb3..efc80da 100644 --- a/src/components/settings/AgentsTab.tsx +++ b/src/components/settings/AgentsTab.tsx @@ -5,6 +5,7 @@ import { useAgentStore } from '@/stores/useAgentStore'; import { invoke } from '@tauri-apps/api/core'; import { cn } from '@/lib/utils'; import { + ChatCircle, Robot, TreeStructure, Code, @@ -33,6 +34,7 @@ const AGENT_TYPES: AgentType[] = [ ]; const AGENT_ICONS: Record = { + chat: ChatCircle, orchestrator: Robot, planner: TreeStructure, coder_fe: Code, diff --git a/src/components/settings/ProvidersTab.tsx b/src/components/settings/ProvidersTab.tsx index ca89255..17307ff 100644 --- a/src/components/settings/ProvidersTab.tsx +++ b/src/components/settings/ProvidersTab.tsx @@ -170,6 +170,7 @@ export const ProvidersTab: React.FC = () => { const [newBaseUrl, setNewBaseUrl] = useState(''); const [newApiKey, setNewApiKey] = useState(''); const [newModel, setNewModel] = useState(''); + const [newApiFormat, setNewApiFormat] = useState<'openai' | 'anthropic'>('openai'); const [newSaving, setNewSaving] = useState(false); const [newError, setNewError] = useState(null); @@ -411,12 +412,13 @@ export const ProvidersTab: React.FC = () => { baseUrl: newBaseUrl.trim(), apiKey: newApiKey.trim() || null, model: newModel.trim(), + apiFormat: newApiFormat, }); await loadProviders(); // Select the new provider setSelectedType(created.providerType as ProviderType); setAddingNew(false); - setNewName(''); setNewBaseUrl(''); setNewApiKey(''); setNewModel(''); + setNewName(''); setNewBaseUrl(''); setNewApiKey(''); setNewModel(''); setNewApiFormat('openai'); } catch (e) { const msg = typeof e === 'string' ? e : (e as Error)?.message ?? 'Failed to create provider'; setNewError(msg); @@ -603,6 +605,39 @@ export const ProvidersTab: React.FC = () => {

The model identifier used for API requests

+
+ +
+ + +
+

+ Choose Anthropic for Claude-compatible gateways (enables prompt caching & lower token usage) +

+
+ {newError && (
@@ -752,6 +787,47 @@ export const ProvidersTab: React.FC = () => {
)} + {/* API Format — for custom providers */} + {!FIXED_BASE_URL[selectedType] && selectedProvider && ( +
+ +
+ {(['openai', 'anthropic'] as const).map((fmt) => ( + + ))} +
+

+ Choose Anthropic for Claude-compatible gateways (enables prompt caching & lower token usage) +

+
+ )} + {/* API Key */}
diff --git a/src/stores/useAgentStore.ts b/src/stores/useAgentStore.ts index ad535e9..9415923 100644 --- a/src/stores/useAgentStore.ts +++ b/src/stores/useAgentStore.ts @@ -24,7 +24,7 @@ interface AgentState { export const useAgentStore = create((set) => ({ agentRuns: [], agentConfigs: [], - selectedAgentType: 'orchestrator', + selectedAgentType: 'chat', pendingPermission: null, setAgentRuns: (runs) => set({ agentRuns: runs }), diff --git a/src/types/index.ts b/src/types/index.ts index 4c26385..a780b99 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -33,6 +33,8 @@ export interface Provider { isDefault: boolean; isBuiltin: boolean; isEnabled: boolean; + /** Wire format: 'openai' or 'anthropic'. Controls serialisation & prompt caching. */ + apiFormat: 'openai' | 'anthropic'; createdAt: string; updatedAt: string; } @@ -64,6 +66,7 @@ export interface AgentRun { } export type AgentType = + | 'chat' | 'orchestrator' | 'planner' | 'coder_fe' @@ -76,9 +79,10 @@ export type AgentType = | 'researcher' | 'librarian'; -export const SELECTABLE_AGENTS: AgentType[] = ['orchestrator', 'planner']; +export const SELECTABLE_AGENTS: AgentType[] = ['chat', 'orchestrator', 'planner']; export const AGENT_LABELS: Record = { + chat: 'Chat', orchestrator: 'Orchestrator', planner: 'Planner', coder_fe: 'Coder FE',