1package loop
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "os"
8 "strconv"
9 "strings"
10 "sync"
11 "time"
12
13 "shelley.exe.dev/llm"
14)
15
16// PredictableService is an LLM service that returns predictable responses for testing.
17//
18// To add new test patterns, update the Do() method directly by adding cases to the switch
19// statement or new prefix checks. Do not extend or wrap this service - modify it in place.
20// Available patterns include:
21// - "echo: <text>" - echoes the text back
22// - "bash: <command>" - triggers bash tool with command
23// - "think: <thoughts>" - triggers think tool
24// - "subagent: <slug> <prompt>" - triggers subagent tool
25// - "delay: <seconds>" - delays response by specified seconds
26// - See Do() method for complete list of supported patterns
27type PredictableService struct {
28 // TokenContextWindow size
29 tokenContextWindow int
30 mu sync.Mutex
31 // Recent requests for testing inspection
32 recentRequests []*llm.Request
33 responseDelay time.Duration
34}
35
36// NewPredictableService creates a new predictable LLM service
37func NewPredictableService() *PredictableService {
38 svc := &PredictableService{
39 tokenContextWindow: 200000,
40 }
41
42 if delayEnv := os.Getenv("PREDICTABLE_DELAY_MS"); delayEnv != "" {
43 if ms, err := strconv.Atoi(delayEnv); err == nil && ms > 0 {
44 svc.responseDelay = time.Duration(ms) * time.Millisecond
45 }
46 }
47
48 return svc
49}
50
51// TokenContextWindow returns the maximum token context window size
52func (s *PredictableService) TokenContextWindow() int {
53 return s.tokenContextWindow
54}
55
56// MaxImageDimension returns the maximum allowed image dimension.
57func (s *PredictableService) MaxImageDimension() int {
58 return 2000
59}
60
61// Do processes a request and returns a predictable response based on the input text
62func (s *PredictableService) Do(ctx context.Context, req *llm.Request) (*llm.Response, error) {
63 // Store request for testing inspection
64 s.mu.Lock()
65 delay := s.responseDelay
66 s.recentRequests = append(s.recentRequests, req)
67 // Keep only last 10 requests
68 if len(s.recentRequests) > 10 {
69 s.recentRequests = s.recentRequests[len(s.recentRequests)-10:]
70 }
71 s.mu.Unlock()
72
73 if delay > 0 {
74 select {
75 case <-time.After(delay):
76 case <-ctx.Done():
77 return nil, ctx.Err()
78 }
79 }
80
81 // Calculate input token count based on the request content
82 inputTokens := s.countRequestTokens(req)
83
84 // Extract the text content from the last user message
85 var inputText string
86 if len(req.Messages) > 0 {
87 lastMessage := req.Messages[len(req.Messages)-1]
88 if lastMessage.Role == llm.MessageRoleUser {
89 for _, content := range lastMessage.Content {
90 if content.Type == llm.ContentTypeText {
91 inputText = strings.TrimSpace(content.Text)
92 break
93 }
94 }
95 }
96 }
97
98 // Handle input using case statements
99 switch inputText {
100 case "hello":
101 return s.makeResponse("Well, hi there!", inputTokens), nil
102
103 case "Hello":
104 return s.makeResponse("Hello! I'm Shelley, your AI assistant. How can I help you today?", inputTokens), nil
105
106 case "Create an example":
107 return s.makeThinkToolResponse("I'll create a simple example for you.", inputTokens), nil
108
109 case "screenshot":
110 // Trigger a screenshot of the current page
111 return s.makeScreenshotToolResponse("", inputTokens), nil
112
113 case "tool smorgasbord":
114 // Return a response with all tool types for testing
115 return s.makeToolSmorgasbordResponse(inputTokens), nil
116
117 case "echo: foo":
118 return s.makeResponse("foo", inputTokens), nil
119
120 case "patch fail":
121 // Trigger a patch that will fail (file doesn't exist)
122 return s.makePatchToolResponse("/nonexistent/file/that/does/not/exist.txt", inputTokens), nil
123
124 case "patch success":
125 // Trigger a patch that will succeed (using overwrite, which creates the file)
126 return s.makePatchToolResponseOverwrite("/tmp/test-patch-success.txt", inputTokens), nil
127
128 case "patch bad json":
129 // Trigger a patch with malformed JSON (simulates Anthropic sending invalid JSON)
130 return s.makeMalformedPatchToolResponse(inputTokens), nil
131
132 case "maxTokens":
133 // Simulate a max_tokens truncation
134 return s.makeMaxTokensResponse("This is a truncated response that was cut off mid-sentence because the output token limit was", inputTokens), nil
135
136 default:
137 // Handle pattern-based inputs
138 if strings.HasPrefix(inputText, "echo: ") {
139 text := strings.TrimPrefix(inputText, "echo: ")
140 return s.makeResponse(text, inputTokens), nil
141 }
142
143 if strings.HasPrefix(inputText, "bash: ") {
144 cmd := strings.TrimPrefix(inputText, "bash: ")
145 return s.makeBashToolResponse(cmd, inputTokens), nil
146 }
147
148 if strings.HasPrefix(inputText, "think: ") {
149 thoughts := strings.TrimPrefix(inputText, "think: ")
150 return s.makeThinkToolResponse(thoughts, inputTokens), nil
151 }
152
153 if strings.HasPrefix(inputText, "patch: ") {
154 filePath := strings.TrimPrefix(inputText, "patch: ")
155 return s.makePatchToolResponse(filePath, inputTokens), nil
156 }
157
158 if strings.HasPrefix(inputText, "error: ") {
159 errorMsg := strings.TrimPrefix(inputText, "error: ")
160 return nil, fmt.Errorf("predictable error: %s", errorMsg)
161 }
162
163 if strings.HasPrefix(inputText, "screenshot: ") {
164 selector := strings.TrimSpace(strings.TrimPrefix(inputText, "screenshot: "))
165 return s.makeScreenshotToolResponse(selector, inputTokens), nil
166 }
167
168 if strings.HasPrefix(inputText, "subagent: ") {
169 // Format: "subagent: <slug> <prompt>"
170 parts := strings.SplitN(strings.TrimPrefix(inputText, "subagent: "), " ", 2)
171 slug := parts[0]
172 prompt := "do the task"
173 if len(parts) > 1 {
174 prompt = parts[1]
175 }
176 return s.makeSubagentToolResponse(slug, prompt, inputTokens), nil
177 }
178
179 if strings.HasPrefix(inputText, "delay: ") {
180 delayStr := strings.TrimPrefix(inputText, "delay: ")
181 delaySeconds, err := strconv.ParseFloat(delayStr, 64)
182 if err == nil && delaySeconds > 0 {
183 delayDuration := time.Duration(delaySeconds * float64(time.Second))
184 select {
185 case <-time.After(delayDuration):
186 case <-ctx.Done():
187 return nil, ctx.Err()
188 }
189 }
190 return s.makeResponse(fmt.Sprintf("Delayed for %s seconds", delayStr), inputTokens), nil
191 }
192
193 // Default response for undefined inputs
194 return s.makeResponse("edit predictable.go to add a response for that one...", inputTokens), nil
195 }
196}
197
198// makeMaxTokensResponse creates a response that simulates hitting max_tokens limit
199func (s *PredictableService) makeMaxTokensResponse(text string, inputTokens uint64) *llm.Response {
200 outputTokens := uint64(len(text) / 4)
201 if outputTokens == 0 {
202 outputTokens = 1
203 }
204 return &llm.Response{
205 ID: fmt.Sprintf("pred-%d", time.Now().UnixNano()),
206 Type: "message",
207 Role: llm.MessageRoleAssistant,
208 Model: "predictable-v1",
209 Content: []llm.Content{
210 {Type: llm.ContentTypeText, Text: text},
211 },
212 StopReason: llm.StopReasonMaxTokens,
213 Usage: llm.Usage{
214 InputTokens: inputTokens,
215 OutputTokens: outputTokens,
216 CostUSD: 0.001,
217 },
218 }
219}
220
221// makeResponse creates a simple text response
222func (s *PredictableService) makeResponse(text string, inputTokens uint64) *llm.Response {
223 outputTokens := uint64(len(text) / 4) // ~4 chars per token
224 if outputTokens == 0 {
225 outputTokens = 1
226 }
227 return &llm.Response{
228 ID: fmt.Sprintf("pred-%d", time.Now().UnixNano()),
229 Type: "message",
230 Role: llm.MessageRoleAssistant,
231 Model: "predictable-v1",
232 Content: []llm.Content{
233 {Type: llm.ContentTypeText, Text: text},
234 },
235 StopReason: llm.StopReasonStopSequence,
236 Usage: llm.Usage{
237 InputTokens: inputTokens,
238 OutputTokens: outputTokens,
239 CostUSD: 0.001,
240 },
241 }
242}
243
244// makeBashToolResponse creates a response that calls the bash tool
245func (s *PredictableService) makeBashToolResponse(command string, inputTokens uint64) *llm.Response {
246 // Properly marshal the command to avoid JSON escaping issues
247 toolInputData := map[string]string{"command": command}
248 toolInputBytes, _ := json.Marshal(toolInputData)
249 toolInput := json.RawMessage(toolInputBytes)
250 responseText := fmt.Sprintf("I'll run the command: %s", command)
251 outputTokens := uint64(len(responseText)/4 + len(toolInputBytes)/4)
252 if outputTokens == 0 {
253 outputTokens = 1
254 }
255 return &llm.Response{
256 ID: fmt.Sprintf("pred-bash-%d", time.Now().UnixNano()),
257 Type: "message",
258 Role: llm.MessageRoleAssistant,
259 Model: "predictable-v1",
260 Content: []llm.Content{
261 {Type: llm.ContentTypeText, Text: responseText},
262 {
263 ID: fmt.Sprintf("tool_%d", time.Now().UnixNano()%1000),
264 Type: llm.ContentTypeToolUse,
265 ToolName: "bash",
266 ToolInput: toolInput,
267 },
268 },
269 StopReason: llm.StopReasonToolUse,
270 Usage: llm.Usage{
271 InputTokens: inputTokens,
272 OutputTokens: outputTokens,
273 CostUSD: 0.002,
274 },
275 }
276}
277
278// makeThinkToolResponse creates a response that calls the think tool
279func (s *PredictableService) makeThinkToolResponse(thoughts string, inputTokens uint64) *llm.Response {
280 // Properly marshal the thoughts to avoid JSON escaping issues
281 toolInputData := map[string]string{"thoughts": thoughts}
282 toolInputBytes, _ := json.Marshal(toolInputData)
283 toolInput := json.RawMessage(toolInputBytes)
284 responseText := "Let me think about this."
285 outputTokens := uint64(len(responseText)/4 + len(toolInputBytes)/4)
286 if outputTokens == 0 {
287 outputTokens = 1
288 }
289 return &llm.Response{
290 ID: fmt.Sprintf("pred-think-%d", time.Now().UnixNano()),
291 Type: "message",
292 Role: llm.MessageRoleAssistant,
293 Model: "predictable-v1",
294 Content: []llm.Content{
295 {Type: llm.ContentTypeText, Text: responseText},
296 {
297 ID: fmt.Sprintf("tool_%d", time.Now().UnixNano()%1000),
298 Type: llm.ContentTypeToolUse,
299 ToolName: "think",
300 ToolInput: toolInput,
301 },
302 },
303 StopReason: llm.StopReasonToolUse,
304 Usage: llm.Usage{
305 InputTokens: inputTokens,
306 OutputTokens: outputTokens,
307 CostUSD: 0.002,
308 },
309 }
310}
311
312// makePatchToolResponse creates a response that calls the patch tool
313func (s *PredictableService) makePatchToolResponse(filePath string, inputTokens uint64) *llm.Response {
314 // Properly marshal the patch data to avoid JSON escaping issues
315 toolInputData := map[string]interface{}{
316 "path": filePath,
317 "patches": []map[string]string{
318 {
319 "operation": "replace",
320 "oldText": "example",
321 "newText": "updated example",
322 },
323 },
324 }
325 toolInputBytes, _ := json.Marshal(toolInputData)
326 toolInput := json.RawMessage(toolInputBytes)
327 responseText := fmt.Sprintf("I'll patch the file: %s", filePath)
328 outputTokens := uint64(len(responseText)/4 + len(toolInputBytes)/4)
329 if outputTokens == 0 {
330 outputTokens = 1
331 }
332 return &llm.Response{
333 ID: fmt.Sprintf("pred-patch-%d", time.Now().UnixNano()),
334 Type: "message",
335 Role: llm.MessageRoleAssistant,
336 Model: "predictable-v1",
337 Content: []llm.Content{
338 {Type: llm.ContentTypeText, Text: responseText},
339 {
340 ID: fmt.Sprintf("tool_%d", time.Now().UnixNano()%1000),
341 Type: llm.ContentTypeToolUse,
342 ToolName: "patch",
343 ToolInput: toolInput,
344 },
345 },
346 StopReason: llm.StopReasonToolUse,
347 Usage: llm.Usage{
348 InputTokens: inputTokens,
349 OutputTokens: outputTokens,
350 CostUSD: 0.003,
351 },
352 }
353}
354
355// makePatchToolResponseOverwrite creates a response that uses overwrite operation (always succeeds)
356func (s *PredictableService) makePatchToolResponseOverwrite(filePath string, inputTokens uint64) *llm.Response {
357 toolInputData := map[string]interface{}{
358 "path": filePath,
359 "patches": []map[string]string{
360 {
361 "operation": "overwrite",
362 "newText": "This is the new content of the file.\nLine 2\nLine 3\n",
363 },
364 },
365 }
366 toolInputBytes, _ := json.Marshal(toolInputData)
367 toolInput := json.RawMessage(toolInputBytes)
368 responseText := fmt.Sprintf("I'll create/overwrite the file: %s", filePath)
369 outputTokens := uint64(len(responseText)/4 + len(toolInputBytes)/4)
370 if outputTokens == 0 {
371 outputTokens = 1
372 }
373 return &llm.Response{
374 ID: fmt.Sprintf("pred-patch-overwrite-%d", time.Now().UnixNano()),
375 Type: "message",
376 Role: llm.MessageRoleAssistant,
377 Model: "predictable-v1",
378 Content: []llm.Content{
379 {Type: llm.ContentTypeText, Text: responseText},
380 {
381 ID: fmt.Sprintf("tool_%d", time.Now().UnixNano()%1000),
382 Type: llm.ContentTypeToolUse,
383 ToolName: "patch",
384 ToolInput: toolInput,
385 },
386 },
387 StopReason: llm.StopReasonToolUse,
388 Usage: llm.Usage{
389 InputTokens: inputTokens,
390 OutputTokens: outputTokens,
391 CostUSD: 0.0,
392 },
393 }
394}
395
396// makeMalformedPatchToolResponse creates a response with malformed JSON that will fail to parse
397// This simulates when Anthropic sends back invalid JSON in the tool input
398func (s *PredictableService) makeMalformedPatchToolResponse(inputTokens uint64) *llm.Response {
399 // This malformed JSON has a string where an object is expected (patch field)
400 // Mimics the error: "cannot unmarshal string into Go struct field PatchInputOneSingular.patch"
401 malformedJSON := `{"path":"/home/agent/example.css","patch":"<parameter name=\"operation\">replace","oldText":".example {\n color: red;\n}","newText":".example {\n color: blue;\n}"}`
402 toolInput := json.RawMessage(malformedJSON)
403 return &llm.Response{
404 ID: fmt.Sprintf("pred-patch-malformed-%d", time.Now().UnixNano()),
405 Type: "message",
406 Role: llm.MessageRoleAssistant,
407 Model: "predictable-v1",
408 Content: []llm.Content{
409 {Type: llm.ContentTypeText, Text: "I'll patch the file with the changes."},
410 {
411 ID: fmt.Sprintf("tool_%d", time.Now().UnixNano()%1000),
412 Type: llm.ContentTypeToolUse,
413 ToolName: "patch",
414 ToolInput: toolInput,
415 },
416 },
417 StopReason: llm.StopReasonToolUse,
418 Usage: llm.Usage{
419 InputTokens: inputTokens,
420 OutputTokens: 50,
421 CostUSD: 0.003,
422 },
423 }
424}
425
426// GetRecentRequests returns the recent requests made to this service
427func (s *PredictableService) GetRecentRequests() []*llm.Request {
428 s.mu.Lock()
429 defer s.mu.Unlock()
430
431 if len(s.recentRequests) == 0 {
432 return nil
433 }
434
435 requests := make([]*llm.Request, len(s.recentRequests))
436 copy(requests, s.recentRequests)
437 return requests
438}
439
440// GetLastRequest returns the most recent request, or nil if none
441func (s *PredictableService) GetLastRequest() *llm.Request {
442 s.mu.Lock()
443 defer s.mu.Unlock()
444
445 if len(s.recentRequests) == 0 {
446 return nil
447 }
448 return s.recentRequests[len(s.recentRequests)-1]
449}
450
451// ClearRequests clears the request history
452func (s *PredictableService) ClearRequests() {
453 s.mu.Lock()
454 defer s.mu.Unlock()
455
456 s.recentRequests = nil
457}
458
459// countRequestTokens estimates token count based on character count.
460// Uses a simple ~4 chars per token approximation.
461func (s *PredictableService) countRequestTokens(req *llm.Request) uint64 {
462 var totalChars int
463
464 // Count system prompt characters
465 for _, sys := range req.System {
466 totalChars += len(sys.Text)
467 }
468
469 // Count message characters
470 for _, msg := range req.Messages {
471 for _, content := range msg.Content {
472 switch content.Type {
473 case llm.ContentTypeText:
474 totalChars += len(content.Text)
475 case llm.ContentTypeToolUse:
476 totalChars += len(content.ToolName)
477 totalChars += len(content.ToolInput)
478 case llm.ContentTypeToolResult:
479 for _, result := range content.ToolResult {
480 if result.Type == llm.ContentTypeText {
481 totalChars += len(result.Text)
482 }
483 }
484 }
485 }
486 }
487
488 // Count tool definitions
489 for _, tool := range req.Tools {
490 totalChars += len(tool.Name)
491 totalChars += len(tool.Description)
492 totalChars += len(tool.InputSchema)
493 }
494
495 // ~4 chars per token is a rough approximation
496 return uint64(totalChars / 4)
497}
498
499// makeScreenshotToolResponse creates a response that calls the screenshot tool
500func (s *PredictableService) makeScreenshotToolResponse(selector string, inputTokens uint64) *llm.Response {
501 toolInputData := map[string]any{}
502 if selector != "" {
503 toolInputData["selector"] = selector
504 }
505 toolInputBytes, _ := json.Marshal(toolInputData)
506 toolInput := json.RawMessage(toolInputBytes)
507 responseText := "Taking a screenshot..."
508 outputTokens := uint64(len(responseText)/4 + len(toolInputBytes)/4)
509 if outputTokens == 0 {
510 outputTokens = 1
511 }
512 return &llm.Response{
513 ID: fmt.Sprintf("pred-screenshot-%d", time.Now().UnixNano()),
514 Type: "message",
515 Role: llm.MessageRoleAssistant,
516 Model: "predictable-v1",
517 Content: []llm.Content{
518 {Type: llm.ContentTypeText, Text: responseText},
519 {
520 ID: fmt.Sprintf("tool_%d", time.Now().UnixNano()%1000),
521 Type: llm.ContentTypeToolUse,
522 ToolName: "browser_take_screenshot",
523 ToolInput: toolInput,
524 },
525 },
526 StopReason: llm.StopReasonToolUse,
527 Usage: llm.Usage{
528 InputTokens: inputTokens,
529 OutputTokens: outputTokens,
530 CostUSD: 0.0,
531 },
532 }
533}
534
535func (s *PredictableService) makeSubagentToolResponse(slug, prompt string, inputTokens uint64) *llm.Response {
536 toolInputData := map[string]any{
537 "slug": slug,
538 "prompt": prompt,
539 }
540 toolInputBytes, _ := json.Marshal(toolInputData)
541 toolInput := json.RawMessage(toolInputBytes)
542 responseText := fmt.Sprintf("Delegating to subagent '%s'...", slug)
543 outputTokens := uint64(len(responseText)/4 + len(toolInputBytes)/4)
544 if outputTokens == 0 {
545 outputTokens = 1
546 }
547 return &llm.Response{
548 ID: fmt.Sprintf("pred-subagent-%d", time.Now().UnixNano()),
549 Type: "message",
550 Role: llm.MessageRoleAssistant,
551 Model: "predictable-v1",
552 Content: []llm.Content{
553 {Type: llm.ContentTypeText, Text: responseText},
554 {
555 ID: fmt.Sprintf("tool_%d", time.Now().UnixNano()%1000),
556 Type: llm.ContentTypeToolUse,
557 ToolName: "subagent",
558 ToolInput: toolInput,
559 },
560 },
561 StopReason: llm.StopReasonToolUse,
562 Usage: llm.Usage{
563 InputTokens: inputTokens,
564 OutputTokens: outputTokens,
565 CostUSD: 0.0,
566 },
567 }
568}
569
570// makeToolSmorgasbordResponse creates a response that uses all available tool types
571func (s *PredictableService) makeToolSmorgasbordResponse(inputTokens uint64) *llm.Response {
572 baseNano := time.Now().UnixNano()
573 content := []llm.Content{
574 {Type: llm.ContentTypeText, Text: "Here's a sample of all the tools:"},
575 }
576
577 // bash tool
578 bashInput, _ := json.Marshal(map[string]string{"command": "echo 'hello from bash'"})
579 content = append(content, llm.Content{
580 ID: fmt.Sprintf("tool_bash_%d", baseNano%1000),
581 Type: llm.ContentTypeToolUse,
582 ToolName: "bash",
583 ToolInput: json.RawMessage(bashInput),
584 })
585
586 // think tool
587 thinkInput, _ := json.Marshal(map[string]string{"thoughts": "I'm thinking about the best approach for this task. Let me consider all the options available."})
588 content = append(content, llm.Content{
589 ID: fmt.Sprintf("tool_think_%d", (baseNano+1)%1000),
590 Type: llm.ContentTypeToolUse,
591 ToolName: "think",
592 ToolInput: json.RawMessage(thinkInput),
593 })
594
595 // patch tool
596 patchInput, _ := json.Marshal(map[string]interface{}{
597 "path": "/tmp/example.txt",
598 "patches": []map[string]string{
599 {"operation": "replace", "oldText": "foo", "newText": "bar"},
600 },
601 })
602 content = append(content, llm.Content{
603 ID: fmt.Sprintf("tool_patch_%d", (baseNano+2)%1000),
604 Type: llm.ContentTypeToolUse,
605 ToolName: "patch",
606 ToolInput: json.RawMessage(patchInput),
607 })
608
609 // screenshot tool
610 screenshotInput, _ := json.Marshal(map[string]string{})
611 content = append(content, llm.Content{
612 ID: fmt.Sprintf("tool_screenshot_%d", (baseNano+3)%1000),
613 Type: llm.ContentTypeToolUse,
614 ToolName: "browser_take_screenshot",
615 ToolInput: json.RawMessage(screenshotInput),
616 })
617
618 // keyword_search tool
619 keywordInput, _ := json.Marshal(map[string]interface{}{
620 "query": "find all references",
621 "search_terms": []string{"reference", "example"},
622 })
623 content = append(content, llm.Content{
624 ID: fmt.Sprintf("tool_keyword_%d", (baseNano+4)%1000),
625 Type: llm.ContentTypeToolUse,
626 ToolName: "keyword_search",
627 ToolInput: json.RawMessage(keywordInput),
628 })
629
630 // browser_navigate tool
631 navigateInput, _ := json.Marshal(map[string]string{"url": "https://example.com"})
632 content = append(content, llm.Content{
633 ID: fmt.Sprintf("tool_navigate_%d", (baseNano+5)%1000),
634 Type: llm.ContentTypeToolUse,
635 ToolName: "browser_navigate",
636 ToolInput: json.RawMessage(navigateInput),
637 })
638
639 // browser_eval tool
640 evalInput, _ := json.Marshal(map[string]string{"expression": "document.title"})
641 content = append(content, llm.Content{
642 ID: fmt.Sprintf("tool_eval_%d", (baseNano+6)%1000),
643 Type: llm.ContentTypeToolUse,
644 ToolName: "browser_eval",
645 ToolInput: json.RawMessage(evalInput),
646 })
647
648 // read_image tool
649 readImageInput, _ := json.Marshal(map[string]string{"path": "/tmp/image.png"})
650 content = append(content, llm.Content{
651 ID: fmt.Sprintf("tool_readimg_%d", (baseNano+7)%1000),
652 Type: llm.ContentTypeToolUse,
653 ToolName: "read_image",
654 ToolInput: json.RawMessage(readImageInput),
655 })
656
657 // browser_recent_console_logs tool
658 consoleInput, _ := json.Marshal(map[string]string{})
659 content = append(content, llm.Content{
660 ID: fmt.Sprintf("tool_console_%d", (baseNano+8)%1000),
661 Type: llm.ContentTypeToolUse,
662 ToolName: "browser_recent_console_logs",
663 ToolInput: json.RawMessage(consoleInput),
664 })
665
666 return &llm.Response{
667 ID: fmt.Sprintf("pred-smorgasbord-%d", baseNano),
668 Type: "message",
669 Role: llm.MessageRoleAssistant,
670 Model: "predictable-v1",
671 Content: content,
672 StopReason: llm.StopReasonToolUse,
673 Usage: llm.Usage{
674 InputTokens: inputTokens,
675 OutputTokens: 200,
676 CostUSD: 0.01,
677 },
678 }
679}