Compare commits

..
1 Commits
Author SHA1 Message Date
KimiSwitch Dev 6b172914be 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
2026-07-28 16:24:36 +08:00
4 changed files with 131 additions and 66 deletions

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "kimiswitch", "name": "kimiswitch",
"private": true, "private": true,
"version": "0.4.0", "version": "0.4.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "kimiswitch" name = "kimiswitch"
version = "0.4.0" version = "0.4.1"
description = "Kimi Switch - model config manager" description = "Kimi Switch - model config manager"
authors = ["you"] authors = ["you"]
edition = "2021" edition = "2021"
+128 -63
View File
@@ -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<Vec<DiscoveredModel>, String> { async fn fetch_openai_models(base: &str, api_key: &str) -> Result<Vec<DiscoveredModel>, 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)] #[derive(serde::Deserialize)]
struct OaiModel { struct OaiModel {
id: String, id: String,
@@ -270,16 +258,57 @@ async fn fetch_openai_models(base: &str, api_key: &str) -> Result<Vec<Discovered
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
struct OaiList { struct OaiList {
data: Vec<OaiModel>, data: Vec<OaiModel>,
#[serde(default)]
has_more: Option<bool>,
#[serde(default)]
last_id: Option<String>,
#[serde(default)]
next_page_token: Option<String>,
} }
let body: OaiList = resp let client = reqwest::Client::new();
.json() let root = base.trim_end_matches('/').to_string();
.await let mut url = format!("{}/models", root);
.map_err(|e| format!("failed to parse response from {}: {e}", url))?; let mut all: Vec<OaiModel> = Vec::new();
const MAX_PAGES: usize = 50;
Ok(body for _ in 0..MAX_PAGES {
.data 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=<last_id>
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=<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() .into_iter()
.filter(|m| seen.insert(m.id.clone()))
.map(|m| DiscoveredModel { .map(|m| DiscoveredModel {
id: m.id, id: m.id,
display_name: None, display_name: None,
@@ -288,22 +317,9 @@ async fn fetch_openai_models(base: &str, api_key: &str) -> Result<Vec<Discovered
.collect()) .collect())
} }
// ── Anthropic /v1/models endpoint ─────────────────────────────────── // ── Anthropic /v1/models endpoint (with pagination) ──────────────────
async fn fetch_anthropic_models(base: &str, api_key: &str) -> Result<Vec<DiscoveredModel>, String> { async fn fetch_anthropic_models(base: &str, api_key: &str) -> Result<Vec<DiscoveredModel>, 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)] #[derive(serde::Deserialize)]
struct AntModel { struct AntModel {
id: String, id: String,
@@ -312,16 +328,46 @@ async fn fetch_anthropic_models(base: &str, api_key: &str) -> Result<Vec<Discove
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
struct AntList { struct AntList {
data: Vec<AntModel>, data: Vec<AntModel>,
#[serde(default)]
has_more: Option<bool>,
#[serde(default)]
last_id: Option<String>,
} }
let body: AntList = resp let client = reqwest::Client::new();
.json() let root = base.trim_end_matches('/').to_string();
.await let mut url = format!("{}/v1/models?limit=1000", root);
.map_err(|e| format!("failed to parse response from {}: {e}", url))?; let mut all: Vec<AntModel> = Vec::new();
const MAX_PAGES: usize = 20;
Ok(body for _ in 0..MAX_PAGES {
.data 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() .into_iter()
.filter(|m| seen.insert(m.id.clone()))
.map(|m| DiscoveredModel { .map(|m| DiscoveredModel {
id: m.id, id: m.id,
display_name: m.display_name, display_name: m.display_name,
@@ -330,27 +376,12 @@ async fn fetch_anthropic_models(base: &str, api_key: &str) -> Result<Vec<Discove
.collect()) .collect())
} }
// ── Google GenAI /v1beta/models endpoint ──────────────────────────── // ── Google GenAI /v1beta/models endpoint (with pagination) ───────────
async fn fetch_google_genai_models( async fn fetch_google_genai_models(
base: &str, base: &str,
api_key: &str, api_key: &str,
) -> Result<Vec<DiscoveredModel>, String> { ) -> Result<Vec<DiscoveredModel>, 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)] #[derive(serde::Deserialize)]
struct GglModel { struct GglModel {
name: String, name: String,
@@ -362,18 +393,52 @@ async fn fetch_google_genai_models(
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
struct GglList { struct GglList {
models: Vec<GglModel>, models: Vec<GglModel>,
#[serde(rename = "nextPageToken", default)]
next_page_token: Option<String>,
} }
let body: GglList = resp let client = reqwest::Client::new();
.json() let root = base.trim_end_matches('/').to_string();
.await let mut url = format!("{}/v1beta/models?key={}&pageSize=1000", root, api_key);
.map_err(|e| format!("failed to parse response from {}: {e}", url))?; let mut all: Vec<GglModel> = Vec::new();
const MAX_PAGES: usize = 50;
Ok(body for _ in 0..MAX_PAGES {
.models 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() .into_iter()
.filter(|m| seen.insert(m.name.clone()))
.map(|m| { .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 { DiscoveredModel {
id, id,
display_name: m.display_name, display_name: m.display_name,
@@ -553,4 +618,4 @@ pub fn open_installer(app: tauri::AppHandle, path: String) -> Result<(), String>
app.opener() app.opener()
.open_path(&path, None::<&str>) .open_path(&path, None::<&str>)
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"productName": "Kimi Switch", "productName": "Kimi Switch",
"version": "0.4.0", "version": "0.4.1",
"identifier": "com.kimiswitch.app", "identifier": "com.kimiswitch.app",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",