Skip to content

Commit 9e16f9c

Browse files
committed
feat(providers): add api_format field for custom Anthropic-compatible gateways
- Add api_format column to providers table (openai/anthropic) - Route chat/agent/title-gen through Anthropic format when api_format=anthropic - Add non-streaming fallback when gateway returns empty SSE stream - Add SSE event-name fallback for gateways that strip JSON type field - Trim chat history with sliding window to reduce token usage - Strip html:preview blocks from assistant context - Add API Format toggle in ProvidersTab UI (new + edit) - Unify model listing to use Provider struct directly
1 parent 37f6a69 commit 9e16f9c

11 files changed

Lines changed: 571 additions & 63 deletions

File tree

CHANGELOG.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,40 @@ All notable changes to enowX Coder are documented here.
44

55
---
66

7+
## [0.2.6] — 2026-04-25
8+
9+
### Token Optimization — 99% Reduction for Anthropic-format Gateways
10+
- **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
11+
- **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
12+
- **`uses_anthropic_format()` method**: Centralized routing logic replaces scattered `provider_type == "anthropic" || provider_type == "enowxlabs"` checks across chat service and agent runner
13+
14+
### Gateway SSE Compatibility Fix
15+
- **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
16+
- Applied to both chat SSE parser (`chat_service.rs`) and agent tool SSE parser (`runner.rs`)
17+
18+
### Non-Streaming Fallback for Unsupported Models
19+
- **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
20+
- Fixes blank responses for models where the gateway doesn't support streaming (e.g. `claude-opus-4.6` on certain proxies)
21+
- Applied to both chat path and agent path (with tool call support)
22+
23+
### Endpoint Resolution Fix for Custom Gateways
24+
- **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
25+
- Fixed in chat service, title generation, and agent runner
26+
27+
### Model Listing for Custom Providers
28+
- **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
29+
- `fetch_anthropic_models` now accepts a configurable base URL and auth scheme instead of hardcoding `api.anthropic.com`
30+
31+
### Provider Settings UI
32+
- **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
33+
- Selector shown in both "Add Provider" form and existing provider detail panel
34+
- Built-in providers (`enowxlabs`, `anthropic`) auto-set to Anthropic format
35+
36+
### Database
37+
- **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
38+
39+
---
40+
741
## [0.2.5] — 2026-04-23
842

943
### Excalidraw Canvas — Collaborative Whiteboard
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
-- Add api_format column to providers.
2+
-- Values: 'openai' (default) or 'anthropic'.
3+
-- This lets custom/gateway providers opt into the Anthropic message format
4+
-- which enables prompt caching and correct content-block serialisation.
5+
ALTER TABLE providers ADD COLUMN api_format TEXT NOT NULL DEFAULT 'openai';
6+
7+
-- Built-in providers that already use Anthropic format
8+
UPDATE providers SET api_format = 'anthropic' WHERE provider_type IN ('anthropic', 'enowxlabs');

src-tauri/src/agents/runner.rs

Lines changed: 103 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -794,7 +794,7 @@ impl AgentRunner {
794794
return Err(AppError::Cancelled);
795795
}
796796

797-
let turn = if provider.provider_type == "anthropic" || provider.provider_type == "enowxlabs" {
797+
let turn = if provider.uses_anthropic_format() {
798798
self.send_anthropic_with_tools(
799799
&provider.base_url,
800800
&provider.api_key,
@@ -1171,12 +1171,14 @@ impl AgentRunner {
11711171
]);
11721172
}
11731173

1174-
// Resolve endpoint: anthropic direct or enowxlabs proxy
1174+
// Resolve endpoint: same logic as chat_service::send_anthropic
11751175
let endpoint = if provider_type == "anthropic" {
11761176
"https://api.anthropic.com/v1/messages".to_string()
1177-
} else {
1178-
// enowxlabs or other Anthropic-compatible gateway
1177+
} else if provider_type == "enowxlabs" {
11791178
format!("{}/messages", base_url.trim_end_matches('/').trim_end_matches("/v1"))
1179+
} else {
1180+
// Custom gateway: preserve full base_url path (e.g. /v1/messages)
1181+
format!("{}/messages", base_url.trim_end_matches('/'))
11801182
};
11811183

11821184
let client = reqwest::Client::new();
@@ -1202,8 +1204,44 @@ impl AgentRunner {
12021204
return Err(AppError::Http(format!("Anthropic {status}: {body}")));
12031205
}
12041206

1205-
self.stream_anthropic_tool_sse(response, agent_run_id, token_sink)
1206-
.await
1207+
let turn = self
1208+
.stream_anthropic_tool_sse(response, agent_run_id, token_sink)
1209+
.await?;
1210+
1211+
// Fallback: some gateways return message_start → message_stop without
1212+
// any content_block events when streaming certain models. When that
1213+
// happens, retry the request with `stream: false` and parse the full
1214+
// response synchronously.
1215+
if turn.text.is_empty() && turn.tool_calls.is_empty() {
1216+
log::warn!("anthropic stream returned empty — retrying non-streaming");
1217+
payload["stream"] = json!(false);
1218+
1219+
let mut retry_req = client
1220+
.post(&endpoint)
1221+
.header(CONTENT_TYPE, "application/json")
1222+
.header("anthropic-version", "2023-06-01")
1223+
.json(&payload);
1224+
1225+
if let Some(key) = api_key.as_deref().filter(|k| !k.trim().is_empty()) {
1226+
if provider_type == "anthropic" {
1227+
retry_req = retry_req.header("x-api-key", key);
1228+
} else {
1229+
retry_req = retry_req.header(AUTHORIZATION, format!("Bearer {key}"));
1230+
}
1231+
}
1232+
1233+
let retry_resp = retry_req.send().await?;
1234+
if !retry_resp.status().is_success() {
1235+
let status = retry_resp.status();
1236+
let body = retry_resp.text().await.unwrap_or_default();
1237+
return Err(AppError::Http(format!("Anthropic non-stream {status}: {body}")));
1238+
}
1239+
1240+
let body: Value = retry_resp.json().await?;
1241+
return parse_anthropic_non_stream_response(&body, agent_run_id, token_sink, &self.app_handle);
1242+
}
1243+
1244+
Ok(turn)
12071245
}
12081246

12091247
async fn stream_openai_tool_sse<S: TokenSink + Sync>(
@@ -1363,6 +1401,9 @@ impl AgentRunner {
13631401
let mut output = String::new();
13641402
let mut stop_reason: Option<String> = None;
13651403
let mut pending_calls: HashMap<usize, StreamingToolCall> = HashMap::new();
1404+
// Track the most recent `event:` line so we can fall back to it when
1405+
// the JSON payload omits the top-level `"type"` field (some gateways).
1406+
let mut current_event = String::new();
13661407

13671408
while let Some(chunk) = stream.next().await {
13681409
line_buffer.push_str(&String::from_utf8_lossy(&chunk?));
@@ -1376,6 +1417,7 @@ impl AgentRunner {
13761417

13771418
let should_stop = self.parse_anthropic_sse_line(
13781419
&line,
1420+
&mut current_event,
13791421
agent_run_id,
13801422
token_sink,
13811423
&mut output,
@@ -1396,6 +1438,7 @@ impl AgentRunner {
13961438
fn parse_anthropic_sse_line<S: TokenSink + Sync>(
13971439
&self,
13981440
line: &str,
1441+
current_event: &mut String,
13991442
agent_run_id: &str,
14001443
token_sink: &S,
14011444
output: &mut String,
@@ -1408,7 +1451,9 @@ impl AgentRunner {
14081451
}
14091452

14101453
if let Some(event_name) = trimmed.strip_prefix("event:") {
1411-
if event_name.trim() == "message_stop" {
1454+
let event_name = event_name.trim();
1455+
*current_event = event_name.to_string();
1456+
if event_name == "message_stop" {
14121457
return Ok(true);
14131458
}
14141459
return Ok(false);
@@ -1424,7 +1469,12 @@ impl AgentRunner {
14241469
Err(_) => return Ok(false),
14251470
};
14261471

1427-
let event_type = value.get("type").and_then(Value::as_str).unwrap_or_default();
1472+
// Prefer `"type"` from JSON payload; fall back to the preceding
1473+
// `event:` line when the gateway strips it.
1474+
let event_type = value
1475+
.get("type")
1476+
.and_then(Value::as_str)
1477+
.unwrap_or(current_event.as_str());
14281478

14291479
match event_type {
14301480
"content_block_start" => {
@@ -1561,6 +1611,51 @@ struct StreamingToolCall {
15611611
arguments: String,
15621612
}
15631613

1614+
/// Parse a non-streaming Anthropic Messages API response into an `LLMTurn`.
1615+
///
1616+
/// Used as a fallback when the gateway returns an empty stream (some proxies
1617+
/// don't support streaming for certain models).
1618+
fn parse_anthropic_non_stream_response<S: TokenSink + Sync>(
1619+
body: &Value,
1620+
agent_run_id: &str,
1621+
token_sink: &S,
1622+
app_handle: &tauri::AppHandle,
1623+
) -> AppResult<LLMTurn> {
1624+
let mut text = String::new();
1625+
let mut tool_calls = Vec::new();
1626+
1627+
if let Some(content) = body.get("content").and_then(Value::as_array) {
1628+
for block in content {
1629+
let block_type = block.get("type").and_then(Value::as_str).unwrap_or("");
1630+
match block_type {
1631+
"text" => {
1632+
if let Some(t) = block.get("text").and_then(Value::as_str) {
1633+
text.push_str(t);
1634+
// Send tokens to UI so the user sees the response
1635+
token_sink.send(t);
1636+
let _ = app_handle.emit(
1637+
"agent-token",
1638+
AgentTokenEvent {
1639+
agent_run_id: agent_run_id.to_string(),
1640+
token: t.to_string(),
1641+
},
1642+
);
1643+
}
1644+
}
1645+
"tool_use" => {
1646+
let id = block.get("id").and_then(Value::as_str).unwrap_or("").to_string();
1647+
let name = block.get("name").and_then(Value::as_str).unwrap_or("").to_string();
1648+
let input = block.get("input").cloned().unwrap_or(Value::Object(Default::default()));
1649+
tool_calls.push(ParsedToolCall { id, name, input });
1650+
}
1651+
_ => {}
1652+
}
1653+
}
1654+
}
1655+
1656+
Ok(LLMTurn { text, tool_calls })
1657+
}
1658+
15641659
#[derive(Debug, Clone)]
15651660
struct ToolExecutionOutcome {
15661661
output: String,

src-tauri/src/commands/provider.rs

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ pub async fn create_provider(
1515
base_url: String,
1616
api_key: Option<String>,
1717
model: String,
18+
api_format: Option<String>,
1819
) -> AppResult<Provider> {
1920
provider_service::create_provider(
2021
state.pool(),
@@ -23,6 +24,7 @@ pub async fn create_provider(
2324
&base_url,
2425
api_key.as_deref(),
2526
&model,
27+
api_format.as_deref(),
2628
)
2729
.await
2830
}
@@ -35,6 +37,7 @@ pub async fn update_provider(
3537
base_url: String,
3638
api_key: Option<String>,
3739
model: String,
40+
api_format: Option<String>,
3841
) -> AppResult<()> {
3942
provider_service::update_provider(
4043
state.pool(),
@@ -43,6 +46,7 @@ pub async fn update_provider(
4346
&base_url,
4447
api_key.as_deref(),
4548
&model,
49+
api_format.as_deref(),
4650
)
4751
.await
4852
}
@@ -75,12 +79,7 @@ pub async fn list_models(
7579
crate::services::provider_service::get_provider_for_chat(state.pool(), Some(&provider_id))
7680
.await?;
7781

78-
crate::services::model_service::list_models(
79-
&provider.provider_type,
80-
&provider.base_url,
81-
provider.api_key.as_deref(),
82-
)
83-
.await
82+
crate::services::model_service::list_models(&provider).await
8483
}
8584

8685
#[tauri::command]

src-tauri/src/models/provider.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,29 @@ pub struct Provider {
1313
pub is_default: bool,
1414
pub is_builtin: bool,
1515
pub is_enabled: bool,
16+
/// Wire format: `"openai"` (default) or `"anthropic"`.
17+
/// Determines which serialisation path (and prompt caching) is used.
18+
#[serde(default = "default_api_format")]
19+
#[sqlx(default)]
20+
pub api_format: String,
1621
pub created_at: String,
1722
pub updated_at: String,
1823
}
1924

25+
fn default_api_format() -> String {
26+
"openai".to_string()
27+
}
28+
29+
impl Provider {
30+
/// Returns `true` when the provider should use the Anthropic Messages API
31+
/// format (content blocks, system-as-top-level, prompt caching).
32+
pub fn uses_anthropic_format(&self) -> bool {
33+
self.api_format == "anthropic"
34+
|| self.provider_type == "anthropic"
35+
|| self.provider_type == "enowxlabs"
36+
}
37+
}
38+
2039
pub fn fixed_base_url(provider_type: &str) -> Option<&'static str> {
2140
match provider_type {
2241
"enowxlabs" => Some("https://api.enowxlabs.com/v1"),

0 commit comments

Comments
 (0)