Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
55517f1
fix(model): prevent chat-only abilities from persisting on embedding …
XiaoBuHaly Jan 9, 2026
d83acca
fix(model): normalize embedding invariants across UI and config resol…
XiaoBuHaly Jan 9, 2026
4cf6324
fix(model): harden embedding overrides and prevent chat-only state le…
XiaoBuHaly Jan 11, 2026
7c6fe0a
fix(model): centralize model override parsing and harden invariants
XiaoBuHaly Jan 12, 2026
71f7b65
refactor(model): extract shared model types and harden overrides parsing
XiaoBuHaly Jan 13, 2026
6f7f68b
fix(model): reset abilities and output when model type changes
XiaoBuHaly Jan 14, 2026
4459191
fix(model): restore desktop model selector right-side alignment
XiaoBuHaly Jan 14, 2026
e21f5bd
fix(model): harden overrides parsing and model editing state
XiaoBuHaly Jan 15, 2026
13d040e
fix(model): harden provider modelOverrides migration and override par…
XiaoBuHaly Jan 15, 2026
70974f9
fix(model): harden override parsing and avoid controller leaks
XiaoBuHaly Jan 15, 2026
aeb6dc3
fix(model): normalize overrides and prevent type-switch state leakage
XiaoBuHaly Jan 15, 2026
09ffbd7
fix(model): harden overrides parsing and prevent state leakage
XiaoBuHaly Jan 16, 2026
9f85e71
fix(model): harden model overrides persistence and sanitization
XiaoBuHaly Jan 16, 2026
c8060b8
merge(master): resolve settings_provider conflict
XiaoBuHaly Jan 16, 2026
08932de
fix(model): harden overrides handling and improve diagnostics
XiaoBuHaly Jan 17, 2026
6016c98
fix(model): harden overrides handling and improve diagnostics
XiaoBuHaly Jan 18, 2026
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
83 changes: 83 additions & 0 deletions lib/core/models/model_types.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import 'package:flutter/foundation.dart';

enum ModelType { chat, embedding }

enum Modality { text, image }

enum ModelAbility { tool, reasoning }

@immutable
class ModelInfo {
final String id;
final String displayName;
final ModelType type;
final List<Modality> input;
final List<Modality> output;
final List<ModelAbility> abilities;

static List<Modality> _normalizeModalities(Iterable<Modality> mods) {
final set = <Modality>{...mods};
if (!set.contains(Modality.text)) set.add(Modality.text);
final list = set.toList()..sort((a, b) => a.index.compareTo(b.index));
return List.unmodifiable(list);
}

static List<ModelAbility> _normalizeAbilities(Iterable<ModelAbility> abs) {
final set = <ModelAbility>{...abs};
final list = set.toList()..sort((a, b) => a.index.compareTo(b.index));
return List.unmodifiable(list);
}

ModelInfo({
required this.id,
required this.displayName,
this.type = ModelType.chat,
List<Modality> input = const [Modality.text],
List<Modality> output = const [Modality.text],
List<ModelAbility> abilities = const [],
}) : input = _normalizeModalities(input),
output = _normalizeModalities(output),
abilities = _normalizeAbilities(abilities);

ModelInfo copyWith({
String? id,
String? displayName,
ModelType? type,
List<Modality>? input,
List<Modality>? output,
List<ModelAbility>? abilities,
}) {
return ModelInfo(
id: id ?? this.id,
displayName: displayName ?? this.displayName,
type: type ?? this.type,
input: input ?? this.input,
output: output ?? this.output,
abilities: abilities ?? this.abilities,
);
}

@override
bool operator ==(Object other) {
return identical(this, other) ||
(other is ModelInfo &&
runtimeType == other.runtimeType &&
id == other.id &&
displayName == other.displayName &&
type == other.type &&
listEquals(input, other.input) &&
listEquals(output, other.output) &&
listEquals(abilities, other.abilities));
}

@override
int get hashCode => Object.hash(
id,
displayName,
type,
Object.hashAll(input),
Object.hashAll(output),
Object.hashAll(abilities),
);
}

49 changes: 12 additions & 37 deletions lib/core/providers/model_provider.dart
Original file line number Diff line number Diff line change
@@ -1,49 +1,17 @@
export '../models/model_types.dart';

import 'dart:convert';
import 'dart:io' show HttpException;

import 'package:http/http.dart' as http;

import '../models/model_types.dart';
import 'settings_provider.dart';
import '../services/network/dio_http_client.dart';
import '../services/api_key_manager.dart';
import 'package:Kelivo/secrets/fallback.dart';
import '../services/api/google_service_account_auth.dart';

enum ModelType { chat, embedding }
enum Modality { text, image }
enum ModelAbility { tool, reasoning }

class ModelInfo {
final String id;
final String displayName;
final ModelType type;
final List<Modality> input;
final List<Modality> output;
final List<ModelAbility> abilities;
ModelInfo({
required this.id,
required this.displayName,
this.type = ModelType.chat,
this.input = const [Modality.text],
this.output = const [Modality.text],
this.abilities = const [],
});

ModelInfo copyWith({
String? id,
String? displayName,
ModelType? type,
List<Modality>? input,
List<Modality>? output,
List<ModelAbility>? abilities,
}) => ModelInfo(
id: id ?? this.id,
displayName: displayName ?? this.displayName,
type: type ?? this.type,
input: input ?? this.input,
output: output ?? this.output,
abilities: abilities ?? this.abilities,
);
}

class ModelRegistry {
// Updated model groups to reflect new series
// Vision-capable models (text + image input)
Expand Down Expand Up @@ -86,6 +54,13 @@ class ModelRegistry {
final inMods = <Modality>[...base.input];
final outMods = <Modality>[...base.output];
final ab = <ModelAbility>[...base.abilities];
if (base.type == ModelType.embedding) {
outMods
..clear()
..add(Modality.text);
ab.clear();
return base.copyWith(input: inMods, output: outMods, abilities: ab);
}
// If model id contains 'image', treat it as an image model:
// - Input and output both include image
// - No tool or reasoning abilities
Expand Down
139 changes: 139 additions & 0 deletions lib/core/providers/settings_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,23 @@ enum DesktopTopicPosition { left, right }
// Desktop: send message shortcut
enum DesktopSendShortcut { enter, ctrlEnter }

enum _MigrationResult { noChange, applied, failed }

class SettingsProvider extends ChangeNotifier {
static const String _providersOrderKey = 'providers_order_v1';
static const String _themeModeKey = 'theme_mode_v1';
static const String _providerConfigsKey = 'provider_configs_v1';
static const String _providerConfigsBackupKey = 'provider_configs_backup_v1';
static const String _migrationsVersionKey = 'migrations_version_v1';
static const int _embeddingOverridesMigrationVersion = 3;
static const Set<String> _embeddingTypeStrings = {'embedding', 'embeddings'};
static const Set<String> _embeddingChatOnlyFields = {
'abilities',
'output',
'builtInTools',
'built_in_tools',
'tools',
};
static const String _pinnedModelsKey = 'pinned_models_v1';
static const String _selectedModelKey = 'selected_model_v1';
static const String _titleModelKey = 'title_model_v1';
Expand Down Expand Up @@ -223,6 +236,68 @@ class SettingsProvider extends ChangeNotifier {
_load();
}

Future<_MigrationResult> _migrateEmbeddingModelOverrides(SharedPreferences prefs) async {
Map<String, ProviderConfig>? nextProviderConfigs;
int providersChanged = 0;
int modelsChanged = 0;

for (final entry in _providerConfigs.entries) {
final providerKey = entry.key;
final cfg = entry.value;

Map<String, dynamic>? nextOverrides;

for (final ovEntry in cfg.modelOverrides.entries) {
final modelKey = ovEntry.key;
final rawOv = ovEntry.value;
if (rawOv is! Map) continue;

final normalizedRawOv = rawOv.map((k, v) => MapEntry(k.toString(), v));
final t = (normalizedRawOv['type'] ?? normalizedRawOv['t'] ?? '').toString().trim().toLowerCase();
if (!_embeddingTypeStrings.contains(t)) continue;

// Migration/cleanup (embedding): remove chat-only fields.
// Embeddings may still use explicit input modalities like text/image.
final hasChatOnlyKeys = _embeddingChatOnlyFields.any(normalizedRawOv.containsKey);
if (!hasChatOnlyKeys) continue;

nextOverrides ??= Map<String, dynamic>.from(cfg.modelOverrides);
final m = Map<String, dynamic>.from(normalizedRawOv);
for (final k in _embeddingChatOnlyFields) {
m.remove(k);
}
nextOverrides[modelKey] = m;
modelsChanged++;
}

if (nextOverrides == null) continue;
nextProviderConfigs ??= Map<String, ProviderConfig>.from(_providerConfigs);
nextProviderConfigs[providerKey] = cfg.copyWith(modelOverrides: nextOverrides);
providersChanged++;
}

if (nextProviderConfigs == null) return _MigrationResult.noChange;
try {
final map = nextProviderConfigs.map((k, v) => MapEntry(k, v.toJson()));
final encoded = jsonEncode(map);
final ok = await prefs.setString(_providerConfigsKey, encoded);
if (!ok) return _MigrationResult.failed;
} catch (e, st) {
assert(() {
debugPrint('[SettingsProvider] provider configs migration persist failed: $e');
debugPrint('$st');
return true;
}());
return _MigrationResult.failed;
}
_providerConfigs = nextProviderConfigs;
assert(() {
debugPrint('[SettingsProvider] embedding overrides migration: providers=$providersChanged, models=$modelsChanged');
return true;
}());
return _MigrationResult.applied;
}

Future<void> _load() async {
final prefs = await SharedPreferences.getInstance();
_providersOrder = prefs.getStringList(_providersOrderKey) ?? [];
Expand All @@ -239,12 +314,76 @@ class SettingsProvider extends ChangeNotifier {
}
_themePaletteId = prefs.getString(_themePaletteKey) ?? 'default';
_useDynamicColor = prefs.getBool(_useDynamicColorKey) ?? true;
var providerConfigsLoaded = false;
final cfgStr = prefs.getString(_providerConfigsKey);
if (cfgStr != null && cfgStr.isNotEmpty) {
try {
final raw = jsonDecode(cfgStr) as Map<String, dynamic>;
_providerConfigs = raw.map((k, v) => MapEntry(k, ProviderConfig.fromJson(v as Map<String, dynamic>)));
providerConfigsLoaded = true;
} catch (e, st) {
assert(() {
debugPrint('[SettingsProvider] providerConfigs decode failed: $e');
debugPrint('$st');
return true;
}());
}
}

// Migration/cleanup: embedding models should not keep chat-only fields (abilities/output/builtInTools).
// Embeddings still support explicit input modalities like text/image.
// This fixes previously persisted overrides where type was switched from chat -> embedding.
try {
final migrationVersion = prefs.getInt(_migrationsVersionKey) ?? 0;
if (providerConfigsLoaded && migrationVersion < _embeddingOverridesMigrationVersion) {
try {
FlutterLogger.log('[SettingsProvider] provider modelOverrides migration start', tag: 'Migration');
} catch (_) {}
var backupOk = true;
if (!prefs.containsKey(_providerConfigsBackupKey)) {
final backup = _providerConfigs.map((k, v) => MapEntry(k, v.toJson()));
backupOk = await prefs.setString(_providerConfigsBackupKey, jsonEncode(backup));
assert(() {
debugPrint('[SettingsProvider] provider configs backup saved before migration.');
return true;
}());
if (!backupOk) {
assert(() {
debugPrint('[SettingsProvider] provider configs backup failed; abort migration.');
return true;
}());
}
}
if (backupOk) {
final result = await _migrateEmbeddingModelOverrides(prefs);
// Mark as attempted so we don't keep re-scanning on every startup when no changes are needed.
if (result != _MigrationResult.failed) {
await prefs.setInt(_migrationsVersionKey, _embeddingOverridesMigrationVersion);
}
assert(() {
if (result == _MigrationResult.applied) {
debugPrint('[SettingsProvider] provider modelOverrides migration applied.');
}
return true;
}());
try {
FlutterLogger.log(
'[SettingsProvider] provider modelOverrides migration done (result=$result)',
tag: 'Migration',
);
} catch (_) {}
}
}
} catch (e, st) {
// Debug-only visibility for migration failures (no behavior change in release).
try {
FlutterLogger.log('[SettingsProvider] provider modelOverrides migration failed: $e\n$st', tag: 'Migration');
} catch (_) {}
assert(() {
debugPrint('[SettingsProvider] provider modelOverrides migration failed: $e');
debugPrint('$st');
return true;
}());
}
// load pinned models
final pinned = prefs.getStringList(_pinnedModelsKey) ?? const <String>[];
Expand Down
28 changes: 8 additions & 20 deletions lib/core/services/api/chat_api_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import 'package:Kelivo/secrets/fallback.dart';
import '../../../utils/markdown_media_sanitizer.dart';
import '../../../utils/unicode_sanitizer.dart';
import 'builtin_tools.dart';
import '../model_override_resolver.dart';
import '../logging/flutter_logger.dart';

class ChatApiService {
static const String _aihubmixAppCode = 'ZKRT3588';
Expand Down Expand Up @@ -150,27 +152,13 @@ class ChatApiService {
final upstreamId = _apiModelId(cfg, modelId);
final base = ModelRegistry.infer(ModelInfo(id: upstreamId, displayName: upstreamId));
final ov = _modelOverride(cfg, modelId);
ModelType? type;
final t = (ov['type'] as String?) ?? '';
if (t == 'embedding') type = ModelType.embedding; else if (t == 'chat') type = ModelType.chat;
List<Modality>? input;
if (ov['input'] is List) {
input = [for (final e in (ov['input'] as List)) (e.toString() == 'image' ? Modality.image : Modality.text)];
}
List<Modality>? output;
if (ov['output'] is List) {
output = [for (final e in (ov['output'] as List)) (e.toString() == 'image' ? Modality.image : Modality.text)];
}
List<ModelAbility>? abilities;
if (ov['abilities'] is List) {
abilities = [for (final e in (ov['abilities'] as List)) (e.toString() == 'reasoning' ? ModelAbility.reasoning : ModelAbility.tool)];
if (ov.isEmpty) return base;
try {
return ModelOverrideResolver.applyModelOverride(base, ov);
} catch (e, st) {
FlutterLogger.log('[ModelOverride] applyModelOverride failed: $e\n$st', tag: 'ModelOverride');
return base;
}
return base.copyWith(
type: type ?? base.type,
input: input ?? base.input,
output: output ?? base.output,
abilities: abilities ?? base.abilities,
);
}
static String _mimeFromPath(String path) {
final lower = path.toLowerCase();
Expand Down
Loading