feat: v0.3.0 - fix provider data loss, oauth corruption & switch UX

Bug fixes:
- Fix config.toml wiped to single provider on switch (now writes all providers)
- Fix managed:kimi-code oauth key corruption (oauth/kimi-code was rewritten
  to oauth/managed-kimi-code, breaking the official subscription)
- Fix default_model not switching due to React 18 deferred state update
  (updateConfig now sets configRef synchronously before setConfig)
- Strip KimiSwitch-private default_model from config.toml provider output

Architecture:
- config.toml is now the authoritative source for Kimi Code provider data
- SQLite stores only metadata (notes, official URLs, remembered models)
- Load reconciles config.toml with SQLite for external /provider changes
- Activate is a no-op for KimiCode (save already writes config.toml)

Features:
- Selected provider moves to top of list on switch
- Add models-dev context size lookup
- Add tauri-plugin-single-instance

Tests:
- 3 new regression tests: oauth key round-trip, all-providers write,
  private default_model strip
This commit is contained in:
KimiSwitch Dev committed 2026-07-27 22:36:42 +08:00
1 parent 5806fac056
commit 139af18826
15 files changed
+2398 -97

No files matched your search

+22 -13
View File
@@ -6,6 +6,7 @@ import { ProviderList } from "./components/ProviderList";
import { ProviderEdit } from "./components/ProviderEdit";
import { useTranslation } from "./i18n";
import { getDefaultMaxContextSize } from "./lib/model-defaults";
import { getModelRef } from "./lib/models-dev";
import type { Agent, Model, Provider } from "./types";
const AGENT_STORAGE_KEY = "kimi-switch-agent";
@@ -242,26 +243,31 @@ export default function App() {
}
}
// Only the selected provider is active. All other providers (including
// Kimi native/managed and custom) are deactivated so the target agent
// follows Kimi Switch's choice exactly.
// Rebuild providers with the selected one moved to the front so it is
// visually prioritised. All others are deactivated.
const providers: Record<string, Provider> = {};
for (const [key, p] of Object.entries(cfg.providers)) {
providers[key] = { ...p, active: key === name };
}
providers[name] = { ...target, active: true };
// Remember the active provider's current default model (if it belongs to
// that provider) so switching back later restores the user's choice.
if (activeProviderName && cfg.default_model) {
const activeProvider = providers[activeProviderName];
if (activeProvider && cfg.models[cfg.default_model]?.provider === activeProviderName) {
// Remember the old active provider's current default model (if it
// belongs to that provider) so switching back later restores the user's
// choice.
if (activeProviderName && activeProviderName !== name && cfg.default_model) {
const oldActive = cfg.providers[activeProviderName];
if (oldActive && cfg.models[cfg.default_model]?.provider === activeProviderName) {
providers[activeProviderName] = setProviderDefaultModel(
activeProvider,
{ ...oldActive, active: false },
cfg.default_model
);
}
}
// Add remaining providers (skip selected and old-active if already added).
for (const [key, p] of Object.entries(cfg.providers)) {
if (key === name) continue;
if (providers[key]) continue;
providers[key] = { ...p, active: false };
}
// Prefer the target provider's remembered default model; fall back to
// its first model when there is no saved preference or it is invalid.
let default_model = getProviderDefaultModel(target);
@@ -315,7 +321,10 @@ export default function App() {
...m,
alias,
provider: provider.name,
max_context_size: m.max_context_size ?? getDefaultMaxContextSize(m.model),
max_context_size:
m.max_context_size ??
getModelRef(m.model)?.context ??
getDefaultMaxContextSize(m.model),
};
}
+13 -3
View File
@@ -3,6 +3,7 @@ import { invoke } from "@tauri-apps/api/core";
import { useTranslation } from "../i18n";
import type { TranslationKey } from "../i18n/zh";
import { getDefaultMaxContextSize } from "../lib/model-defaults";
import { capabilitiesFromRef, getModelRef } from "../lib/models-dev";
import { AgentSettingsPanel } from "./AgentSettingsPanel";
import type { Agent, DiscoveredModel, Model, Provider, ProviderType } from "../types";
@@ -400,15 +401,24 @@ function ModelMapping({
for (const dm of discovered) {
if (!selected.has(dm.id)) continue;
const alias = dm.id.replace(/[^a-zA-Z0-9_-]/g, "-");
const max_context_size = dm.max_context_size ?? getDefaultMaxContextSize(dm.id);
const ref = getModelRef(dm.id);
// Priority: API-provided value > models.dev reference > regex fallback
const max_context_size =
dm.max_context_size ?? ref?.context ?? getDefaultMaxContextSize(dm.id);
toAdd.push({
alias,
provider: provider.name,
model: dm.id,
max_context_size,
display_name: dm.display_name,
display_name: dm.display_name ?? ref?.name ?? null,
supports_1m: max_context_size >= 1_000_000,
capabilities: fetchThinking ? ["thinking"] : [],
// Reference data wins when available; otherwise keep the old
// fetchThinking-checkbox behavior as the fallback.
capabilities: ref
? capabilitiesFromRef(ref)
: fetchThinking
? ["thinking"]
: [],
});
}
onBulkAdd(toAdd);
+9 -4
View File
@@ -48,11 +48,16 @@ export function useConfig(agent: Agent): UseConfigReturn {
}, [refresh]);
const updateConfig = useCallback((updater: (config: Config) => Config) => {
setConfig((prev) => {
const next = prev ? updater(prev) : prev;
// Compute next state synchronously from the ref (always current) and set
// the ref BEFORE setConfig. Previously the ref was updated inside the
// setConfig updater, which React 18 defers — so save() reading the ref
// immediately after would get a stale value.
const prev = configRef.current;
if (prev) {
const next = updater(prev);
configRef.current = next;
return next;
});
setConfig(next);
}
setDirty(true);
}, []);
File diff suppressed because it is too large. Load diff
+57
View File
@@ -0,0 +1,57 @@
import snapshot from "./models-dev.json";
/**
* Compact per-model reference data from models.dev
* (see scripts/fetch-models-dev.mjs).
*/
export interface ModelRef {
name?: string;
context?: number;
reasoning?: boolean;
tool_call?: boolean;
structured_output?: boolean;
image?: boolean;
video?: boolean;
}
const data: Record<string, ModelRef> = snapshot;
// models.dev keys are "<lab>/<model>" and may be mixed-case (e.g.
// "minimax/MiniMax-M3"), while provider APIs return bare model ids
// (e.g. "kimi-k2.5"). Index lowercase for tolerant matching.
const byLowerKey: Record<string, ModelRef> = {};
for (const [key, value] of Object.entries(data)) {
byLowerKey[key.toLowerCase()] = value;
}
/**
* Look up reference data for a provider model id.
* Matches exact key first, then any "<lab>/<id>" suffix.
* Returns undefined when nothing matches — callers fall back to
* getDefaultMaxContextSize / manual capabilities.
*/
export function getModelRef(modelId: string): ModelRef | undefined {
const id = modelId.trim().toLowerCase();
if (!id) return undefined;
const exact = byLowerKey[id];
if (exact) return exact;
const suffix = `/${id}`;
for (const [key, value] of Object.entries(byLowerKey)) {
if (key.endsWith(suffix)) return value;
}
return undefined;
}
/**
* Map reference data to Kimi Code capability flags.
* Multimodal (image_in/video_in) comes from models.dev input modalities;
* always_thinking cannot be derived and stays manual.
*/
export function capabilitiesFromRef(ref: ModelRef): string[] {
const caps: string[] = [];
if (ref.reasoning) caps.push("thinking");
if (ref.tool_call) caps.push("tool_use");
if (ref.image) caps.push("image_in");
if (ref.video) caps.push("video_in");
return caps;
}