diff --git a/CHANGELOG.md b/CHANGELOG.md index b3e4ac4..b50326e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. + ## [0.16.0] ### Added diff --git a/docs/commands/assistant.mdx b/docs/commands/assistant.mdx index a80b1bb..be5273e 100644 --- a/docs/commands/assistant.mdx +++ b/docs/commands/assistant.mdx @@ -20,6 +20,7 @@ kagi assistant thread list kagi assistant thread get kagi assistant thread delete kagi assistant thread export [--format markdown|json] +kagi assistant models kagi assistant repl [OPTIONS] kagi assistant custom list kagi assistant custom get @@ -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 ` Select the streamed output mode. This option requires `--stream`. @@ -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` diff --git a/docs/reference/output-contract.mdx b/docs/reference/output-contract.mdx index cf65670..42f885f 100644 --- a/docs/reference/output-contract.mdx +++ b/docs/reference/output-contract.mdx @@ -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 ` and `kagi assistant --contract-file ` print only the validated contract JSON. Contract mode supports `--format json` and `--format compact`. ```json diff --git a/src/api.rs b/src/api.rs index 42da063..f91602d 100644 --- a/src/api.rs +++ b/src/api.rs @@ -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; @@ -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"; @@ -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"; @@ -879,17 +880,17 @@ fn take_next_assistant_sse_frame(pending: &mut Vec) -> Result 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 { - let html = fetch_authenticated_html( - &http::kagi_url(KAGI_SETTINGS_CUSTOM_ASSISTANT_PATH), + let response = fetch_current_assistant_json::( + 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. @@ -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, } } @@ -4850,6 +4852,41 @@ struct CurrentAssistantConversationInitResponse { messages: CurrentAssistantMessagesPage, } +#[derive(Debug, Deserialize)] +struct CurrentAssistantInitResponse { + models: CurrentAssistantModelCatalog, +} + +#[derive(Debug, Deserialize)] +struct CurrentAssistantModelCatalog { + models: Vec, + default: String, +} + +impl From 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 for AssistantModelOption { + fn from(model: CurrentAssistantModel) -> Self { + Self { + id: model.id, + label: model.display_name, + } + } +} + #[derive(Debug, Deserialize)] struct CurrentAssistantConversationCreateResponse { conversation: CurrentAssistantConversation, @@ -4883,6 +4920,8 @@ struct CurrentAssistantPromptStreamFrame { #[serde(default)] references: Vec, #[serde(default)] + usage: Option, + #[serde(default)] is_final: bool, #[serde(default)] error: Option, @@ -4944,6 +4983,7 @@ impl CurrentAssistantPromptParser { documents: Vec::new(), profile, trace_id: None, + usage: None, }; Self { meta: AssistantMeta::default(), @@ -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); } @@ -5134,6 +5177,31 @@ struct CurrentAssistantMessage { model_name: Option, #[serde(default)] model_version: Option, + #[serde(default)] + input_tokens: Option, + #[serde(default)] + output_tokens: Option, + #[serde(default)] + cost_usd: Option, +} + +#[derive(Debug, Deserialize)] +struct CurrentAssistantUsage { + input_tokens: u64, + output_tokens: u64, + total_tokens: u64, + cost_usd: Number, +} + +impl From 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( @@ -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(), @@ -5190,6 +5259,7 @@ fn current_message_pair_to_assistant_message( documents: assistant.attachments, profile, trace_id: None, + usage, } } @@ -5212,6 +5282,7 @@ fn current_user_message_to_assistant_message( documents: user.attachments, profile: None, trace_id: None, + usage: None, } } @@ -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(), @@ -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 { + 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 { if message.model_name.is_none() && message.model_version.is_none() { return None; @@ -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] diff --git a/src/cli.rs b/src/cli.rs index 523de43..4dcc3a9 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -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), diff --git a/src/main.rs b/src/main.rs index f824bdf..cf98746 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5623,6 +5623,7 @@ mod tests { documents: vec![], profile: None, trace_id: None, + usage: None, }, } } diff --git a/src/parser.rs b/src/parser.rs index f5978b8..bc40f0a 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -12,9 +12,9 @@ use crate::error::KagiError; #[cfg(test)] use crate::types::AssistantThreadSummary; use crate::types::{ - AssistantModelCatalog, AssistantModelOption, AssistantProfileDetails, AssistantProfileSummary, - CustomBangDetails, CustomBangSummary, LensDetails, LensSummary, NewsSearchCluster, - NewsSearchResult, RedirectRuleDetails, RedirectRuleSummary, SearchResult, + AssistantProfileDetails, AssistantProfileSummary, CustomBangDetails, CustomBangSummary, + LensDetails, LensSummary, NewsSearchCluster, NewsSearchResult, RedirectRuleDetails, + RedirectRuleSummary, SearchResult, }; /// Parse Kagi search results from HTML. @@ -386,46 +386,6 @@ pub fn parse_assistant_profile_form(html: &str) -> Result Result { - let document = Html::parse_document(html); - let selector = selector(r#"input[type="radio"][name="base_model"]"#)?; - let models = document - .select(&selector) - .filter_map(|node| { - let id = node.value().attr("value")?.trim(); - if id.is_empty() { - return None; - } - - let label = node - .value() - .attr("aria-label") - .or_else(|| node.value().attr("title")) - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(id); - - Some(AssistantModelOption { - id: id.to_string(), - label: label.to_string(), - selected: node.value().attr("checked").is_some(), - }) - }) - .collect(); - - Ok(AssistantModelCatalog { models }) -} - /// Parses a list of Kagi lenses from the settings HTML. /// /// # Arguments @@ -815,10 +775,9 @@ fn parse_query_value(href: &str, key: &str) -> Option { #[cfg(test)] mod tests { use super::{ - parse_assistant_model_catalog, parse_assistant_profile_form, parse_assistant_profile_list, - parse_assistant_thread_list, parse_custom_bang_form, parse_custom_bang_list, - parse_lens_form, parse_lens_list, parse_news_search_results, parse_redirect_form, - parse_redirect_list, parse_search_results, + parse_assistant_profile_form, parse_assistant_profile_list, parse_assistant_thread_list, + parse_custom_bang_form, parse_custom_bang_list, parse_lens_form, parse_lens_list, + parse_news_search_results, parse_redirect_form, parse_redirect_list, parse_search_results, }; use crate::error::KagiError; @@ -1020,27 +979,6 @@ mod tests { assert_eq!(details.selected_lens, "0"); } - #[test] - fn parses_assistant_model_catalog_from_base_model_radios() { - let html = r#" -
- - - -
- "#; - - let catalog = parse_assistant_model_catalog(html).expect("catalog should parse"); - - assert_eq!(catalog.models.len(), 2); - assert_eq!(catalog.models[0].id, "gpt-5-5"); - assert_eq!(catalog.models[0].label, "GPT 5.5"); - assert!(catalog.models[0].selected); - assert_eq!(catalog.models[1].id, "claude-4-7-opus"); - assert_eq!(catalog.models[1].label, "Claude Opus"); - assert!(!catalog.models[1].selected); - } - #[test] fn parses_lens_list_items() { let html = r#" diff --git a/src/types.rs b/src/types.rs index 559259d..ff7e406 100644 --- a/src/types.rs +++ b/src/types.rs @@ -15,7 +15,7 @@ use std::collections::HashMap; use std::path::PathBuf; use serde::{Deserialize, Serialize}; -use serde_json::Value; +use serde_json::{Number, Value}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] /// A single search result from the Kagi search API. @@ -424,6 +424,15 @@ pub struct AssistantThread { pub folder_ids: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +/// Token and cost accounting for one completed Assistant message. +pub struct AssistantUsage { + pub prompt_tokens: u64, + pub completion_tokens: u64, + pub total_tokens: u64, + pub cost_usd: Number, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] /// A single message within an assistant thread. pub struct AssistantMessage { @@ -450,6 +459,8 @@ pub struct AssistantMessage { pub profile: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub trace_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub usage: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -471,17 +482,17 @@ pub struct AssistantPromptStreamEvent { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -/// One model option from the Assistant custom-profile form. +/// One base model available to the authenticated Assistant account. pub struct AssistantModelOption { pub id: String, pub label: String, - pub selected: bool, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] /// Stable JSON shape for Assistant model catalog output. pub struct AssistantModelCatalog { pub models: Vec, + pub default: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] diff --git a/tests/integration-cli.rs b/tests/integration-cli.rs index 57916f4..9b85778 100644 --- a/tests/integration-cli.rs +++ b/tests/integration-cli.rs @@ -515,6 +515,12 @@ fn mock_current_assistant_prompt<'a>( "html_content": format!("

{final_markdown}

"), "conversation_title": "Current Assistant", "assistant_message_uuid": "assistant-current", + "usage": { + "input_tokens": 4314, + "output_tokens": 2, + "total_tokens": 4316, + "cost_usd": 0.006192 + }, "is_final": true }) )); @@ -2064,12 +2070,22 @@ fn assistant_thread_list_paginates_with_cursor_id() { #[test] fn assistant_models_prints_json_catalog() { let server = MockServer::start(); - let _form = server.mock(|when, then| { + let _catalog = server.mock(|when, then| { when.method(GET) - .path("/settings/custom_assistant") - .header("cookie", "kagi_session=test-session"); + .path("/api/init") + .header("cookie", "kagi_session=test-session") + .header("accept", "application/json"); then.status(200) - .body(assistant_form_html("profile-once", "Once")); + .header("content-type", "application/json") + .json_body(json!({ + "models": { + "models": [ + { "id": "ki_quick", "display_name": "Quick" }, + { "id": "claude-5-opus-thinking", "display_name": "Claude Opus 5 (reasoning)" } + ], + "default": "ki_quick" + } + })); }); let tempdir = TempDir::new().expect("tempdir"); @@ -2078,10 +2094,10 @@ fn assistant_models_prints_json_catalog() { assert_success(&output); let body: Value = serde_json::from_slice(&output.stdout).expect("json output should parse"); - assert_eq!(body["models"][0]["id"], "gpt-5-mini"); - assert_eq!(body["models"][0]["label"], "GPT 5 Mini"); - assert_eq!(body["models"][0]["selected"], true); - assert_eq!(body["models"][1]["id"], "claude-4-7-opus"); + assert_eq!(body["models"][0]["id"], "ki_quick"); + assert_eq!(body["models"][0]["label"], "Quick"); + assert_eq!(body["models"][1]["id"], "claude-5-opus-thinking"); + assert_eq!(body["default"], "ki_quick"); } #[test] @@ -2121,6 +2137,10 @@ fn assistant_stream_can_print_ndjson_updates() { assert_eq!(lines[0]["md_delta"], "Hel"); assert_eq!(lines[1]["md_delta"], "lo"); assert_eq!(lines[1]["message"]["state"], "done"); + assert_eq!(lines[1]["message"]["usage"]["prompt_tokens"], 4314); + assert_eq!(lines[1]["message"]["usage"]["completion_tokens"], 2); + assert_eq!(lines[1]["message"]["usage"]["total_tokens"], 4316); + assert_eq!(lines[1]["message"]["usage"]["cost_usd"], 0.006192); } #[test] @@ -2932,6 +2952,9 @@ fn mcp_assistant_thread_export_json_overrides_default_output() { "content": "Hello back", "html_content": "

Hello back

", "created_at": "2026-03-16T06:20:07Z", + "input_tokens": 4314, + "output_tokens": 2, + "cost_usd": 0.006192, "references": [] } ], @@ -2973,6 +2996,10 @@ fn mcp_assistant_thread_export_json_overrides_default_output() { assert_eq!(body["thread"]["id"], "thread-1"); assert_eq!(body["messages"][0]["prompt"], "Hello"); assert_eq!(body["messages"][0]["markdown"], "Hello back"); + assert_eq!(body["messages"][0]["usage"]["prompt_tokens"], 4314); + assert_eq!(body["messages"][0]["usage"]["completion_tokens"], 2); + assert_eq!(body["messages"][0]["usage"]["total_tokens"], 4316); + assert_eq!(body["messages"][0]["usage"]["cost_usd"], 0.006192); } #[test]