Add initial project files and configurations for clawd_code

This commit is contained in:
ImBenji
2026-04-04 05:46:34 +01:00
parent c88a1badc7
commit fa4415553d
14 changed files with 763 additions and 459 deletions
+233
View File
@@ -103,6 +103,218 @@ class OpenRouterClient {
return ResponseParser.parseOpenRouterResponse(response);
}
Future<ApiMessage> createStreamingMessage({
required String model,
required int maxTokens,
required List<Map<String, dynamic>> messages,
String? system,
double? temperature,
List<Map<String, dynamic>>? tools,
String? toolChoice,
void Function(String delta)? onTextDelta,
}) async {
final requestBody = <String, dynamic>{
"model": model,
"max_tokens": maxTokens,
"messages": messages,
"stream": true,
"stream_options": <String, dynamic>{"include_usage": true},
};
if (system != null) {
if (messages.isEmpty || messages.first["role"] != "system") {
requestBody["messages"] = [
{"role": "system", "content": system},
...messages,
];
}
}
if (temperature != null) {
requestBody["temperature"] = temperature;
}
if (tools != null && tools.isNotEmpty) {
requestBody["tools"] = tools;
if (toolChoice != null) {
requestBody["tool_choice"] = toolChoice;
}
}
final url = Uri.parse("$_baseUrl/chat/completions");
final headers = _buildHeaders();
final textBuffer = StringBuffer();
final toolCalls = <int, _StreamingToolCallBuilder>{};
String responseId = "";
String responseModel = model;
String? finishReason;
Map<String, dynamic>? usage;
try {
if (_requestCancelled) {
throw const RequestCancelledException();
}
final request = await _httpClient.openUrl("POST", url);
headers.forEach((key, value) {
request.headers.set(key, value);
});
request.headers.contentType = ContentType.json;
request.write(jsonEncode(requestBody));
final response = await request.close();
if (response.statusCode >= 400) {
final responseBody = await response.transform(utf8.decoder).join();
print(
"OpenRouter API error ${response.statusCode} for /chat/completions: $responseBody",
);
_handleErrorResponse(response.statusCode, responseBody);
}
final responseStream = response
.transform(utf8.decoder)
.transform(const LineSplitter());
await for (final line in responseStream) {
if (_requestCancelled) {
throw const RequestCancelledException();
}
final event = StreamingResponseParser.parseStreamLine(line);
if (StreamingResponseParser.isDone(event)) {
break;
}
if (event == null) {
continue;
}
final id = event["id"];
if (id is String && id.isNotEmpty) {
responseId = id;
}
final streamedModel = event["model"];
if (streamedModel is String && streamedModel.isNotEmpty) {
responseModel = streamedModel;
}
final rawUsage = event["usage"];
if (rawUsage is Map<String, dynamic>) {
usage = rawUsage;
}
final choices = event["choices"];
if (choices is! List || choices.isEmpty) {
continue;
}
final firstChoice = choices.first;
if (firstChoice is! Map<String, dynamic>) {
continue;
}
final rawFinishReason = firstChoice["finish_reason"];
if (rawFinishReason is String && rawFinishReason.isNotEmpty) {
finishReason = rawFinishReason == "tool_calls"
? "tool_use"
: rawFinishReason;
}
final delta = firstChoice["delta"];
if (delta is! Map<String, dynamic>) {
continue;
}
final content = delta["content"];
if (content is String && content.isNotEmpty) {
textBuffer.write(content);
onTextDelta?.call(content);
}
final toolCallDeltas = delta["tool_calls"];
if (toolCallDeltas is! List) {
continue;
}
for (final rawToolCall in toolCallDeltas) {
if (rawToolCall is! Map<String, dynamic>) {
continue;
}
final index = (rawToolCall["index"] as num?)?.toInt() ?? 0;
final builder = toolCalls.putIfAbsent(
index,
() => _StreamingToolCallBuilder(),
);
final toolCallId = rawToolCall["id"];
if (toolCallId is String && toolCallId.isNotEmpty) {
builder.id = toolCallId;
}
final rawType = rawToolCall["type"];
if (rawType is String && rawType.isNotEmpty) {
builder.type = rawType;
}
final function = rawToolCall["function"];
if (function is! Map<String, dynamic>) {
continue;
}
final name = function["name"];
if (name is String && name.isNotEmpty) {
builder.name = name;
}
final arguments = function["arguments"];
if (arguments is String && arguments.isNotEmpty) {
builder.arguments.write(arguments);
}
}
}
final contentBlocks = <Map<String, dynamic>>[];
final text = textBuffer.toString();
if (text.isNotEmpty) {
contentBlocks.add(<String, dynamic>{"type": "text", "text": text});
}
final orderedToolCalls = toolCalls.entries.toList()
..sort((a, b) => a.key.compareTo(b.key));
for (final entry in orderedToolCalls) {
final builder = entry.value;
contentBlocks.add(<String, dynamic>{
"type": "tool_use",
"id": builder.id,
"name": builder.name,
"input": builder.parsedArguments,
});
}
return ApiMessage(
id: responseId,
type: "message",
role: "assistant",
content: contentBlocks,
model: responseModel,
stopReason: finishReason,
usage: usage,
inputTokens: (usage?["prompt_tokens"] as num?)?.toInt(),
outputTokens: (usage?["completion_tokens"] as num?)?.toInt(),
);
} catch (e) {
if (_requestCancelled) {
throw const RequestCancelledException();
}
if (_config.enableLogging) {
_log("[API STREAM ERROR] $e");
}
rethrow;
}
}
// List available models
Future<List<Map<String, dynamic>>> listModels() async {
final response = await _makeRequest(method: "GET", endpoint: "/models");
@@ -225,6 +437,27 @@ class OpenRouterClient {
}
}
class _StreamingToolCallBuilder {
String id = "";
String type = "function";
String name = "";
final StringBuffer arguments = StringBuffer();
Map<String, dynamic> get parsedArguments {
final raw = arguments.toString();
if (raw.isEmpty) {
return <String, dynamic>{};
}
try {
final decoded = jsonDecode(raw);
return decoded is Map<String, dynamic> ? decoded : <String, dynamic>{};
} catch (_) {
return <String, dynamic>{};
}
}
}
class RequestCancelledException implements Exception {
const RequestCancelledException();
+14 -5
View File
@@ -1,6 +1,8 @@
// Response parser for Anthropic Message API responses
// Ported from old_repo/services/api/errors.ts and claude.ts
import "dart:convert";
import "api_types.dart";
// Parse Message API response into ApiMessage model
@@ -145,12 +147,15 @@ class ErrorParser {
class StreamingResponseParser {
// parse a streamed event from newline-delimited JSON
static Map<String, dynamic>? parseStreamLine(String line) {
if (line.trim().isEmpty) return null;
final trimmed = line.trim();
if (trimmed.isEmpty || trimmed.startsWith(":")) return null;
try {
// handle SSE format (data: {...})
final data = line.startsWith("data: ") ? line.substring(6) : line;
// simple JSON parsing - in production would use json.decode
if (data.trim() == "[DONE]") {
return <String, dynamic>{"type": "done"};
}
return _parseJson(data);
} catch (_) {
return null;
@@ -158,9 +163,8 @@ class StreamingResponseParser {
}
static Map<String, dynamic>? _parseJson(String jsonStr) {
// stubbed - would use dart:convert.jsonDecode in real impl
// for now just return null to indicate parsing would happen
return null;
final decoded = jsonDecode(jsonStr);
return decoded is Map<String, dynamic> ? decoded : null;
}
// check if streamed event is a message delta (partial response)
@@ -177,6 +181,11 @@ class StreamingResponseParser {
return type == "message_stop";
}
static bool isDone(Map<String, dynamic>? event) {
if (event == null) return false;
return event["type"] == "done";
}
// extract partial text from delta event
static String? extractDeltaText(Map<String, dynamic>? event) {
if (event == null) return null;
+14 -1
View File
@@ -13,11 +13,13 @@ class ToolLoopResult {
required this.apiMessages,
required this.responseText,
required this.response,
required this.finalResponseWasStreamed,
});
final List<Map<String, dynamic>> apiMessages;
final String responseText;
final ApiMessage response;
final bool finalResponseWasStreamed;
}
class ToolLoopException implements Exception {
@@ -48,6 +50,8 @@ class ToolLoopService {
String? workingDirectory,
void Function(String toolName, Map<String, dynamic> input)? onToolCall,
void Function(String toolName, String result)? onToolResult,
void Function(String delta)? onAssistantTextDelta,
void Function()? onAssistantMessageComplete,
}) async {
final updatedMessages = List<Map<String, dynamic>>.from(apiMessages)
..add(<String, dynamic>{"role": "user", "content": userText});
@@ -56,14 +60,22 @@ class ToolLoopService {
try {
while (true) {
lastResponse = await client.createMessage(
bool streamedTextThisIteration = false;
lastResponse = await client.createStreamingMessage(
model: model,
maxTokens: 4096,
messages: updatedMessages,
system: _buildSystemPrompt(workingDirectory),
tools: _buildToolDefinitions(),
toolChoice: "auto",
onTextDelta: (delta) {
streamedTextThisIteration = true;
onAssistantTextDelta?.call(delta);
},
);
if (streamedTextThisIteration) {
onAssistantMessageComplete?.call();
}
updatedMessages.add(_assistantMessageForApi(lastResponse));
@@ -78,6 +90,7 @@ class ToolLoopService {
? _buildEmptyAssistantFallback(lastResponse)
: responseText,
response: lastResponse,
finalResponseWasStreamed: streamedTextThisIteration,
);
}
+15
View File
@@ -30,6 +30,21 @@ class ConversationHistory {
_session!.updated = DateTime.now().toUtc();
}
void appendToLastMessage(String text) {
if (_session == null || _session!.messages.isEmpty || text.isEmpty) {
return;
}
final lastMessage = _session!.messages.last;
_session!.messages[_session!.messages.length - 1] = Message(
role: lastMessage.role,
content: "${lastMessage.content}$text",
timestamp: lastMessage.timestamp,
tokens: lastMessage.tokens,
);
_session!.updated = DateTime.now().toUtc();
}
void removeLastMessage() {
if (_session == null || _session!.messages.isEmpty) {
return;