Skip to content

Commit 6673b96

Browse files
committed
feat: tok/s indicator + todo management — active streaming rate, no-usage fallback, auto-poke
1 parent 2d5d25d commit 6673b96

17 files changed

Lines changed: 3416 additions & 860 deletions

File tree

docs/configuration.md

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ values. Keys absent from the project file fall back to the global value.
4242
"config": { ... },
4343
"providers": { ... },
4444
"modelOverrides": { ... },
45+
"favoriteModels": [ ... ],
4546
"projects": { ... },
4647
"commands": { ... },
4748
"formatter": { ... },
@@ -380,6 +381,69 @@ and `api_base` override the corresponding environment variables.
380381
| `models_blacklist` | array | These model IDs are never offered. |
381382
| `options` | object | Provider-specific passthrough options. |
382383

384+
### Custom OpenAI-Compatible Providers
385+
386+
For OpenAI-compatible endpoints not in the built-in provider list (e.g.
387+
self-hosted gateways, internal LLM proxies), define them under the
388+
`customProviders` map. Each entry is a self-contained provider with its
389+
own base URL, API key, custom headers, and model catalog.
390+
391+
```json
392+
"customProviders": {
393+
"my-gateway": {
394+
"name": "My Gateway",
395+
"apiBase": "https://gateway.example.com/v1",
396+
"apiKey": "{env:GATEWAY_API_KEY}",
397+
"headers": {
398+
"X-Custom-Header": "value"
399+
},
400+
"models": {
401+
"model-1": {
402+
"name": "Model One",
403+
"contextWindow": 128000,
404+
"maxOutputTokens": 8192,
405+
"reasoningEffort": "high",
406+
"variants": {
407+
"max": { "reasoningEffort": "max" },
408+
"none": { "reasoningEffort": "none" }
409+
}
410+
}
411+
},
412+
"requestTimeoutSecs": 300
413+
}
414+
}
415+
```
416+
417+
`CustomProviderDef` fields:
418+
419+
| Field | Type | Description |
420+
|-------|------|-------------|
421+
| `name` | string | Display name shown in the provider picker. |
422+
| `apiBase` | string | OpenAI-compatible base URL. Claurst appends `/chat/completions`. |
423+
| `apiKey` | string \| null | API key. Supports `{env:VAR}` substitution. `null` = no key. |
424+
| `headers` | object | Custom HTTP headers sent on every request. |
425+
| `models` | object | Model catalog local to this provider, keyed by model id. |
426+
| `requestTimeoutSecs` | number \| null | Per-provider request timeout override in seconds. |
427+
428+
`CustomModelDef` fields (inside `models`):
429+
430+
| Field | Type | Description |
431+
|-------|------|-------------|
432+
| `name` | string \| null | Display name shown in the model picker. |
433+
| `contextWindow` | number \| null | Total context window size in tokens. |
434+
| `maxOutputTokens` | number \| null | Maximum tokens the model can emit in one response. |
435+
| `reasoningEffort` | string \| null | Reasoning effort level (`"high"`, `"max"`, `"none"`). |
436+
| `variants` | object | Named variants that override specific fields. |
437+
438+
Adding a provider via the `/add` command:
439+
440+
```
441+
/add my-gateway https://gateway.example.com/v1 {env:GATEWAY_API_KEY}
442+
```
443+
444+
The map key (e.g. `"my-gateway"`) becomes the provider id used in
445+
`provider/model` routing (e.g. `my-gateway/model-1`).
446+
383447
---
384448

385449
## Environment Variables
@@ -639,6 +703,13 @@ matches. They are defined in the `formatter` map:
639703
}
640704
},
641705

706+
// Pin frequently-used models to the top of the /model picker.
707+
"favoriteModels": [
708+
"anthropic/claude-sonnet-4-6",
709+
"openai/gpt-4o",
710+
"nvidia/z-ai/glm-5.2"
711+
],
712+
642713
// Custom slash commands
643714
"commands": {
644715
"test": {
@@ -657,3 +728,29 @@ matches. They are defined in the `formatter` map:
657728
}
658729
}
659730
```
731+
732+
---
733+
734+
## Favorite Models
735+
736+
Pin frequently-used models to the top of the `/model` picker by adding them to
737+
the `favoriteModels` array in `settings.json`:
738+
739+
```json
740+
"favoriteModels": [
741+
"anthropic/claude-sonnet-4-6",
742+
"openai/gpt-4o",
743+
"nvidia/z-ai/glm-5.2"
744+
]
745+
```
746+
747+
Entries use the canonical `"provider/model"` format (the same key used by
748+
`modelOverrides`). For the `anthropic` and `free` composite providers, the
749+
provider prefix is optional — the bare model id (`"claude-sonnet-4-6"`) is
750+
accepted too.
751+
752+
In the model picker, press `f` (or `*`) to toggle favorite status on the
753+
highlighted model. Favorited models appear with a ★ prefix at the top of the
754+
list and persist across sessions in `~/.claurst/settings.json`. Stale
755+
favorites (models no longer in the catalog) are hidden from the picker but
756+
kept in settings until you un-favorite them.

src-rust/crates/api/src/model_registry.rs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1243,6 +1243,66 @@ impl ModelRegistry {
12431243
}
12441244
apply_overrides_to_entries(&mut self.entries, &self.overrides);
12451245
}
1246+
1247+
/// Register user-defined custom provider models into the registry.
1248+
/// Each model is keyed as `<provider_id>/<model_id>` and a synthetic
1249+
/// `ModelEntry` is created from the `CustomModelDef` metadata.
1250+
/// A `ProviderEntry` is also created for each custom provider so it
1251+
/// appears in `/providers` listing.
1252+
pub fn apply_custom_providers(
1253+
&mut self,
1254+
custom_providers: &HashMap<String, claurst_core::config::CustomProviderDef>,
1255+
) {
1256+
for (provider_id, def) in custom_providers {
1257+
// Register the provider entry so it shows up in /providers.
1258+
self.providers.entry(provider_id.clone()).or_insert(ProviderEntry {
1259+
id: ProviderId::new(provider_id),
1260+
name: def.name.clone(),
1261+
env: Vec::new(),
1262+
api: Some(def.api_base.clone()),
1263+
npm: None,
1264+
doc: None,
1265+
});
1266+
1267+
for (model_id, model_def) in &def.models {
1268+
let key = format!("{}/{}", provider_id, model_id);
1269+
self.entries.insert(key, ModelEntry {
1270+
info: ModelInfo {
1271+
id: ModelId::new(model_id),
1272+
provider_id: ProviderId::new(provider_id),
1273+
name: model_def.name.clone().unwrap_or_else(|| model_id.to_string()),
1274+
context_window: model_def.context_window.unwrap_or(0),
1275+
max_output_tokens: model_def.max_output_tokens.unwrap_or(0),
1276+
release_date: None,
1277+
status: None,
1278+
},
1279+
family: None,
1280+
status: Default::default(),
1281+
release_date: None,
1282+
last_updated: None,
1283+
knowledge: None,
1284+
open_weights: false,
1285+
tool_calling: true,
1286+
reasoning: model_def.reasoning_effort.is_some(),
1287+
structured_output: false,
1288+
temperature: true,
1289+
attachment: false,
1290+
interleaved: None,
1291+
modalities_input: vec![Modality::Text],
1292+
modalities_output: vec![Modality::Text],
1293+
cost_input: None,
1294+
cost_output: None,
1295+
cost_cache_read: None,
1296+
cost_cache_write: None,
1297+
cost: Default::default(),
1298+
provider_override: None,
1299+
experimental_modes: Default::default(),
1300+
options: Default::default(),
1301+
headers: Default::default(),
1302+
});
1303+
}
1304+
}
1305+
}
12461306
}
12471307

12481308
/// Layer `overrides` onto `entries`: patch existing catalog rows in place and
Lines changed: 44 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,44 @@
1-
pub mod anthropic;
2-
pub use anthropic::AnthropicProvider;
3-
4-
pub(crate) mod message_normalization;
5-
pub(crate) mod request_options;
6-
7-
pub mod openai;
8-
pub use openai::OpenAiProvider;
9-
10-
pub mod google;
11-
pub use google::GoogleProvider;
12-
13-
pub mod minimax;
14-
pub use minimax::MinimaxProvider;
15-
16-
pub mod openai_compat;
17-
pub use openai_compat::OpenAiCompatProvider;
18-
19-
pub mod openai_compat_providers;
20-
pub use openai_compat_providers::{
21-
baseten, cerebras, deepinfra, deepseek, fireworks, friendli, groq, huggingface, llama_cpp,
22-
lm_studio, mistral, moonshot, nebius, novita, nvidia, ollama, opencode_zen, openrouter,
23-
ovhcloud, perplexity, qwen, sambanova, scaleway, siliconflow, stepfun, together_ai, upstage,
24-
venice, vultr_ai, xai, zai, zhipu,
25-
};
26-
27-
pub mod free;
28-
pub use free::{catalog_entry, FreeEntry, FreeProvider, FreeUpstream, FREE_CATALOG};
29-
30-
pub mod cohere;
31-
pub use cohere::CohereProvider;
32-
33-
pub mod azure;
34-
pub use azure::AzureProvider;
35-
36-
pub mod bedrock;
37-
pub use bedrock::BedrockProvider;
38-
39-
pub mod copilot;
40-
pub use copilot::CopilotProvider;
41-
42-
pub mod codex;
43-
pub use codex::CodexProvider;
1+
pub mod anthropic;
2+
pub use anthropic::AnthropicProvider;
3+
4+
pub(crate) mod message_normalization;
5+
pub(crate) mod request_options;
6+
7+
pub mod openai;
8+
pub use openai::OpenAiProvider;
9+
10+
pub mod google;
11+
pub use google::GoogleProvider;
12+
13+
pub mod minimax;
14+
pub use minimax::MinimaxProvider;
15+
16+
pub mod openai_compat;
17+
pub use openai_compat::OpenAiCompatProvider;
18+
19+
pub mod openai_compat_providers;
20+
pub use openai_compat_providers::{
21+
baseten, cerebras, deepinfra, deepseek, fireworks, friendli, groq, huggingface, llama_cpp,
22+
lm_studio, mistral, moonshot, nebius, novita, nvidia, ollama, opencode_zen, openrouter,
23+
ovhcloud, perplexity, qwen, sambanova, scaleway, siliconflow, stepfun, together_ai, upstage,
24+
venice, vultr_ai, xai, zai, zhipu,
25+
};
26+
27+
pub mod free;
28+
pub use free::{catalog_entry, FreeEntry, FreeProvider, FreeUpstream, FREE_CATALOG};
29+
30+
pub mod cohere;
31+
pub use cohere::CohereProvider;
32+
33+
pub mod azure;
34+
pub use azure::AzureProvider;
35+
36+
pub mod bedrock;
37+
pub use bedrock::BedrockProvider;
38+
39+
pub mod copilot;
40+
pub use copilot::CopilotProvider;
41+
42+
pub mod codex;
43+
pub use codex::CodexProvider;
44+

src-rust/crates/api/src/providers/openai_compat.rs

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,12 @@ use super::request_options::merge_openai_compatible_options;
3232

3333
/// Provider-specific behavioural quirks that alter how the generic adapter
3434
/// builds and interprets requests/responses.
35-
#[derive(Debug, Clone, Default)]
35+
#[derive(Debug, Clone)]
3636
pub struct ProviderQuirks {
37+
/// Whether this provider supports SSE streaming. Default: `true`.
38+
/// When `false`, the query engine falls back to non-streaming requests.
39+
pub streaming: bool,
40+
3741
/// Truncate tool call IDs to at most this many characters before sending.
3842
/// For example, Mistral requires tool IDs of at most 9 characters.
3943
pub tool_id_max_len: Option<usize>,
@@ -95,6 +99,26 @@ pub struct ProviderQuirks {
9599
pub lm_studio_native_host: Option<String>,
96100
}
97101

102+
impl Default for ProviderQuirks {
103+
fn default() -> Self {
104+
Self {
105+
streaming: true,
106+
tool_id_max_len: None,
107+
tool_id_alphanumeric_only: false,
108+
overflow_patterns: Vec::new(),
109+
include_usage_in_stream: false,
110+
default_temperature: None,
111+
fix_tool_user_sequence: false,
112+
reasoning_field: None,
113+
requires_reasoning_roundtrip: false,
114+
max_tokens_cap: None,
115+
no_api_key_required: false,
116+
ollama_native_host: None,
117+
lm_studio_native_host: None,
118+
}
119+
}
120+
}
121+
98122
// ---------------------------------------------------------------------------
99123
// OpenAiCompatProvider
100124
// ---------------------------------------------------------------------------
@@ -1141,7 +1165,7 @@ impl LlmProvider for OpenAiCompatProvider {
11411165

11421166
fn capabilities(&self) -> ProviderCapabilities {
11431167
ProviderCapabilities {
1144-
streaming: true,
1168+
streaming: self.quirks.streaming,
11451169
tool_calling: true,
11461170
thinking: self.quirks.reasoning_field.is_some(),
11471171
image_input: true,

src-rust/crates/api/src/providers/openai_compat_providers.rs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ use claurst_core::provider_id::ProviderId;
1212
use super::openai_compat::{OpenAiCompatProvider, ProviderQuirks};
1313

1414
pub fn provider_for_id(provider_id: &str) -> Option<OpenAiCompatProvider> {
15+
// Custom providers take priority — check before the fixed-id match.
16+
if let Some(custom) = provider_for_custom_id(provider_id) {
17+
return Some(custom);
18+
}
1519
match provider_id {
1620
"ollama" => Some(ollama()),
1721
"lmstudio" | "lm-studio" => Some(lm_studio()),
@@ -164,6 +168,36 @@ pub fn custom_openai() -> OpenAiCompatProvider {
164168
custom_openai_with_url(base_url)
165169
}
166170

171+
/// Build an [`OpenAiCompatProvider`] from a user-defined custom provider in
172+
/// `Settings::custom_providers`, looked up by id.
173+
///
174+
/// Returns `None` when the id is not in the custom providers map, or when
175+
/// `apiBase` is empty. Custom headers from the definition are applied via
176+
/// the builder API. API key is resolved via `resolve_api_key()` (supports
177+
/// `{env:VAR}` substitution).
178+
pub fn provider_for_custom_id(id: &str) -> Option<OpenAiCompatProvider> {
179+
let settings = Settings::load_sync().unwrap_or_default();
180+
let def = settings.custom_providers.get(id)?;
181+
if def.api_base.trim().is_empty() {
182+
return None;
183+
}
184+
let mut provider = OpenAiCompatProvider::new(id, &def.name, &def.api_base);
185+
if let Some(key) = def.resolve_api_key() {
186+
provider = provider.with_api_key(key);
187+
}
188+
for (name, value) in &def.headers {
189+
provider = provider.with_header(name, value);
190+
}
191+
// Apply streaming / reasoning settings from the custom provider definition.
192+
provider = provider.with_quirks(ProviderQuirks {
193+
streaming: def.streaming.unwrap_or(true),
194+
include_usage_in_stream: def.include_usage_in_stream.unwrap_or(true),
195+
reasoning_field: def.reasoning_field.clone(),
196+
..Default::default()
197+
});
198+
Some(provider)
199+
}
200+
167201
/// DeepSeek V4 — supports reasoning output via `reasoning_content` field.
168202
/// V4 models require reasoning_content to be echoed back on subsequent turns
169203
/// in multi-turn conversations with tool calls.
@@ -630,4 +664,19 @@ mod tests {
630664
assert_eq!(alibaba.id(), qwen.id());
631665
assert_eq!(alibaba.name(), qwen.name());
632666
}
667+
668+
#[test]
669+
fn provider_for_custom_id_returns_none_for_unknown() {
670+
// "nonexistent-custom-provider-12345" is not a real custom provider.
671+
assert!(provider_for_custom_id("nonexistent-custom-provider-12345").is_none());
672+
}
673+
674+
#[test]
675+
fn provider_for_custom_id_returns_none_for_known_fixed_provider() {
676+
// Fixed providers like "ollama" should not match as custom providers.
677+
// This test verifies that provider_for_custom_id doesn't accidentally
678+
// return Some for a fixed provider id when no custom provider is
679+
// registered with that id.
680+
assert!(provider_for_custom_id("ollama").is_none());
681+
}
633682
}

0 commit comments

Comments
 (0)