Skip to content
Draft
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
7 changes: 7 additions & 0 deletions lib/core/models/assistant.dart
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class Assistant {
final int?
thinkingBudget; // null = use global/default; 0=off; >0 tokens budget
final int? maxTokens; // null = unlimited
final String? verbosity; // null = default (medium); "low", "medium", "high"
final String systemPrompt;
final String messageTemplate; // e.g. "{{ message }}"
final List<String> mcpServerIds; // bound MCP server IDs
Expand Down Expand Up @@ -63,6 +64,7 @@ class Assistant {
this.streamOutput = true,
this.thinkingBudget,
this.maxTokens,
this.verbosity,
this.systemPrompt = '',
this.messageTemplate = '{{ message }}',
this.mcpServerIds = const <String>[],
Expand Down Expand Up @@ -92,6 +94,7 @@ class Assistant {
bool? streamOutput,
int? thinkingBudget,
int? maxTokens,
String? verbosity,
String? systemPrompt,
String? messageTemplate,
List<String>? mcpServerIds,
Expand All @@ -110,6 +113,7 @@ class Assistant {
bool clearTopP = false,
bool clearThinkingBudget = false,
bool clearMaxTokens = false,
bool clearVerbosity = false,
bool clearBackground = false,
}) {
return Assistant(
Expand All @@ -131,6 +135,7 @@ class Assistant {
? null
: (thinkingBudget ?? this.thinkingBudget),
maxTokens: clearMaxTokens ? null : (maxTokens ?? this.maxTokens),
verbosity: clearVerbosity ? null : (verbosity ?? this.verbosity),
systemPrompt: systemPrompt ?? this.systemPrompt,
messageTemplate: messageTemplate ?? this.messageTemplate,
mcpServerIds: mcpServerIds ?? this.mcpServerIds,
Expand Down Expand Up @@ -163,6 +168,7 @@ class Assistant {
'streamOutput': streamOutput,
'thinkingBudget': thinkingBudget,
'maxTokens': maxTokens,
'verbosity': verbosity,
'systemPrompt': systemPrompt,
'messageTemplate': messageTemplate,
'mcpServerIds': mcpServerIds,
Expand Down Expand Up @@ -192,6 +198,7 @@ class Assistant {
streamOutput: json['streamOutput'] as bool? ?? true,
thinkingBudget: (json['thinkingBudget'] as num?)?.toInt(),
maxTokens: (json['maxTokens'] as num?)?.toInt(),
verbosity: json['verbosity'] as String?,
systemPrompt: (json['systemPrompt'] as String?) ?? '',
messageTemplate: (json['messageTemplate'] as String?) ?? '{{ message }}',
mcpServerIds:
Expand Down
18 changes: 18 additions & 0 deletions lib/core/providers/settings_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ class SettingsProvider extends ChangeNotifier {
static const String _themePaletteKey = 'theme_palette_v1';
static const String _useDynamicColorKey = 'use_dynamic_color_v1';
static const String _thinkingBudgetKey = 'thinking_budget_v1';
static const String _verbosityKey = 'verbosity_v1';
static const String _displayShowUserAvatarKey = 'display_show_user_avatar_v1';
static const String _displayShowModelIconKey = 'display_show_model_icon_v1';
static const String _displayShowModelNameTimestampKey =
Expand Down Expand Up @@ -714,6 +715,8 @@ class SettingsProvider extends ChangeNotifier {
: lmp;
// load thinking budget (reasoning strength)
_thinkingBudget = prefs.getInt(_thinkingBudgetKey);
// load verbosity (GPT-5 family)
_verbosity = prefs.getString(_verbosityKey);

// display settings
_showUserAvatar = prefs.getBool(_displayShowUserAvatarKey) ?? true;
Expand Down Expand Up @@ -2592,6 +2595,20 @@ DO NOT GIVE ANSWERS OR DO HOMEWORK FOR THE USER. If the user asks a math or logi
}
}

// Verbosity (GPT-5 family): null = default (medium); "low", "medium", "high"
String? _verbosity;
String? get verbosity => _verbosity;
Future<void> setVerbosity(String? value) async {
_verbosity = value;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
if (value == null) {
await prefs.remove(_verbosityKey);
} else {
await prefs.setString(_verbosityKey, value);
}
}

// Display settings: user avatar and model icon visibility
bool _showUserAvatar = true;
bool get showUserAvatar => _showUserAvatar;
Expand Down Expand Up @@ -3227,6 +3244,7 @@ DO NOT GIVE ANSWERS OR DO HOMEWORK FOR THE USER. If the user asks a math or logi
copy._ocrPrompt = _ocrPrompt;
copy._ocrEnabled = _ocrEnabled;
copy._thinkingBudget = _thinkingBudget;
copy._verbosity = _verbosity;
copy._showUserAvatar = _showUserAvatar;
copy._showModelIcon = _showModelIcon;
copy._showModelNameTimestamp = _showModelNameTimestamp;
Expand Down
3 changes: 3 additions & 0 deletions lib/core/services/api/chat_api_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,7 @@ class ChatApiService {
Map<String, dynamic>? extraBody,
bool stream = true,
String? requestId,
String? verbosity,
}) async* {
final kind = ProviderConfig.classify(
config.id,
Expand Down Expand Up @@ -390,6 +391,7 @@ class ChatApiService {
extraHeaders: extraHeaders,
extraBody: extraBody,
stream: stream,
verbosity: verbosity,
);
} else {
yield* _sendOpenAIChatCompletionsStream(
Expand All @@ -407,6 +409,7 @@ class ChatApiService {
extraHeaders: extraHeaders,
extraBody: extraBody,
stream: stream,
verbosity: verbosity,
);
}
} else if (kind == ProviderKind.claude) {
Expand Down
2 changes: 2 additions & 0 deletions lib/core/services/api/providers/openai_chat_completions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ Stream<ChatStreamChunk> _sendOpenAIChatCompletionsStream(
Map<String, String>? extraHeaders,
Map<String, dynamic>? extraBody,
bool stream = true,
String? verbosity,
}) {
final cfg = config.copyWith(useResponseApi: false);
return _sendOpenAIStream(
Expand All @@ -99,5 +100,6 @@ Stream<ChatStreamChunk> _sendOpenAIChatCompletionsStream(
extraHeaders: extraHeaders,
extraBody: extraBody,
stream: stream,
verbosity: verbosity,
);
}
115 changes: 100 additions & 15 deletions lib/core/services/api/providers/openai_common.dart
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ Stream<ChatStreamChunk> _sendOpenAIStream(
Map<String, String>? extraHeaders,
Map<String, dynamic>? extraBody,
bool stream = true,
String? verbosity,
}) async* {
final upstreamModelId = _apiModelId(config, modelId);
final base = config.baseUrl.endsWith('/')
Expand All @@ -98,6 +99,19 @@ Stream<ChatStreamChunk> _sendOpenAIStream(
final host = Uri.tryParse(config.baseUrl)?.host.toLowerCase() ?? '';
final modelLower = upstreamModelId.toLowerCase();
final bool isAzureOpenAI = host.contains('openai.azure.com');
// Direct OpenAI API supports previous_response_id for Responses API
final bool isOpenAIHost =
host.contains('openai.com') && !isAzureOpenAI;
// Keep `phase` narrowly scoped for now:
// - OpenAI host only (compat providers may reject/ignore this field)
// - gpt-5.4 family and gpt-5.3-codex (documented/validated targets)
final bool usePhase =
isOpenAIHost &&
config.useResponseApi == true &&
RegExp(r'gpt-5\.(?:4|3-codex)(?:$|[-.])', caseSensitive: false)
.hasMatch(upstreamModelId);
final bool hasValidVerbosity =
verbosity == 'low' || verbosity == 'medium' || verbosity == 'high';
final bool isMimoHost = host.contains('xiaomimimo');
final bool isMimoModel =
modelLower.startsWith('mimo-') || modelLower.contains('/mimo-');
Expand Down Expand Up @@ -243,9 +257,11 @@ Stream<ChatStreamChunk> _sendOpenAIStream(
}

final isAssistant = roleRaw == 'assistant';
// Track whether this assistant message precedes tool calls (for phase)
final bool hasToolCalls = isAssistant && m['tool_calls'] is List;

// Handle assistant messages with tool_calls - convert to function_call format
if (isAssistant && m['tool_calls'] is List) {
if (hasToolCalls) {
final toolCalls = m['tool_calls'] as List;
for (final tc in toolCalls) {
if (tc is! Map) continue;
Expand Down Expand Up @@ -350,27 +366,39 @@ Stream<ChatStreamChunk> _sendOpenAIStream(
}
// Use proper message object format for assistant messages
if (isAssistant) {
input.add({
final msg = <String, dynamic>{
'type': 'message',
'role': 'assistant',
'status': 'completed',
'content': parts,
});
};
// GPT-5.4: annotate assistant messages with phase to prevent
// early stopping in long tool-calling chains.
if (usePhase) {
msg['phase'] =
hasToolCalls ? 'commentary' : 'final_answer';
}
input.add(msg);
} else {
input.add({'role': roleRaw, 'content': parts});
}
} else {
// No images
if (isAssistant) {
// Use proper message object format for assistant messages
input.add({
final msg = <String, dynamic>{
'type': 'message',
'role': 'assistant',
'status': 'completed',
'content': [
{'type': 'output_text', 'text': raw},
],
});
};
if (usePhase) {
msg['phase'] =
hasToolCalls ? 'commentary' : 'final_answer';
}
input.add(msg);
} else {
input.add({'role': roleRaw, 'content': raw});
}
Expand All @@ -391,6 +419,11 @@ Stream<ChatStreamChunk> _sendOpenAIStream(
'summary': 'auto',
if (effort != 'auto') 'effort': effort,
},
// GPT-5 family: verbosity (OpenAI Responses API nests under 'text')
if (hasValidVerbosity &&
isOpenAIHost &&
isOpenAIGpt5FamilyModel(upstreamModelId))
'text': {'verbosity': verbosity},
};
// Append include parameter if we opted into sources via overrides
try {
Expand Down Expand Up @@ -558,6 +591,11 @@ Stream<ChatStreamChunk> _sendOpenAIStream(
if (tools != null && tools.isNotEmpty)
'tools': _cleanToolsForCompatibility(tools),
if (tools != null && tools.isNotEmpty) 'tool_choice': 'auto',
// GPT-5 family: verbosity (OpenAI Chat Completions uses top-level key)
if (hasValidVerbosity &&
isOpenAIHost &&
isOpenAIGpt5FamilyModel(upstreamModelId))
'verbosity': verbosity,
};
_setMaxTokens(body);
}
Expand Down Expand Up @@ -1014,6 +1052,8 @@ Stream<ChatStreamChunk> _sendOpenAIStream(
<int, Map<String, String>>{}; // index -> {call_id,name,args}
List<Map<String, dynamic>> lastResponseOutputItems =
const <Map<String, dynamic>>[];
// Track the latest Responses API response ID for previous_response_id chaining
String? lastResponseId;
String? finishReason;

await for (final chunk in sse) {
Expand Down Expand Up @@ -1137,6 +1177,10 @@ Stream<ChatStreamChunk> _sendOpenAIStream(
if (tools != null && tools.isNotEmpty)
'tools': _cleanToolsForCompatibility(tools),
if (tools != null && tools.isNotEmpty) 'tool_choice': 'auto',
if (hasValidVerbosity &&
isOpenAIHost &&
isOpenAIGpt5FamilyModel(upstreamModelId))
'verbosity': verbosity,
};
_setMaxTokens(body2);

Expand Down Expand Up @@ -1675,6 +1719,11 @@ Stream<ChatStreamChunk> _sendOpenAIStream(
entry['args'] = (entry['args'] ?? '') + argsDelta;
}
} else if (type == 'response.completed') {
// Capture response ID for previous_response_id chaining
try {
final rid = (json['response']?['id'] ?? '').toString();
if (rid.isNotEmpty) lastResponseId = rid;
} catch (_) {}
final u = json['response']?['usage'];
if (u != null) {
final inTok = (u['input_tokens'] ?? 0) as int;
Expand Down Expand Up @@ -1865,19 +1914,33 @@ Stream<ChatStreamChunk> _sendOpenAIStream(
);
}

// Build follow-up Responses request input
List<Map<String, dynamic>> currentInput = <Map<String, dynamic>>[
...responsesInitialInput,
];
if (lastResponseOutputItems.isNotEmpty)
currentInput.addAll(lastResponseOutputItems);
currentInput.addAll(followUpOutputs);
// Build follow-up Responses request input.
// When talking to OpenAI directly, use previous_response_id to
// preserve reasoning items and avoid early stopping (GPT-5.4).
// Keep this OpenAI-host-only for now; some OpenAI-style providers
// do not reliably support this Responses API field.
// Only the new function_call_output items are needed as input.
final bool usePrevResponseId =
isOpenAIHost && lastResponseId != null;
List<Map<String, dynamic>> currentInput;
if (usePrevResponseId) {
currentInput = <Map<String, dynamic>>[...followUpOutputs];
} else {
currentInput = <Map<String, dynamic>>[
...responsesInitialInput,
];
if (lastResponseOutputItems.isNotEmpty)
currentInput.addAll(lastResponseOutputItems);
currentInput.addAll(followUpOutputs);
}

// Iteratively request until no more tool calls
for (int round = 0; round < 3; round++) {
final body2 = <String, dynamic>{
'model': upstreamModelId,
'input': currentInput,
if (isOpenAIHost && lastResponseId != null)
'previous_response_id': lastResponseId,
'stream': true,
if (responsesToolsSpec.isNotEmpty)
'tools': responsesToolsSpec,
Expand All @@ -1892,6 +1955,10 @@ Stream<ChatStreamChunk> _sendOpenAIStream(
'summary': 'auto',
if (effort != 'auto') 'effort': effort,
},
if (hasValidVerbosity &&
isOpenAIHost &&
isOpenAIGpt5FamilyModel(upstreamModelId))
'text': {'verbosity': verbosity},
if (responsesIncludeParam != null)
'include': responsesIncludeParam,
};
Expand Down Expand Up @@ -2010,6 +2077,11 @@ Stream<ChatStreamChunk> _sendOpenAIStream(
}
} else if (o is Map &&
(o['type'] ?? '') == 'response.completed') {
// Capture response ID for next round's previous_response_id
try {
final rid = (o['response']?['id'] ?? '').toString();
if (rid.isNotEmpty) lastResponseId = rid;
} catch (_) {}
// usage
final u2 = o['response']?['usage'];
if (u2 != null) {
Expand Down Expand Up @@ -2121,9 +2193,14 @@ Stream<ChatStreamChunk> _sendOpenAIStream(
toolResults: resultsInfo2,
);
}
// Extend current input with this round's model output and our outputs
if (outItems2.isNotEmpty) currentInput.addAll(outItems2);
currentInput.addAll(followUpOutputs2);
// Extend current input with this round's model output and our outputs.
// When using previous_response_id, only send new tool outputs.
if (isOpenAIHost && lastResponseId != null) {
currentInput = <Map<String, dynamic>>[...followUpOutputs2];
} else {
if (outItems2.isNotEmpty) currentInput.addAll(outItems2);
currentInput.addAll(followUpOutputs2);
}
}

// Safety
Expand Down Expand Up @@ -2533,6 +2610,10 @@ Stream<ChatStreamChunk> _sendOpenAIStream(
if (tools != null && tools.isNotEmpty)
'tools': _cleanToolsForCompatibility(tools),
if (tools != null && tools.isNotEmpty) 'tool_choice': 'auto',
if (hasValidVerbosity &&
isOpenAIHost &&
isOpenAIGpt5FamilyModel(upstreamModelId))
'verbosity': verbosity,
};
_setMaxTokens(body2);
final off = _isOff(thinkingBudget);
Expand Down Expand Up @@ -3109,6 +3190,10 @@ Stream<ChatStreamChunk> _sendOpenAIStream(
if (tools != null && tools.isNotEmpty)
'tools': _cleanToolsForCompatibility(tools),
if (tools != null && tools.isNotEmpty) 'tool_choice': 'auto',
if (hasValidVerbosity &&
isOpenAIHost &&
isOpenAIGpt5FamilyModel(upstreamModelId))
'verbosity': verbosity,
};
_setMaxTokens(body2);
final off = _isOff(thinkingBudget);
Expand Down
Loading