Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ Before `1.0.0`, breaking changes may still ship in minor releases.

## [Unreleased]

### Added

- `kagi assistant models` now lists every base model available to the account without requiring a saved custom assistant.

### Fixed

- Assistant final stream events and thread responses now include prompt, completion, and total token counts plus the upstream USD cost when Kagi supplies usage data.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## [0.16.0]

### Added
Expand Down
13 changes: 13 additions & 0 deletions docs/commands/assistant.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ kagi assistant thread list
kagi assistant thread get <THREAD_ID>
kagi assistant thread delete <THREAD_ID>
kagi assistant thread export <THREAD_ID> [--format markdown|json]
kagi assistant models
kagi assistant repl [OPTIONS]
kagi assistant custom list
kagi assistant custom get <ID_OR_NAME>
Expand Down Expand Up @@ -129,6 +130,8 @@ Use JSON mode when a script needs structured stream events:
kagi assistant --stream --stream-output json "Write a 3-line release note"
```

The final JSON event includes `message.usage` with prompt, completion, and total token counts plus `cost_usd` when Kagi supplies usage data.

#### `--stream-output <MODE>`

Select the streamed output mode. This option requires `--stream`.
Expand Down Expand Up @@ -226,6 +229,16 @@ Return the thread as structured JSON instead of markdown. This emits the same en
kagi assistant thread export "$THREAD_ID" --format json
```

## Model Catalog

### `kagi assistant models`

List every base model available to the current account. This reads the account-level Assistant catalog and does not require a saved custom assistant.

```bash
kagi assistant models | jq -r '.models[].id'
```

## Custom Assistant Subcommands

### `kagi assistant custom list`
Expand Down
24 changes: 23 additions & 1 deletion docs/reference/output-contract.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -187,13 +187,35 @@ Single-URL extract prints markdown by default, or the full Extract API envelope
"created_at": "2026-03-16T06:19:07Z",
"state": "done",
"prompt": "Hello",
"markdown": "Hi"
"markdown": "Hi",
"usage": {
"prompt_tokens": 4314,
"completion_tokens": 2,
"total_tokens": 4316,
"cost_usd": 0.006192
}
}
}
```

`kagi assistant --stream` writes incremental markdown deltas to stdout and flushes after each update. `kagi assistant --stream --stream-output json` writes the same stream as newline-delimited compact JSON events with an `md_delta` field plus the current `meta`, `thread`, and `message` snapshot.

The final stream event and completed messages returned by `kagi assistant thread get` include `message.usage` when Kagi supplies token and cost data. `prompt_tokens` maps to Kagi's input tokens, `completion_tokens` maps to output tokens, and `total_tokens` is their sum when the thread API does not return a total.

`kagi assistant models` reads the account-scoped Assistant catalog and returns every base model independently of saved custom assistants:

```json
{
"models": [
{
"id": "ki_quick",
"label": "Quick"
}
],
"default": "ki_quick"
}
```

`kagi assistant --contract <NAME>` and `kagi assistant --contract-file <PATH>` print only the validated contract JSON. Contract mode supports `--format json` and `--format compact`.

```json
Expand Down
155 changes: 114 additions & 41 deletions src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use serde::Deserialize;
use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::json;
use serde_json::{Map, Value};
use serde_json::{Map, Number, Value};
use tokio::time::sleep;
use tracing::debug;

Expand All @@ -25,33 +25,33 @@ use crate::http::{self, map_transport_error};
#[cfg(test)]
use crate::parser::parse_assistant_thread_list;
use crate::parser::{
parse_assistant_model_catalog, parse_assistant_profile_form, parse_assistant_profile_list,
parse_custom_bang_form, parse_custom_bang_list, parse_lens_form, parse_lens_list,
parse_redirect_form, parse_redirect_list,
parse_assistant_profile_form, parse_assistant_profile_list, parse_custom_bang_form,
parse_custom_bang_list, parse_lens_form, parse_lens_list, parse_redirect_form,
parse_redirect_list,
};
#[cfg(test)]
use crate::types::ApiMeta;
use crate::types::{
AlternativeTranslationsResponse, AskPageRequest, AskPageResponse, AskPageSource,
AssistantMessage, AssistantMeta, AssistantModelCatalog, AssistantProfileCreateRequest,
AssistantProfileDetails, AssistantProfileSummary, AssistantProfileUpdateRequest,
AssistantPromptRequest, AssistantPromptResponse, AssistantPromptStreamEvent, AssistantThread,
AssistantThreadDeleteResponse, AssistantThreadExportResponse, AssistantThreadListResponse,
AssistantThreadOpenResponse, AssistantThreadPagination, AssistantThreadSummary,
CustomBangCreateRequest, CustomBangDetails, CustomBangSummary, CustomBangUpdateRequest,
DeletedResourceResponse, EnrichResponse, ExtractPageInput, ExtractRequest, ExtractResponse,
FastGptRequest, FastGptResponse, LensCreateRequest, LensDetails, LensSummary,
LensUpdateRequest, NewsBatchCategories, NewsBatchCategory, NewsCategoriesResponse,
NewsCategoryMetadata, NewsCategoryMetadataList, NewsChaos, NewsChaosResponse,
NewsContentFilterSummary, NewsFilterPresetListEntry, NewsFilterPresetListResponse,
NewsLatestBatch, NewsResolvedCategory, NewsStoriesPayload, NewsStoriesResponse,
NewsStoryContentFilterSummary, RedirectRuleCreateRequest, RedirectRuleDetails,
RedirectRuleSummary, RedirectRuleUpdateRequest, SmallWebFeed, SubscriberSummarization,
SubscriberSummarizeMeta, SubscriberSummarizeRequest, SubscriberSummarizeResponse,
SummarizeRequest, SummarizeResponse, TextAlignmentsResponse, ToggleResourceResponse,
TranslateBootstrapMetadata, TranslateCommandRequest, TranslateDetectedLanguage,
TranslateOptionState, TranslateResponse, TranslateTextResponse, TranslateWarning,
TranslationSuggestionsResponse, WordInsightsResponse,
AssistantMessage, AssistantMeta, AssistantModelCatalog, AssistantModelOption,
AssistantProfileCreateRequest, AssistantProfileDetails, AssistantProfileSummary,
AssistantProfileUpdateRequest, AssistantPromptRequest, AssistantPromptResponse,
AssistantPromptStreamEvent, AssistantThread, AssistantThreadDeleteResponse,
AssistantThreadExportResponse, AssistantThreadListResponse, AssistantThreadOpenResponse,
AssistantThreadPagination, AssistantThreadSummary, AssistantUsage, CustomBangCreateRequest,
CustomBangDetails, CustomBangSummary, CustomBangUpdateRequest, DeletedResourceResponse,
EnrichResponse, ExtractPageInput, ExtractRequest, ExtractResponse, FastGptRequest,
FastGptResponse, LensCreateRequest, LensDetails, LensSummary, LensUpdateRequest,
NewsBatchCategories, NewsBatchCategory, NewsCategoriesResponse, NewsCategoryMetadata,
NewsCategoryMetadataList, NewsChaos, NewsChaosResponse, NewsContentFilterSummary,
NewsFilterPresetListEntry, NewsFilterPresetListResponse, NewsLatestBatch, NewsResolvedCategory,
NewsStoriesPayload, NewsStoriesResponse, NewsStoryContentFilterSummary,
RedirectRuleCreateRequest, RedirectRuleDetails, RedirectRuleSummary, RedirectRuleUpdateRequest,
SmallWebFeed, SubscriberSummarization, SubscriberSummarizeMeta, SubscriberSummarizeRequest,
SubscriberSummarizeResponse, SummarizeRequest, SummarizeResponse, TextAlignmentsResponse,
ToggleResourceResponse, TranslateBootstrapMetadata, TranslateCommandRequest,
TranslateDetectedLanguage, TranslateOptionState, TranslateResponse, TranslateTextResponse,
TranslateWarning, TranslationSuggestionsResponse, WordInsightsResponse,
};

const KAGI_SUMMARIZE_PATH: &str = "/api/v0/summarize";
Expand All @@ -63,6 +63,7 @@ const KAGI_NEWS_BATCH_CATEGORIES_PATH: &str = "/api/batches";
const NEWS_FILTER_PRESETS_JSON: &str = include_str!("../data/news-filter-presets.json");
const DEBUG_BODY_PREVIEW_LIMIT: usize = 256;
const KAGI_ASSISTANT_CONVERSATIONS_PATH: &str = "/api/conversations";
const KAGI_ASSISTANT_INIT_PATH: &str = "/api/init";
const KAGI_SETTINGS_ASSISTANT_PATH: &str = "/html/settings/assistant";
const KAGI_SETTINGS_CUSTOM_ASSISTANT_PATH: &str = "/settings/custom_assistant";
const KAGI_SETTINGS_CUSTOM_ASSISTANT_UPDATE_PATH: &str = "/settings/ast/profiles/update";
Expand Down Expand Up @@ -879,17 +880,17 @@ fn take_next_assistant_sse_frame(pending: &mut Vec<u8>) -> Result<Option<String>
Ok(Some(frame))
}

/// Lists Assistant base models exposed by the custom assistant form.
/// Lists every Assistant base model available to the authenticated account.
pub async fn execute_assistant_model_catalog(
token: &str,
) -> Result<AssistantModelCatalog, KagiError> {
let html = fetch_authenticated_html(
&http::kagi_url(KAGI_SETTINGS_CUSTOM_ASSISTANT_PATH),
let response = fetch_current_assistant_json::<CurrentAssistantInitResponse>(
KAGI_ASSISTANT_INIT_PATH,
token,
"custom assistant form",
"Assistant initialization",
)
.await?;
parse_assistant_model_catalog(&html)
Ok(response.models.into())
}

/// Lists all Kagi Assistant threads for the authenticated user.
Expand Down Expand Up @@ -4752,6 +4753,7 @@ fn assistant_message_from_payload(payload: AssistantMessagePayload) -> Assistant
documents: payload.documents,
profile: payload.profile,
trace_id: payload.trace_id,
usage: None,
}
}

Expand Down Expand Up @@ -4850,6 +4852,41 @@ struct CurrentAssistantConversationInitResponse {
messages: CurrentAssistantMessagesPage,
}

#[derive(Debug, Deserialize)]
struct CurrentAssistantInitResponse {
models: CurrentAssistantModelCatalog,
}

#[derive(Debug, Deserialize)]
struct CurrentAssistantModelCatalog {
models: Vec<CurrentAssistantModel>,
default: String,
}

impl From<CurrentAssistantModelCatalog> for AssistantModelCatalog {
fn from(catalog: CurrentAssistantModelCatalog) -> Self {
Self {
models: catalog.models.into_iter().map(Into::into).collect(),
default: catalog.default,
}
}
}

#[derive(Debug, Deserialize)]
struct CurrentAssistantModel {
id: String,
display_name: String,
}

impl From<CurrentAssistantModel> for AssistantModelOption {
fn from(model: CurrentAssistantModel) -> Self {
Self {
id: model.id,
label: model.display_name,
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

#[derive(Debug, Deserialize)]
struct CurrentAssistantConversationCreateResponse {
conversation: CurrentAssistantConversation,
Expand Down Expand Up @@ -4883,6 +4920,8 @@ struct CurrentAssistantPromptStreamFrame {
#[serde(default)]
references: Vec<Value>,
#[serde(default)]
usage: Option<CurrentAssistantUsage>,
#[serde(default)]
is_final: bool,
#[serde(default)]
error: Option<String>,
Expand Down Expand Up @@ -4944,6 +4983,7 @@ impl CurrentAssistantPromptParser {
documents: Vec::new(),
profile,
trace_id: None,
usage: None,
};
Self {
meta: AssistantMeta::default(),
Expand Down Expand Up @@ -4993,6 +5033,9 @@ impl CurrentAssistantPromptParser {
self.message.references_markdown =
current_assistant_references_markdown(&frame.references);
}
if let Some(usage) = frame.usage {
self.message.usage = Some(usage.into());
}
if let Some(markdown) = frame.text {
self.message.markdown = Some(markdown);
}
Expand Down Expand Up @@ -5134,6 +5177,31 @@ struct CurrentAssistantMessage {
model_name: Option<String>,
#[serde(default)]
model_version: Option<String>,
#[serde(default)]
input_tokens: Option<u64>,
#[serde(default)]
output_tokens: Option<u64>,
#[serde(default)]
cost_usd: Option<Number>,
}

#[derive(Debug, Deserialize)]
struct CurrentAssistantUsage {
input_tokens: u64,
output_tokens: u64,
total_tokens: u64,
cost_usd: Number,
}

impl From<CurrentAssistantUsage> for AssistantUsage {
fn from(usage: CurrentAssistantUsage) -> Self {
Self {
prompt_tokens: usage.input_tokens,
completion_tokens: usage.output_tokens,
total_tokens: usage.total_tokens,
cost_usd: usage.cost_usd,
}
}
}

fn current_assistant_messages_to_legacy_turns(
Expand Down Expand Up @@ -5175,6 +5243,7 @@ fn current_message_pair_to_assistant_message(
assistant: CurrentAssistantMessage,
) -> AssistantMessage {
let profile = current_assistant_message_profile(&assistant);
let usage = current_assistant_message_usage(&assistant);
AssistantMessage {
id: assistant.uuid.or(user.uuid).unwrap_or_default(),
thread_id: thread_id.to_string(),
Expand All @@ -5190,6 +5259,7 @@ fn current_message_pair_to_assistant_message(
documents: assistant.attachments,
profile,
trace_id: None,
usage,
}
}

Expand All @@ -5212,6 +5282,7 @@ fn current_user_message_to_assistant_message(
documents: user.attachments,
profile: None,
trace_id: None,
usage: None,
}
}

Expand All @@ -5220,6 +5291,7 @@ fn current_assistant_message_to_assistant_message(
assistant: CurrentAssistantMessage,
) -> AssistantMessage {
let profile = current_assistant_message_profile(&assistant);
let usage = current_assistant_message_usage(&assistant);
AssistantMessage {
id: assistant.uuid.unwrap_or_default(),
thread_id: thread_id.to_string(),
Expand All @@ -5235,9 +5307,22 @@ fn current_assistant_message_to_assistant_message(
documents: assistant.attachments,
profile,
trace_id: None,
usage,
}
}

fn current_assistant_message_usage(message: &CurrentAssistantMessage) -> Option<AssistantUsage> {
let prompt_tokens = message.input_tokens?;
let completion_tokens = message.output_tokens?;
let cost_usd = message.cost_usd.clone()?;
Some(AssistantUsage {
prompt_tokens,
completion_tokens,
total_tokens: prompt_tokens + completion_tokens,
cost_usd,
})
}

fn current_assistant_message_profile(message: &CurrentAssistantMessage) -> Option<Value> {
if message.model_name.is_none() && message.model_version.is_none() {
return None;
Expand Down Expand Up @@ -6820,19 +6905,7 @@ mod tests {
let catalog = super::execute_assistant_model_catalog(token)
.await
.expect("assistant model catalog should load");
catalog
.models
.iter()
.find(|model| model.selected)
.or_else(|| {
catalog
.models
.iter()
.find(|model| model.id == "gpt-5-4-nano")
})
.or_else(|| catalog.models.first())
.map(|model| model.id.clone())
.expect("assistant model catalog should contain at least one model")
catalog.default
}

#[tokio::test]
Expand Down
2 changes: 1 addition & 1 deletion src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1014,7 +1014,7 @@ pub struct AssistantArgs {
pub enum AssistantSubcommand {
/// Manage Assistant threads
Thread(AssistantThreadArgs),
/// List Assistant base-model slugs available to custom assistants
/// List every Assistant base model available to the current account
Models,
/// Manage custom assistants
Custom(AssistantCustomArgs),
Expand Down
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5623,6 +5623,7 @@ mod tests {
documents: vec![],
profile: None,
trace_id: None,
usage: None,
},
}
}
Expand Down
Loading