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
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,13 @@ OpenQuota checks for updates automatically. Installable updates are cryptographi
- **[OpenRouter](docs/providers/openrouter.md)** — credit balance and daily, weekly and monthly spend
(API key)
- **[Z.ai](docs/providers/zai.md)** — GLM Coding Plan session, weekly, and web-search quotas (API key)
- **[Kimi](docs/providers/kimi.md)** — Kimi Code session and weekly quotas (API key)
- **[MiniMax](docs/providers/minimax.md)** — Token Plan session and weekly quotas (API key)

Most providers use credentials already available on your computer. OpenRouter and Z.ai require API
keys, which you can add in Customize; OpenQuota stores them securely in your operating system's
credential store. Codex subscription limits require a ChatGPT login and are not available in
API-key-only sessions.
Most providers use credentials already available on your computer. OpenRouter, Z.ai, Kimi, and
MiniMax require API keys, which you can add in Customize; OpenQuota stores them securely in your
operating system's credential store. Codex subscription limits require a ChatGPT login and are not
available in API-key-only sessions.

## Features

Expand Down
26 changes: 26 additions & 0 deletions docs/providers/kimi.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Kimi

OpenQuota tracks the Session (rolling five-hour) and Weekly quotas of a Kimi Code membership.

## What it tracks

| Metric | Meaning |
| ------- | -------------------------------------------- |
| Session | Usage remaining in the rolling 5-hour window |
| Weekly | Usage remaining in the rolling 7-day window |

## Setup

Create a Kimi Code API key in the [Kimi Code Console](https://www.kimi.com/code/console), then add
it in **Customize** in OpenQuota. Saved keys are stored in the operating system's credential store.
OpenQuota also checks `KIMI_API_KEY` and `~/.config/openquota/kimi.json`; a key saved in the app
takes priority.

This provider uses the Kimi Code endpoint, `https://api.kimi.com/coding/v1`. Kimi Code keys are not
interchangeable with Kimi Open Platform keys.

## Troubleshooting

- **Add an API key** — add a Kimi Code key in Customize or provide it through a supported external source.
- **API key invalid** — create or verify the key in the [Kimi Code Console](https://www.kimi.com/code/console).
- **Usage unavailable** — check the connection and refresh again.
28 changes: 28 additions & 0 deletions docs/providers/minimax.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# MiniMax

OpenQuota tracks the Session and Weekly quotas of a MiniMax Token Plan.

## What it tracks

| Metric | Meaning |
| ------- | -------------------------------------------- |
| Session | Usage remaining in the rolling 5-hour window |
| Weekly | Usage remaining in the rolling 7-day window |

## Setup

Create or view the Token Plan subscription key in the [MiniMax global console](https://platform.minimax.io/console/plan),
then add it in **Customize** in OpenQuota. Saved keys are stored in the operating system's credential
store. OpenQuota also checks `MINIMAX_API_KEY` and `~/.config/openquota/minimax.json`; a key saved
in the app takes priority.

This provider uses MiniMax's global endpoint, `https://www.minimax.io/v1/token_plan/remains`. Use a
key from the global console; keys from the mainland China platform are a separate account system.

## Troubleshooting

- **Add an API key** — add a MiniMax Token Plan key in Customize or provide it through a supported
external source.
- **No active token plan** — subscribe to a Token Plan in the
[MiniMax global console](https://platform.minimax.io/console/plan).
- **Usage unavailable** — check the connection and refresh again.
4 changes: 3 additions & 1 deletion scripts/verify/verify-provider-registry-contract.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const frontendConsumers = [
'src/lib/shareCard.ts',
];
const providerLiteral =
/["'](?:claude|codex|cursor|antigravity|copilot|devin|grok|opencode|openrouter|zai)["']/;
/["'](?:claude|codex|cursor|antigravity|copilot|devin|grok|opencode|openrouter|zai|kimi|minimax)["']/;

for (const file of rustConsumers) {
const source = fs.readFileSync(new URL(file, root), 'utf8').split('#[cfg(test)]')[0];
Expand Down Expand Up @@ -90,6 +90,8 @@ const expectedRuntimeOrder = [
'OpenCodeProvider',
'OpenRouterProvider',
'ZaiProvider',
'KimiProvider',
'MiniMaxProvider',
];
if (runtimeOrder.join(',') !== expectedRuntimeOrder.join(',')) {
throw new Error(
Expand Down
8 changes: 5 additions & 3 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,9 @@ use crate::{
providers::{
antigravity::AntigravityProvider, claude, codex::reset_claim::CodexResetClaimService,
codex::CodexProvider, copilot::CopilotProvider, cursor::CursorProvider,
detect_local_credentials, devin::DevinProvider, grok::GrokProvider,
opencode::OpenCodeProvider, openrouter::OpenRouterProvider, zai::ZaiProvider,
ProviderRegistry, UsageProvider,
detect_local_credentials, devin::DevinProvider, grok::GrokProvider, kimi::KimiProvider,
minimax::MiniMaxProvider, opencode::OpenCodeProvider, openrouter::OpenRouterProvider,
zai::ZaiProvider, ProviderRegistry, UsageProvider,
},
storage::Storage,
window::{
Expand Down Expand Up @@ -240,6 +240,8 @@ pub fn run() {
Arc::new(OpenCodeProvider::new(pricing.clone())) as Arc<dyn UsageProvider>,
Arc::new(OpenRouterProvider::new()?) as Arc<dyn UsageProvider>,
Arc::new(ZaiProvider::new()?) as Arc<dyn UsageProvider>,
Arc::new(KimiProvider::new()?) as Arc<dyn UsageProvider>,
Arc::new(MiniMaxProvider::new()?) as Arc<dyn UsageProvider>,
]);
let registry = Arc::new(ProviderRegistry::new(providers)?);
let (settings_service, credential_detection_plan) =
Expand Down
8 changes: 8 additions & 0 deletions src-tauri/src/menu_bar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ const GROK_ICON: &str = include_str!("../../src/assets/provider-icons/grok.svg")
const OPENCODE_ICON: &str = include_str!("../../src/assets/provider-icons/opencode.svg");
const OPENROUTER_ICON: &str = include_str!("../../src/assets/provider-icons/openrouter.svg");
const ZAI_ICON: &str = include_str!("../../src/assets/provider-icons/zai.svg");
const KIMI_ICON: &str = include_str!("../../src/assets/provider-icons/kimi.svg");
const MINIMAX_ICON: &str = include_str!("../../src/assets/provider-icons/minimax.svg");

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TextGroup {
Expand Down Expand Up @@ -277,6 +279,8 @@ fn provider_path(provider_id: &str) -> Option<&'static Path> {
static OPENCODE: OnceLock<Path> = OnceLock::new();
static OPENROUTER: OnceLock<Path> = OnceLock::new();
static ZAI: OnceLock<Path> = OnceLock::new();
static KIMI: OnceLock<Path> = OnceLock::new();
static MINIMAX: OnceLock<Path> = OnceLock::new();
match crate::providers::provider_family(provider_id) {
"claude" => Some(parsed(CLAUDE_ICON, &CLAUDE)),
"codex" => Some(parsed(CODEX_ICON, &CODEX)),
Expand All @@ -288,6 +292,8 @@ fn provider_path(provider_id: &str) -> Option<&'static Path> {
"opencode" => Some(parsed(OPENCODE_ICON, &OPENCODE)),
"openrouter" => Some(parsed(OPENROUTER_ICON, &OPENROUTER)),
"zai" => Some(parsed(ZAI_ICON, &ZAI)),
"kimi" => Some(parsed(KIMI_ICON, &KIMI)),
"minimax" => Some(parsed(MINIMAX_ICON, &MINIMAX)),
_ => None,
}
}
Expand Down Expand Up @@ -591,6 +597,8 @@ mod tests {
"opencode",
"openrouter",
"zai",
"kimi",
"minimax",
] {
let path = provider_path(provider).expect("known provider mark should exist");
assert!(path.bounds().width() > 0.0);
Expand Down
65 changes: 65 additions & 0 deletions src-tauri/src/providers/kimi/auth.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
use crate::{
models::ApiKeyStatus,
providers::api_key::{ApiKeyStore, SecretString},
};

use super::KimiError;

const CONFIG_PATHS: &[&str] = &["~/.config/openquota/kimi.json"];
const ENVIRONMENT_NAMES: &[&str] = &["KIMI_API_KEY"];

#[derive(Clone)]
pub struct KimiAuthStore {
store: ApiKeyStore,
}

impl KimiAuthStore {
pub fn new() -> Self {
Self {
store: ApiKeyStore::new_with_sources("kimi", ENVIRONMENT_NAMES, CONFIG_PATHS),
}
}

#[cfg(test)]
pub(super) fn with_store(store: ApiKeyStore) -> Self {
Self { store }
}

pub fn load(&self) -> Result<Option<SecretString>, KimiError> {
self.store.load().map_err(|_| KimiError::CredentialStorage)
}

pub fn has_local_credentials(&self) -> bool {
self.load().is_ok_and(|secret| secret.is_some())
}

pub fn status(&self) -> Result<ApiKeyStatus, KimiError> {
self.store
.status()
.map_err(|_| KimiError::CredentialStorage)
}

pub fn save(&self, value: &str) -> Result<(), KimiError> {
self.store.save(value).map_err(|_| {
if value.trim().is_empty() {
KimiError::MissingKey
} else {
crate::app_warn!("auth:kimi", "system credential store write failed");
KimiError::CredentialStorage
}
})
}

pub fn delete(&self) -> Result<(), KimiError> {
self.store.delete().map_err(|_| {
crate::app_warn!("auth:kimi", "system credential store delete failed");
KimiError::CredentialStorage
})
}
}

impl Default for KimiAuthStore {
fn default() -> Self {
Self::new()
}
}
69 changes: 69 additions & 0 deletions src-tauri/src/providers/kimi/client.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
use std::time::Duration;

use reqwest::{blocking::Client, StatusCode};
use serde_json::Value;

use super::KimiError;

const USAGES_URL: &str = "https://api.kimi.com/coding/v1/usages";

#[derive(Debug)]
pub struct EndpointResponse {
pub status: StatusCode,
pub body: Value,
}

pub struct KimiClient {
client: Client,
url: String,
}

impl KimiClient {
pub fn new() -> Result<Self, KimiError> {
Self::with_endpoint(USAGES_URL, Duration::from_secs(15))
}

fn with_endpoint(url: &str, timeout: Duration) -> Result<Self, KimiError> {
let client = Client::builder()
.connect_timeout(Duration::from_secs(8))
.timeout(timeout)
.user_agent(concat!("OpenQuota/", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|_| KimiError::ConnectionFailed)?;
Ok(Self {
client,
url: url.to_owned(),
})
}

pub fn fetch(&self, api_key: &str) -> Result<EndpointResponse, KimiError> {
let started = std::time::Instant::now();
let response = self
.client
.get(&self.url)
.bearer_auth(api_key)
.header("Accept", "application/json")
.send()
.map_err(|_| {
crate::app_warn!("http", "kimi usages request failed (transport)");
KimiError::ConnectionFailed
})?;
let status = response.status();
crate::app_debug!(
"http",
"kimi usages HTTP {} ({}ms)",
status.as_u16(),
started.elapsed().as_millis()
);
let text = response.text().map_err(|_| KimiError::InvalidResponse)?;
let body = serde_json::from_str(&text).unwrap_or(Value::Null);
Ok(EndpointResponse { status, body })
}
}

#[cfg(test)]
impl KimiClient {
pub fn for_test(url: &str, timeout: Duration) -> Self {
Self::with_endpoint(url, timeout).unwrap()
}
}
Loading