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
21 changes: 8 additions & 13 deletions lib/src/api/ai_chat_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -112,20 +112,15 @@ abstract class BaseAIChatProvider implements AIChatProvider {
throw StateError('Provider has been disposed.');
}
if (!_isInitialized) {
throw StateError(
'Provider is not initialised. Call initialize() first.',
);
throw StateError('Provider is not initialised. Call initialize() first.');
}
}

/// Retries [fn] up to [maxAttempts] times with exponential back-off.
///
/// Only [NetworkException] and [RateLimitException] trigger a retry;
/// all other exceptions propagate immediately without retry.
Future<T> withRetry<T>(
Future<T> Function() fn, {
int maxAttempts = 3,
}) async {
Future<T> withRetry<T>(Future<T> Function() fn, {int maxAttempts = 3}) async {
int attempt = 0;
Duration delay = const Duration(seconds: 1);
while (true) {
Expand Down Expand Up @@ -166,13 +161,13 @@ abstract class BaseAIChatProvider implements AIChatProvider {
AIChatException mapHttpError(int statusCode, String body) {
return switch (statusCode) {
401 || 403 => APIKeyException(
'Authentication failed: $body',
statusCode: statusCode,
),
'Authentication failed: $body',
statusCode: statusCode,
),
429 => RateLimitException(
'Rate limit exceeded: $body',
retryAfter: _parseRetryAfter(body),
),
'Rate limit exceeded: $body',
retryAfter: _parseRetryAfter(body),
),
_ => AIChatException('API error: $body', statusCode: statusCode),
};
}
Expand Down
23 changes: 6 additions & 17 deletions lib/src/api/chat_exceptions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,7 @@
/// - [NetworkException] — transport-level failures
/// - [RateLimitException] — quota / rate-limit (HTTP 429)
class AIChatException implements Exception {
const AIChatException(
this.message, {
this.statusCode,
this.cause,
});
const AIChatException(this.message, {this.statusCode, this.cause});

/// Human-readable description of the error.
final String message;
Expand All @@ -35,11 +31,7 @@ class AIChatException implements Exception {
///
/// Typically maps to HTTP 401 or 403 responses.
class APIKeyException extends AIChatException {
const APIKeyException(
super.message, {
super.statusCode,
super.cause,
});
const APIKeyException(super.message, {super.statusCode, super.cause});

@override
String toString() =>
Expand All @@ -50,11 +42,7 @@ class APIKeyException extends AIChatException {
/// Thrown for transport-level failures such as timeouts, DNS errors,
/// or connection resets.
class NetworkException extends AIChatException {
const NetworkException(
super.message, {
super.statusCode,
super.cause,
});
const NetworkException(super.message, {super.statusCode, super.cause});

@override
String toString() =>
Expand All @@ -79,8 +67,9 @@ class RateLimitException extends AIChatException {

@override
String toString() {
final retry =
retryAfter != null ? ' Retry after ${retryAfter!.inSeconds}s.' : '';
final retry = retryAfter != null
? ' Retry after ${retryAfter!.inSeconds}s.'
: '';
return 'RateLimitException: $message$retry';
}
}
11 changes: 4 additions & 7 deletions lib/src/api/chat_models.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,15 @@ class ChatMessage {

/// Shorthand for a user message.
const ChatMessage.user(String content)
: this(role: ChatRole.user, content: content);
: this(role: ChatRole.user, content: content);

/// Shorthand for an assistant message.
const ChatMessage.assistant(String content)
: this(role: ChatRole.assistant, content: content);
: this(role: ChatRole.assistant, content: content);

/// Shorthand for a system instruction message.
const ChatMessage.system(String content)
: this(role: ChatRole.system, content: content);
: this(role: ChatRole.system, content: content);

/// The role of the message author.
final ChatRole role;
Expand All @@ -38,10 +38,7 @@ class ChatMessage {
///
/// Other providers may need custom mapping (e.g. Gemini uses "model"
/// instead of "assistant").
Map<String, dynamic> toJson() => {
'role': role.name,
'content': content,
};
Map<String, dynamic> toJson() => {'role': role.name, 'content': content};

@override
String toString() => 'ChatMessage(${role.name}: $content)';
Expand Down
48 changes: 24 additions & 24 deletions lib/src/api/claude_chat_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,10 @@ class ClaudeChatProvider extends BaseAIChatProvider {
Map<String, dynamic>? parameters,
}) {
checkInitialized();
return sendChatMessages(
[...?history, ChatMessage.user(message)],
parameters: parameters,
);
return sendChatMessages([
...?history,
ChatMessage.user(message),
], parameters: parameters);
}

@override
Expand All @@ -91,10 +91,10 @@ class ClaudeChatProvider extends BaseAIChatProvider {
Map<String, dynamic>? parameters,
}) {
checkInitialized();
return _doStreamRequest(
[...?history, ChatMessage.user(message)],
parameters,
);
return _doStreamRequest([
...?history,
ChatMessage.user(message),
], parameters);
}

@override
Expand Down Expand Up @@ -163,9 +163,10 @@ class ClaudeChatProvider extends BaseAIChatProvider {
// Claude SSE pairs "event: <type>" lines with "data: {json}" lines.
// We only care about `content_block_delta` events.
String? currentEvent;
await for (final line in response.stream
.transform(utf8.decoder)
.transform(const LineSplitter())) {
await for (final line
in response.stream
.transform(utf8.decoder)
.transform(const LineSplitter())) {
if (line.startsWith('event: ')) {
currentEvent = line.substring(7).trim();
continue;
Expand All @@ -174,8 +175,7 @@ class ClaudeChatProvider extends BaseAIChatProvider {

if (currentEvent == 'content_block_delta') {
try {
final json =
jsonDecode(line.substring(6)) as Map<String, dynamic>;
final json = jsonDecode(line.substring(6)) as Map<String, dynamic>;
final delta = json['delta'] as Map<String, dynamic>?;
if (delta?['type'] == 'text_delta') {
final text = delta!['text'] as String?;
Expand All @@ -197,10 +197,12 @@ class ClaudeChatProvider extends BaseAIChatProvider {
required bool stream,
}) {
// Claude requires system messages as a separate top-level field
final systemMessages =
messages.where((m) => m.role == ChatRole.system).toList();
final conversationMessages =
messages.where((m) => m.role != ChatRole.system).toList();
final systemMessages = messages
.where((m) => m.role == ChatRole.system)
.toList();
final conversationMessages = messages
.where((m) => m.role != ChatRole.system)
.toList();

final body = <String, dynamic>{
'model': (parameters?['model'] as String?) ?? _model,
Expand All @@ -222,18 +224,16 @@ class ClaudeChatProvider extends BaseAIChatProvider {
}

Map<String, String> _headers() => {
'x-api-key': _apiKey,
'anthropic-version': _anthropicVersion,
'Content-Type': 'application/json',
};
'x-api-key': _apiKey,
'anthropic-version': _anthropicVersion,
'Content-Type': 'application/json',
};

ChatResponse _parseResponse(Map<String, dynamic> json) {
final contentBlocks = json['content'] as List;
// Concatenate all text blocks (in practice usually just one)
final text = contentBlocks
.where(
(b) => (b as Map<String, dynamic>)['type'] == 'text',
)
.where((b) => (b as Map<String, dynamic>)['type'] == 'text')
.map((b) => (b as Map<String, dynamic>)['text'] as String)
.join();
final usage = json['usage'] as Map<String, dynamic>?;
Expand Down
51 changes: 25 additions & 26 deletions lib/src/api/gemini_chat_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,10 @@ class GeminiChatProvider extends BaseAIChatProvider {
Map<String, dynamic>? parameters,
}) {
checkInitialized();
return sendChatMessages(
[...?history, ChatMessage.user(message)],
parameters: parameters,
);
return sendChatMessages([
...?history,
ChatMessage.user(message),
], parameters: parameters);
}

@override
Expand All @@ -90,10 +90,10 @@ class GeminiChatProvider extends BaseAIChatProvider {
Map<String, dynamic>? parameters,
}) {
checkInitialized();
return _doStreamRequest(
[...?history, ChatMessage.user(message)],
parameters,
);
return _doStreamRequest([
...?history,
ChatMessage.user(message),
], parameters);
}

@override
Expand Down Expand Up @@ -167,26 +167,24 @@ class GeminiChatProvider extends BaseAIChatProvider {
throw mapHttpError(response.statusCode, errorBody);
}

await for (final line in response.stream
.transform(utf8.decoder)
.transform(const LineSplitter())) {
await for (final line
in response.stream
.transform(utf8.decoder)
.transform(const LineSplitter())) {
if (line.isEmpty || !line.startsWith('data: ')) continue;
try {
final json = jsonDecode(line.substring(6)) as Map<String, dynamic>;
final candidates = json['candidates'] as List?;
if (candidates == null || candidates.isEmpty) continue;
final content = (candidates.first as Map<String, dynamic>)['content']
as Map<String, dynamic>?;
final content =
(candidates.first as Map<String, dynamic>)['content']
as Map<String, dynamic>?;
final parts = content?['parts'] as List?;
if (parts == null || parts.isEmpty) continue;
final text =
(parts.first as Map<String, dynamic>)['text'] as String?;
final text = (parts.first as Map<String, dynamic>)['text'] as String?;
if (text != null && text.isNotEmpty) yield text;
} catch (e) {
dev.log(
'Failed to parse SSE line: $line',
name: 'GeminiChatProvider',
);
dev.log('Failed to parse SSE line: $line', name: 'GeminiChatProvider');
}
}
}
Expand All @@ -196,18 +194,20 @@ class GeminiChatProvider extends BaseAIChatProvider {
Map<String, dynamic>? parameters,
) {
// Separate system messages — Gemini uses a dedicated field
final systemMessages =
messages.where((m) => m.role == ChatRole.system).toList();
final conversationMessages =
messages.where((m) => m.role != ChatRole.system).toList();
final systemMessages = messages
.where((m) => m.role == ChatRole.system)
.toList();
final conversationMessages = messages
.where((m) => m.role != ChatRole.system)
.toList();

final body = <String, dynamic>{
'contents': conversationMessages
.map(
(m) => {
'role': m.role == ChatRole.assistant ? 'model' : 'user',
'parts': [
{'text': m.content}
{'text': m.content},
],
},
)
Expand Down Expand Up @@ -240,8 +240,7 @@ class GeminiChatProvider extends BaseAIChatProvider {
final candidate =
(json['candidates'] as List).first as Map<String, dynamic>;
final content = candidate['content'] as Map<String, dynamic>;
final text =
(content['parts'] as List).first['text'] as String;
final text = (content['parts'] as List).first['text'] as String;
final usage = json['usageMetadata'] as Map<String, dynamic>?;
return ChatResponse(
message: ChatMessage(role: ChatRole.assistant, content: text),
Expand Down
Loading
Loading