From 6b172914be0e7bcddd0d169fa162875097436d28 Mon Sep 17 00:00:00 2001 From: KimiSwitch Dev Date: Tue, 28 Jul 2026 16:24:36 +0800 Subject: [PATCH] fix: v0.4.1 - paginate model discovery for OpenAI / Anthropic / Google - fetch_openai_models: loop on has_more + last_id (?after=) and fall back to next_page_token (?page_token=) - fetch_anthropic_models: loop on has_more + last_id (?after_id=&limit=1000) - fetch_google_genai_models: loop on nextPageToken (?pageToken=&pageSize=1000) - Dedup by id across pages, MAX_PAGES safety cap per provider - Bump version to 0.4.1 --- package.json | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/commands.rs | 191 +++++++++++++++++++++++++------------- src-tauri/tauri.conf.json | 2 +- 4 files changed, 131 insertions(+), 66 deletions(-) diff --git a/package.json b/package.json index 1a7fd1b..441304d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "kimiswitch", "private": true, - "version": "0.4.0", + "version": "0.4.1", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3c469f5..2f9d6d3 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "kimiswitch" -version = "0.4.0" +version = "0.4.1" description = "Kimi Switch - model config manager" authors = ["you"] edition = "2021" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index f13d2d7..0279e72 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -248,21 +248,9 @@ fn expected_api_key_key(provider_type: &ProviderType) -> &'static str { } } -// ── OpenAI-compatible /models endpoint ────────────────────────────── +// ── OpenAI-compatible /models endpoint (with pagination) ───────────── async fn fetch_openai_models(base: &str, api_key: &str) -> Result, String> { - let url = format!("{}/models", base.trim_end_matches('/')); - let resp = reqwest::Client::new() - .get(&url) - .bearer_auth(api_key) - .send() - .await - .map_err(|e| format!("HTTP request to {} failed: {e}", url))?; - - if !resp.status().is_success() { - return Err(format!("{} returned HTTP {}", url, resp.status())); - } - #[derive(serde::Deserialize)] struct OaiModel { id: String, @@ -270,16 +258,57 @@ async fn fetch_openai_models(base: &str, api_key: &str) -> Result, + #[serde(default)] + has_more: Option, + #[serde(default)] + last_id: Option, + #[serde(default)] + next_page_token: Option, } - let body: OaiList = resp - .json() - .await - .map_err(|e| format!("failed to parse response from {}: {e}", url))?; + let client = reqwest::Client::new(); + let root = base.trim_end_matches('/').to_string(); + let mut url = format!("{}/models", root); + let mut all: Vec = Vec::new(); + const MAX_PAGES: usize = 50; - Ok(body - .data + for _ in 0..MAX_PAGES { + let resp = client + .get(&url) + .bearer_auth(api_key) + .send() + .await + .map_err(|e| format!("HTTP request to {} failed: {e}", url))?; + if !resp.status().is_success() { + return Err(format!("{} returned HTTP {}", url, resp.status())); + } + let body: OaiList = resp + .json() + .await + .map_err(|e| format!("failed to parse response from {}: {e}", url))?; + all.extend(body.data); + + // OpenAI cursor pagination: has_more + last_id → ?after= + if body.has_more.unwrap_or(false) { + if let Some(last_id) = body.last_id.clone() { + url = format!("{}/models?after={}", root, last_id); + continue; + } + } + // Token-based pagination: next_page_token → ?page_token= + if let Some(token) = body.next_page_token.clone() { + if !token.is_empty() { + url = format!("{}/models?page_token={}", root, token); + continue; + } + } + break; + } + + let mut seen = std::collections::HashSet::new(); + Ok(all .into_iter() + .filter(|m| seen.insert(m.id.clone())) .map(|m| DiscoveredModel { id: m.id, display_name: None, @@ -288,22 +317,9 @@ async fn fetch_openai_models(base: &str, api_key: &str) -> Result Result, String> { - let url = format!("{}/v1/models", base.trim_end_matches('/')); - let resp = reqwest::Client::new() - .get(&url) - .header("x-api-key", api_key) - .header("anthropic-version", "2023-06-01") - .send() - .await - .map_err(|e| format!("HTTP request to {} failed: {e}", url))?; - - if !resp.status().is_success() { - return Err(format!("{} returned HTTP {}", url, resp.status())); - } - #[derive(serde::Deserialize)] struct AntModel { id: String, @@ -312,16 +328,46 @@ async fn fetch_anthropic_models(base: &str, api_key: &str) -> Result, + #[serde(default)] + has_more: Option, + #[serde(default)] + last_id: Option, } - let body: AntList = resp - .json() - .await - .map_err(|e| format!("failed to parse response from {}: {e}", url))?; + let client = reqwest::Client::new(); + let root = base.trim_end_matches('/').to_string(); + let mut url = format!("{}/v1/models?limit=1000", root); + let mut all: Vec = Vec::new(); + const MAX_PAGES: usize = 20; - Ok(body - .data + for _ in 0..MAX_PAGES { + let resp = client + .get(&url) + .header("x-api-key", api_key) + .header("anthropic-version", "2023-06-01") + .send() + .await + .map_err(|e| format!("HTTP request to {} failed: {e}", url))?; + if !resp.status().is_success() { + return Err(format!("{} returned HTTP {}", url, resp.status())); + } + let body: AntList = resp + .json() + .await + .map_err(|e| format!("failed to parse response from {}: {e}", url))?; + let more = body.has_more.unwrap_or(false); + let cursor = body.last_id.clone(); + all.extend(body.data); + if !(more && cursor.is_some()) { + break; + } + url = format!("{}/v1/models?limit=1000&after_id={}", root, cursor.unwrap()); + } + + let mut seen = std::collections::HashSet::new(); + Ok(all .into_iter() + .filter(|m| seen.insert(m.id.clone())) .map(|m| DiscoveredModel { id: m.id, display_name: m.display_name, @@ -330,27 +376,12 @@ async fn fetch_anthropic_models(base: &str, api_key: &str) -> Result Result, String> { - let url = format!( - "{}/v1beta/models?key={}", - base.trim_end_matches('/'), - api_key - ); - let resp = reqwest::Client::new() - .get(&url) - .send() - .await - .map_err(|e| format!("HTTP request to {} failed: {e}", url))?; - - if !resp.status().is_success() { - return Err(format!("{} returned HTTP {}", url, resp.status())); - } - #[derive(serde::Deserialize)] struct GglModel { name: String, @@ -362,18 +393,52 @@ async fn fetch_google_genai_models( #[derive(serde::Deserialize)] struct GglList { models: Vec, + #[serde(rename = "nextPageToken", default)] + next_page_token: Option, } - let body: GglList = resp - .json() - .await - .map_err(|e| format!("failed to parse response from {}: {e}", url))?; + let client = reqwest::Client::new(); + let root = base.trim_end_matches('/').to_string(); + let mut url = format!("{}/v1beta/models?key={}&pageSize=1000", root, api_key); + let mut all: Vec = Vec::new(); + const MAX_PAGES: usize = 50; - Ok(body - .models + for _ in 0..MAX_PAGES { + let resp = client + .get(&url) + .send() + .await + .map_err(|e| format!("HTTP request to {} failed: {e}", url))?; + if !resp.status().is_success() { + return Err(format!("{} returned HTTP {}", url, resp.status())); + } + let body: GglList = resp + .json() + .await + .map_err(|e| format!("failed to parse response from {}: {e}", url))?; + let cursor = body.next_page_token.clone(); + all.extend(body.models); + match cursor { + Some(t) if !t.is_empty() => { + url = format!( + "{}/v1beta/models?key={}&pageSize=1000&pageToken={}", + root, api_key, t + ); + } + _ => break, + } + } + + let mut seen = std::collections::HashSet::new(); + Ok(all .into_iter() + .filter(|m| seen.insert(m.name.clone())) .map(|m| { - let id = m.name.strip_prefix("models/").unwrap_or(&m.name).to_string(); + let id = m + .name + .strip_prefix("models/") + .unwrap_or(&m.name) + .to_string(); DiscoveredModel { id, display_name: m.display_name, @@ -553,4 +618,4 @@ pub fn open_installer(app: tauri::AppHandle, path: String) -> Result<(), String> app.opener() .open_path(&path, None::<&str>) .map_err(|e| e.to_string()) -} +} \ No newline at end of file diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 5ea39f0..6947e42 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,6 +1,6 @@ { "productName": "Kimi Switch", - "version": "0.4.0", + "version": "0.4.1", "identifier": "com.kimiswitch.app", "build": { "beforeDevCommand": "npm run dev",