Talking to Claude from CHICKEN Scheme
Every case below is offline. There is no network and no API key: the HTTP transport is the parameter anthropic/transport, and each case installs a stub that replays a recorded response and records the exact bytes the module tried to send. The live suite is test/anthropic-live.scm and is not part of make test.
The transport contract is four arguments in and one anthropic-wire record out:
(transport url headers body stream?) -> anthropic-wire
status exact-integer HTTP status
headers ((lowercase-name value) ...)
origin a datum identifying what produced this wire
port an input port at the first byte of the body; it MUST answer read-string
correctly, so a custom make-input-port needs a read-bytevector: hook
close thunk -> (values exit-status diagnostics); idempotence is enforced by
anthropic-wire/close!, which memoises the thunk's values, not by a backendTwo facts about the JSON layer drive most of the request cases. The encoder writes '() as {} and only #() as [], so every JSON array on the wire is a Scheme vector; and it escapes C0 control characters as \u00XX, where ->string/json used to emit CHICKEN's \x01; -- not JSON, and a body this parser refuses.
Nothing is ever handed to simdjson without passing anthropic-json/well-formed? first: (aux simdjson) signals a malformed document as a plain Scheme error, and the gate is what makes every malformed body an anthropic-decode-error.
The recorded transcripts
A tool-using turn, verbatim off the wire. Three block indices, a thinking block whose signature arrives on its own signature_delta, and input_json_delta fragments that split {"location": "Paris"} mid-token:
event: message_start
data: {"type":"message_start","message":{"id":"msg_s2","type":"message","role":"assistant","model":"claude-opus-5","content":[],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":20,"output_tokens":1}}}
event: content_block_start
data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"Look it up."}}
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"EqQBCgIY"}}
event: content_block_stop
data: {"type":"content_block_stop","index":0}
event: content_block_start
data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}
event: content_block_delta
data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"Checking."}}
event: content_block_stop
data: {"type":"content_block_stop","index":1}
event: content_block_start
data: {"type":"content_block_start","index":2,"content_block":{"type":"tool_use","id":"toolu_01","name":"get_weather","input":{}}}
event: content_block_delta
data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"{\"loca"}}
event: content_block_delta
data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"tion\": \"Pa"}}
event: content_block_delta
data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"ris\"}"}}
event: content_block_stop
data: {"type":"content_block_stop","index":2}
event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"output_tokens":45}}
event: message_stop
data: {"type":"message_stop"}
A text-only stream, including the ping frame and a : comment line:
event: message_start
data: {"type":"message_start","message":{"id":"msg_s1","type":"message","role":"assistant","model":"claude-opus-5","content":[],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":10,"output_tokens":1}}}
: keep-alive
event: content_block_start
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
event: ping
data: {"type":"ping"}
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" world"}}
event: content_block_stop
data: {"type":"content_block_stop","index":0}
event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":12}}
event: message_stop
data: {"type":"message_stop"}
And a blocking response carrying a tool call:
{"id":"msg_02","type":"message","role":"assistant","model":"claude-opus-5","content":[{"type":"text","text":"Let me check the weather."},{"type":"tool_use","id":"toolu_01","name":"get_weather","input":{"location":"Paris"}}],"stop_reason":"tool_use","stop_sequence":null,"stop_details":null,"usage":{"input_tokens":40,"output_tokens":30}}The module
(module
(aux anthropic)
*
(import
scheme
(scheme base)
(scheme char)
(chicken base)
(chicken condition)
(chicken file)
(chicken file posix)
(only (chicken fixnum) fx= fx< fx> fx>= fx+ fx- fx* fxneg)
(chicken flonum)
(chicken foreign)
(chicken io)
(chicken keyword)
(chicken port)
(chicken process)
(chicken process signal)
(chicken process-context)
(chicken random)
(chicken sort)
(chicken string)
srfi-1
srfi-69
(aux base)
(aux simdjson))
(import-for-syntax
scheme
(scheme base)
(chicken base)
(chicken string)
(chicken syntax)
srfi-1
(aux base))
(define anthropic/api-key (make-parameter #f))
(define anthropic/env-api-key
(make-parameter (λ () (get-environment-variable "ANTHROPIC_API_KEY"))))
(define anthropic/base-url
(make-parameter "https://api.anthropic.com/v1/messages"))
(define anthropic/model (make-parameter "claude-opus-5"))
(define anthropic/version (make-parameter "2023-06-01"))
(define anthropic/betas (make-parameter '()))
(define anthropic/max-tokens (make-parameter 16000))
(define anthropic/stream-max-tokens (make-parameter 64000))
(define anthropic/connect-timeout (make-parameter 10))
(define anthropic/max-time (make-parameter 600))
(define anthropic/stream-max-time (make-parameter 1800))
(define anthropic/retries (make-parameter 3))
(define anthropic/backoff (make-parameter 1.0))
(define anthropic/backoff-cap (make-parameter 30.0))
(define anthropic/backoff-jitter (make-parameter #t))
(define anthropic/curl (make-parameter "curl"))
(define anthropic/sleep
(make-parameter
(λ (seconds)
(process-sleep (max 1 (inexact->exact (ceiling seconds)))))))
(define (anthropic-condition kind retryable message properties)
(apply condition
`((exn message ,message arguments ())
(anthropic-error kind ,kind retryable ,retryable)
(,kind ,@properties))))
(define (anthropic-message/error tag detail)
(string-append "anthropic: " tag "\n\n" (->string/pretty-print detail)))
(define (anthropic-raise/config message detail)
(signal
(anthropic-condition
'anthropic-config-error
#f
(anthropic-message/error message detail)
`(detail ,detail))))
(define (anthropic-raise/encode message value)
(signal
(anthropic-condition
'anthropic-encode-error
#f
(anthropic-message/error message value)
`(value ,value))))
(define (anthropic-raise/decode message body)
(signal
(anthropic-condition
'anthropic-decode-error
#f
(anthropic-message/error message body)
`(body ,body))))
(define (anthropic-raise/transport message origin exit-status diagnostics)
(signal
(anthropic-condition
'anthropic-transport-error
(anthropic-transport/transient? exit-status)
(anthropic-message/error
(string-append "transport failure: " message)
`((origin ,(anthropic/redact (->string origin)))
(exit-status ,exit-status)
(diagnostics ,diagnostics)))
`(origin
,origin
exit-status
,exit-status
diagnostics
,(anthropic/redact diagnostics)))))
(define (anthropic-raise/api status headers body)
(let* ((json (if (anthropic-json/well-formed? body)
(anthropic-json/parse body)
'()))
(err (anthropic-json/ref json 'error '()))
(type (anthropic-json/ref err 'type "api_error"))
(text (anthropic-json/ref err 'message body))
(request-id
(let1 (v (anthropic-json/ref json 'request_id #f))
(if (string? v)
v
(anthropic-header/ref headers "request-id" #f)))))
(signal
(anthropic-condition
'anthropic-api-error
(anthropic-status/retryable? status headers)
(anthropic-message/error
(string-append "HTTP " (number->string status))
`((type ,type)
(message ,(anthropic/redact (->string text)))
(request_id ,request-id)))
`(status
,status
type
,type
api-message
,text
request-id
,request-id
headers
,headers
body
,body)))))
(define (anthropic-raise/sse message raw)
(signal
(anthropic-condition
'anthropic-sse-error
#f
(anthropic-message/error (string-append "malformed SSE: " message) raw)
`(raw ,raw))))
(define (anthropic-raise/tool message detail)
(signal
(anthropic-condition
'anthropic-tool-error
#f
(anthropic-message/error message detail)
`(detail ,detail))))
(define (anthropic-raise/loop reason message response)
(signal
(anthropic-condition
'anthropic-loop-error
#f
(anthropic-message/error message `((reason ,reason)))
`(reason ,reason response ,response))))
(define anthropic-error? (condition-predicate 'anthropic-error))
(define anthropic-config-error?
(condition-predicate 'anthropic-config-error))
(define anthropic-encode-error?
(condition-predicate 'anthropic-encode-error))
(define anthropic-decode-error?
(condition-predicate 'anthropic-decode-error))
(define anthropic-transport-error?
(condition-predicate 'anthropic-transport-error))
(define anthropic-api-error? (condition-predicate 'anthropic-api-error))
(define anthropic-sse-error? (condition-predicate 'anthropic-sse-error))
(define anthropic-tool-error? (condition-predicate 'anthropic-tool-error))
(define anthropic-loop-error? (condition-predicate 'anthropic-loop-error))
(define (anthropic-error-ref c property #!optional (default #f))
(let1 (kind (get-condition-property c 'anthropic-error 'kind #f))
(if kind (get-condition-property c kind property default) default)))
(define (anthropic-error-retryable? c)
(and (anthropic-error? c)
(get-condition-property c 'anthropic-error 'retryable #f)
#t))
(define (anthropic-api-key/current)
(let1 (k (or (anthropic/api-key) ((anthropic/env-api-key))))
(and (string? k) (positive? (string-length k)) k)))
(define (anthropic-api-key/effective)
(or (anthropic-api-key/current)
(anthropic-raise/config
"no API key"
"set ANTHROPIC_API_KEY or parameterize anthropic/api-key")))
(define (anthropic-request/headers #!key (betas (anthropic/betas)))
(let1 (headers
`(("content-type" "application/json")
("x-api-key" ,(anthropic-api-key/effective))
("anthropic-version" ,(anthropic/version))
,@(if (null? betas)
'()
`(("anthropic-beta" ,(string-intersperse betas ","))))))
(for-each (λ (h) (anthropic-header/check! (car h) (cadr h))) headers)
headers))
(define (anthropic/redact text)
(let1 (key (anthropic-api-key/current))
(if (and (string? text) (string? key) (>= (string-length key) 8))
(string-translate* text (list (cons key "<redacted>")))
text)))
(define (anthropic-char/digit? c) (and (char>=? c #\0) (char<=? c #\9)))
(define (anthropic-char/hex? c)
(or (anthropic-char/digit? c)
(and (char>=? c #\a) (char<=? c #\f))
(and (char>=? c #\A) (char<=? c #\F))))
(define anthropic-json/overflow-digits
(number->string (- (expt 2 1024) (expt 2 970))))
(define (anthropic-json/finite? s m dot e b)
(if (and (fx= e b) (fx< (fx- dot m) 309))
#t
(let* ((sign (and (fx< e b)
(let1 (c (string-ref s (fx+ e 1)))
(and (or (char=? c #\+) (char=? c #\-)) c))))
(x (cond (sign (fx+ e 2)) ((fx< e b) (fx+ e 1)) (else b)))
(f (let F ((k m))
(if (and (fx< k e)
(let1 (c (string-ref s k))
(or (char=? c #\0) (char=? c #\.))))
(F (fx+ k 1))
k))))
(cond ((fx> (fx- b x) 19) #f)
((fx>= f e) #t)
(else
(let1 (q (fx+ (if (fx< f dot) (fx- dot f) (fx- (fx+ dot 1) f))
(let X ((k x) (v 0))
(cond ((fx< k b)
(X (fx+ k 1)
(if (fx< v 100000000000000)
(fx+ (fx* 10 v)
(fx- (char->integer (string-ref s k)) 48))
v)))
((eqv? sign #\-) (fxneg v))
(else v)))))
(or (fx< q 309)
(and (fx= q 309)
(string<?
(list->string
(remove
(λ (c) (char=? c #\.))
(string->list (substring s f e))))
anthropic-json/overflow-digits)))))))))
(define (anthropic-json/integer-fits? s negative? m b)
(let* ((bound (if negative? "9223372036854775808" "18446744073709551615"))
(k (string-length bound))
(n (fx- b m)))
(or (fx< n k) (and (fx= n k) (not (string<? bound (substring s m b)))))))
(define anthropic-json/max-depth 512)
(define (anthropic-json/well-formed? s)
(and (string? s)
(let* ((n (string-length s))
(at (λ (i) (and (< i n) (string-ref s i)))))
(letrec ((skip (λ (i)
(if (and (< i n)
(memv (string-ref s i)
(list #\space #\tab #\newline #\return)))
(skip (add1 i))
i)))
(word (λ (i w)
(let1 (m (string-length w))
(and (<= (+ i m) n)
(string=? w (substring s i (+ i m)))
(+ i m)))))
(digits
(λ (i)
(let D ((k i))
(if (and (< k n)
(anthropic-char/digit? (string-ref s k)))
(D (add1 k))
(and (> k i) k)))))
(hex4 (λ (j)
(and (<= (+ j 4) n)
(let1 (h (substring s j (+ j 4)))
(and (every anthropic-char/hex? (string->list h))
(string->number h 16))))))
(number
(λ (i)
(let* ((m (if (eqv? #\- (at i)) (add1 i) i))
(dot (cond ((eqv? #\0 (at m)) (add1 m))
((and (at m) (anthropic-char/digit? (at m)))
(digits m))
(else #f))))
(and dot
(let1 (e (if (eqv? #\. (at dot)) (digits (add1 dot)) dot))
(and e
(let1 (j (let1 (c (at e))
(if (memv c '(#\e #\E))
(digits
(if (memv (at (add1 e)) '(#\+ #\-))
(+ e 2)
(add1 e)))
e)))
(and j
(if (fx= dot j)
(anthropic-json/integer-fits? s (fx> m i) m j)
(anthropic-json/finite? s m dot e j))
j))))))))
(text (λ (i)
(and (eqv? #\" (at i))
(let S ((k (add1 i)))
(let1 (c (at k))
(cond ((not c) #f)
((char=? c #\") (add1 k))
((char=? c #\\)
(let1 (e (at (add1 k)))
(cond ((not e) #f)
((memv e (list #\" #\\ #\/ #\b #\f #\n #\r #\t))
(S (+ k 2)))
((char=? e #\u)
(let1 (u (hex4 (+ k 2)))
(cond ((not u) #f)
((<= 56320 u 57343) #f)
((<= 55296 u 56319)
(and (eqv? #\\ (at (+ k 6)))
(eqv? #\u (at (+ k 7)))
(let1 (v (hex4 (+ k 8)))
(and v (<= 56320 v 57343) (S (+ k 12))))))
(else (S (+ k 6))))))
(else #f))))
((char<? c #\space) #f)
((and (char>=? c #\xd800) (char<=? c #\xdfff))
#f)
(else (S (add1 k)))))))))
(value (λ (i d)
(let1 (i (skip i))
(let1 (c (at i))
(cond ((not c) #f)
((and (memv c (list #\{ #\[))
(>= d anthropic-json/max-depth))
#f)
((char=? c #\{) (object (add1 i) (add1 d)))
((char=? c #\[) (array (add1 i) (add1 d)))
((char=? c #\") (text i))
((char=? c #\t) (word i "true"))
((char=? c #\f) (word i "false"))
((char=? c #\n) (word i "null"))
(else (number i)))))))
(array (λ (i d)
(let1 (i (skip i))
(if (eqv? #\] (at i))
(add1 i)
(let A ((i i))
(let1 (j (value i d))
(and j
(let1 (j (skip j))
(cond ((eqv? #\, (at j)) (A (add1 j)))
((eqv? #\] (at j)) (add1 j))
(else #f))))))))))
(object
(λ (i d)
(let1 (i (skip i))
(if (eqv? #\} (at i))
(add1 i)
(let O ((i i))
(let1 (k (text (skip i)))
(and k
(let1 (k (skip k))
(and (eqv? #\: (at k))
(let1 (v (value (add1 k) d))
(and v
(let1 (v (skip v))
(cond ((eqv? #\, (at v)) (O (add1 v)))
((eqv? #\} (at v)) (add1 v))
(else #f)))))))))))))))
(let1 (i (skip 0))
(and (memv (at i) (list #\{ #\[))
(let1 (j (value i 0)) (and j (= n (skip j))))))))))
(define (anthropic-json/utf8-length s) (bytevector-length (string->utf8 s)))
(define (anthropic-json/parse s)
(unless
(anthropic-json/well-formed? s)
(anthropic-raise/decode "not a well-formed JSON object or array" s))
(let* ((dom (simdjson-parse-ondemand-callback
s
(anthropic-json/utf8-length s)))
(v (handle-exceptions
e
(begin
(simdjson-free dom)
(anthropic-raise/decode
"a value the simdjson binding cannot represent"
s))
(simdjson->scheme dom))))
(simdjson-free dom)
v))
(define (anthropic-json/unicode-escape u)
(let1 (hex (number->string u 16))
(string-append
"\\u"
(make-string (- 4 (string-length hex)) #\0)
hex)))
(define (anthropic-json/escape s port)
(display "\"" port)
(let1 (n (string-length s))
(let L ((i 0))
(when (< i n)
(let* ((c (string-ref s i)) (u (char->integer c)))
(cond ((char=? c #\") (display "\\\"" port))
((char=? c #\\) (display "\\\\" port))
((= u 8) (display "\\b" port))
((= u 9) (display "\\t" port))
((= u 10) (display "\\n" port))
((= u 12) (display "\\f" port))
((= u 13) (display "\\r" port))
((< u 32)
(display (anthropic-json/unicode-escape u) port))
(else (display c port))))
(L (add1 i)))))
(display "\"" port))
(define (anthropic-json/flonum->string x)
(let1 (saved (flonum-print-precision))
(dynamic-wind
(τ (void))
(τ (let L ((p 15))
(flonum-print-precision p)
(let1 (s (number->string x))
(if (or (>= p 17) (eqv? x (string->number s)))
s
(L (add1 p))))))
(τ (flonum-print-precision saved)))))
(define (anthropic-json/emit v port)
(cond ((string? v) (anthropic-json/escape v port))
((boolean? v) (display (if v "true" "false") port))
((void? v) (display "null" port))
((null? v) (display "{}" port))
((symbol? v) (anthropic-json/escape (symbol->string v) port))
((char? v) (anthropic-json/escape (string v) port))
((number? v)
(cond ((and (exact? v) (integer? v)) (display v port))
((and (exact? v) (rational? v))
(display
(anthropic-json/flonum->string (exact->inexact v))
port))
((and (flonum? v) (= v v) (< -∞ v ∞))
(display (anthropic-json/flonum->string v) port))
(else (anthropic-raise/encode "not a finite JSON number" v))))
((vector? v)
(display "[" port)
(let1 (n (vector-length v))
(let L ((i 0))
(when (< i n)
(when (> i 0) (display "," port))
(anthropic-json/emit (vector-ref v i) port)
(L (add1 i)))))
(display "]" port))
((pair? v)
(display "{" port)
(let L ((lst v) (first? #t))
(when (pair? lst)
(unless first? (display "," port))
(let1 (kv (car lst))
(unless
(and (pair? kv) (pair? (cdr kv)) (null? (cddr kv)))
(anthropic-raise/encode
"a JSON object entry must be a two-element list"
kv))
(let1 (key (car kv))
(cond ((symbol? key)
(anthropic-json/escape (symbol->string key) port))
((string? key) (anthropic-json/escape key port))
(else
(anthropic-raise/encode
"a JSON object key must be a symbol or a string"
kv))))
(display ":" port)
(anthropic-json/emit (cadr kv) port))
(L (cdr lst) #f)))
(display "}" port))
(else (anthropic-raise/encode "cannot be represented as JSON" v))))
(define (anthropic-json/write v)
(letport/output-string port (anthropic-json/emit v port)))
(define (anthropic-json/ref obj key #!optional (default (void)))
(let1 (kv (and (pair? obj) (assq key obj)))
(if (pair? kv) (cadr kv) default)))
(define (anthropic-json/has? obj key)
(and (pair? obj) (pair? (assq key obj)) #t))
(define (anthropic-json/null? v) (void? v))
(define (anthropic-json/vector->list v)
(if (vector? v) (vector->list v) '()))
(define (anthropic-json/key=? a b)
(or (eq? a b)
(equal?
(if (symbol? a) (symbol->string a) a)
(if (symbol? b) (symbol->string b) b))))
(define (anthropic-json/set obj key val)
(let L ((o obj) (acc '()) (hit #f))
(cond ((not (pair? o)) (reverse (if hit acc (cons (list key val) acc))))
((anthropic-json/key=? key (car (car o)))
(L (cdr o) (cons (list (car (car o)) val) acc) #t))
(else (L (cdr o) (cons (car o) acc) hit)))))
(define (anthropic-json/merge obj patch)
(if (pair? patch)
(foldl (λ (o kv) (anthropic-json/set o (car kv) (cadr kv))) obj patch)
obj))
(define (anthropic-string/trim s)
(let1 (n (string-length s))
(let L ((a 0) (b n))
(cond ((and (< a b) (char-whitespace? (string-ref s a)))
(L (add1 a) b))
((and (< a b) (char-whitespace? (string-ref s (sub1 b))))
(L a (sub1 b)))
(else (substring s a b))))))
(define (anthropic-port->string port)
(let1 (s (read-string #f port)) (if (eof-object? s) "" s)))
(define (anthropic-header/check! name value)
(for-each
(λ (s)
(unless
(string? s)
(anthropic-raise/config
"a header name and value must both be strings"
name))
(when (or (substring-index "\n" s) (substring-index "\r" s))
(anthropic-raise/config
"a header may not contain a newline"
name)))
(list name value)))
(define (anthropic-url/check! url)
(unless
(and (string? url)
(or (eqv? 0 (substring-index "https://" url))
(eqv? 0 (substring-index "http://" url)))
(not (substring-index "\n" url))
(not (substring-index "\r" url)))
(anthropic-raise/config
"the request URL must begin with http:// or https:// and contain no newline"
url))
url)
(define (anthropic-header/parse line)
(let1 (i (substring-index ":" line))
(and i
(positive? i)
(list (string-downcase
(anthropic-string/trim (substring line 0 i)))
(anthropic-string/trim (substring line (add1 i)))))))
(define (anthropic-header/ref headers name #!optional (default #f))
(let1 (h (assoc (string-downcase name) headers))
(if (pair? h) (cadr h) default)))
(define (anthropic-status/ok? status)
(and (exact-integer? status) (<= 200 status 299)))
(define (anthropic-status/retryable? status headers)
(let1 (hint (anthropic-header/ref headers "x-should-retry" #f))
(cond ((equal? "true" hint) #t)
((equal? "false" hint) #f)
(else
(and (exact-integer? status)
(or (= status 429) (>= status 500)))))))
(define-record anthropic-wire status headers origin port close)
(define (anthropic-wire/close! w)
(let1 (c (anthropic-wire-close w))
(if (procedure? c)
(receive
vals
(c)
(anthropic-wire-close-set! w vals)
(apply values vals))
(apply values c))))
(define anthropic-transport/transient-codes '(7 18 28 52 55 56))
(define (anthropic-transport/transient? code)
(member? code anthropic-transport/transient-codes))
(define (anthropic-transport/still-running? pid) (eqv? 0 pid))
(foreign-declare
"#include <signal.h>\n"
"static int anthropic_sigpipe_depth = 0;\n"
"static int anthropic_sigpipe_ignored = 0;\n")
(define anthropic-signal/pipe-kills?
(foreign-lambda*
bool
()
"struct sigaction sa;"
"C_return(sigaction(SIGPIPE, NULL, &sa) == 0 && sa.sa_handler == SIG_DFL);"))
(define anthropic-signal/enter!
(foreign-lambda*
void
()
"struct sigaction sa;"
"if (anthropic_sigpipe_depth++ == 0"
" && sigaction(SIGPIPE, NULL, &sa) == 0 && sa.sa_handler == SIG_DFL"
" && signal(SIGPIPE, SIG_IGN) != SIG_ERR)"
" anthropic_sigpipe_ignored = 1;"))
(define anthropic-signal/leave!
(foreign-lambda*
void
()
"if (--anthropic_sigpipe_depth == 0 && anthropic_sigpipe_ignored) {"
" anthropic_sigpipe_ignored = 0;"
" signal(SIGPIPE, SIG_DFL);"
"}"))
(define (anthropic-signal/without-sigpipe thunk)
(dynamic-wind anthropic-signal/enter! thunk anthropic-signal/leave!))
(define (anthropic-transport/diagnosis code)
(match/first
code
(1
"the transport rejected its own arguments (a bad option, or an unsupported protocol)")
(6 "could not resolve host")
(7 "could not connect")
(18 "the transfer ended prematurely")
(23 "could not write the output (the response was abandoned)")
(28 "the operation timed out")
(35 "the TLS handshake failed")
(52 "empty reply from server")
(55 "failure sending network data")
(56 "failure receiving network data")
(126
"the transport program could not be executed (not executable, or not found on PATH)")
(127 "the shell could not find the transport program")
(else (conc "the transport exited with status " code))))
(define anthropic-curl/transient-codes anthropic-transport/transient-codes)
(define anthropic-curl/transient? anthropic-transport/transient?)
(define anthropic-curl/diagnosis anthropic-transport/diagnosis)
(define (anthropic-curl/argv url headers-file stream?)
(append
(list "--silent"
"--show-error"
"--include"
"--request"
"POST"
"--connect-timeout"
(number->string (anthropic/connect-timeout))
"--max-time"
(number->string
(if stream? (anthropic/stream-max-time) (anthropic/max-time)))
"--header"
"Expect:"
"--header"
(string-append "@" headers-file))
(if stream? (list "--no-buffer") '())
(list "--data-binary" "@-" (anthropic-url/check! url))))
(define anthropic-temp/attempts 64)
(define (anthropic-temp/directory)
(or (get-environment-variable "TMPDIR")
(get-environment-variable "TMP")
(get-environment-variable "TEMP")
"/tmp"))
(define (anthropic-random/bits n)
(let1 (b (random-bytes (make-bytevector n)))
(let L ((i 0) (acc 0))
(if (>= i n)
acc
(L (add1 i) (+ (* acc 256) (bytevector-u8-ref b i)))))))
(define (anthropic-random/unit) (/ (anthropic-random/bits 4) 4294967296.0))
(define (anthropic-temp/token)
(number->string (anthropic-random/bits 12) 16))
(define anthropic-temp/live '())
(define (anthropic-temp/open!)
(let1 (saved (file-creation-mode))
(dynamic-wind
(τ (set! (file-creation-mode) 63))
(τ (let L ((n 0))
(let* ((path (conc (anthropic-temp/directory)
"/anthropic-"
(anthropic-temp/token)
".headers"))
(fd (handle-exceptions
e
#f
(file-open
path
(+ open/wronly open/creat open/excl)
384))))
(cond (fd
(set! anthropic-temp/live
(cons path anthropic-temp/live))
(values path fd))
((< n anthropic-temp/attempts) (L (add1 n)))
(else
(anthropic-raise/config
"could not create a private file for the request headers"
(anthropic-temp/directory)))))))
(τ (set! (file-creation-mode) saved)))))
(define (anthropic-temp/pending) anthropic-temp/live)
(define (anthropic-temp/delete! path)
(set! anthropic-temp/live (delete! path anthropic-temp/live equal?))
(delete-file* path))
(on-exit (τ (for-each delete-file* anthropic-temp/live)))
(define (anthropic-curl/headers->file! headers)
(for-each (λ (h) (anthropic-header/check! (car h) (cadr h))) headers)
(receive
(path fd)
(anthropic-temp/open!)
(let1 (port (open-output-file* fd))
(handle-exceptions
e
(begin
(handle-exceptions e2 (void) (close-output-port port))
(anthropic-temp/delete! path)
(signal e))
(for-each
(λ (h)
(display (car h) port)
(display ": " port)
(display (cadr h) port)
(newline port))
headers)
(close-output-port port)))
path))
(define (anthropic-curl/status-line line)
(and (> (string-length line) 5)
(string=? "HTTP/" (substring line 0 5))
(let1 (i (substring-index " " line))
(and i
(let* ((rest (anthropic-string/trim (substring line (add1 i))))
(j (substring-index " " rest))
(v (string->number (if j (substring rest 0 j) rest))))
(and (exact-integer? v) (<= 100 v 599) v))))))
(define (anthropic-curl/read-block port)
(let1 (head (read-line port))
(if (eof-object? head)
(values #f '())
(let1 (status
(anthropic-curl/status-line (string-chomp head "\r")))
(let L ((headers '()))
(let1 (line (read-line port))
(if (eof-object? line)
(values status (reverse headers))
(let1 (line (string-chomp line "\r"))
(if (string=? "" line)
(values status (reverse headers))
(let1 (h (anthropic-header/parse line))
(L (if h (cons h headers) headers))))))))))))
(define (anthropic-curl/read-headers port)
(let L ()
(receive
(status headers)
(anthropic-curl/read-block port)
(if (and (exact-integer? status) (< status 200))
(L)
(values status headers)))))
(define (anthropic-transport/curl url headers body stream?)
(anthropic-url/check! url)
(let1 (path (anthropic-curl/headers->file! headers))
(handle-exceptions
e
(begin (anthropic-temp/delete! path) (signal e))
(let* ((argv (anthropic-curl/argv url path stream?))
(origin (cons (anthropic/curl) argv))
(p (process* (anthropic/curl) argv))
(stdin (process-input-port p))
(stdout (process-output-port p))
(stderr (process-error-port p))
(close (τ (close-input-port stdout)
(receive
(pid ok? status)
(process-wait p #t)
(when (anthropic-transport/still-running? pid)
(handle-exceptions
e
(void)
(process-signal (process-id p) signal/term))))
(let1 (diagnostics (anthropic-port->string stderr))
(close-input-port stderr)
(anthropic-temp/delete! path)
(receive
(pid ok? status)
(process-wait p)
(values
(if ok? status 128)
(anthropic/redact diagnostics)))))))
(let ((released? #f))
(handle-exceptions
e
(begin (unless released? (close)) (signal e))
(anthropic-signal/without-sigpipe
(τ (condition-case
(begin (display body stdin) (close-output-port stdin))
((exn)
(handle-exceptions
e
(void)
(close-output-port stdin))))))
(receive
(status response-headers)
(anthropic-curl/read-headers stdout)
(anthropic-temp/delete! path)
(unless
(exact-integer? status)
(set! released? #t)
(receive
(exit-status diagnostics)
(close)
(anthropic-raise/transport
(anthropic-transport/diagnosis exit-status)
origin
exit-status
diagnostics)))
(make-anthropic-wire
status
response-headers
origin
stdout
close))))))))
(define anthropic/transport (make-parameter anthropic-transport/curl))
(define-record anthropic-stub script log closes)
(define (anthropic-stub/canned
body
#!key
(status 200)
(headers '(("content-type" "application/json"))))
(list status
(map (λ (h) (list (string-downcase (car h)) (cadr h))) headers)
body))
(define (anthropic-stub/make . script) (make-anthropic-stub script '() 0))
(define (anthropic-stub/calls stub)
(map (λ (call)
(list (car call)
(map (λ (h) (list (car h) (anthropic/redact (cadr h))))
(cadr call))
(caddr call)
(cadddr call)))
(reverse (anthropic-stub-log stub))))
(define (anthropic-stub/requests stub)
(map (λ (call) (anthropic-json/parse (caddr call)))
(reverse (anthropic-stub-log stub))))
(define (anthropic-stub/count stub) (length (anthropic-stub-log stub)))
(define (anthropic-stub/closes stub) (anthropic-stub-closes stub))
(define (anthropic-stub/transport stub)
(λ (url headers body stream?)
(anthropic-stub-log-set!
stub
(cons (list url headers body stream?) (anthropic-stub-log stub)))
(let1 (script (anthropic-stub-script stub))
(when (null? script)
(anthropic-raise/config
(conc "anthropic-stub: unscripted call #"
(anthropic-stub/count stub))
body))
(anthropic-stub-script-set! stub (cdr script))
(let1 (canned (car script))
(let1 (close (τ (anthropic-stub-closes-set!
stub
(add1 (anthropic-stub-closes stub)))
(values 0 "")))
(make-anthropic-wire
(car canned)
(cadr canned)
`(stub ,url)
(open-input-string (caddr canned))
close))))))
(define (anthropic-wire/drain! w)
(let ((text #f) (exit-status 0) (diagnostics ""))
(dynamic-wind
(τ (void))
(τ (set! text (anthropic-port->string (anthropic-wire-port w))))
(τ (receive
(s d)
(anthropic-wire/close! w)
(set! exit-status s)
(set! diagnostics d))))
(unless
(zero? exit-status)
(anthropic-raise/transport
(anthropic-transport/diagnosis exit-status)
(anthropic-wire-origin w)
exit-status
diagnostics))
text))
(define (anthropic-wire->body! w)
(let1 (text (anthropic-wire/drain! w))
(if (anthropic-status/ok? (anthropic-wire-status w))
text
(anthropic-raise/api
(anthropic-wire-status w)
(anthropic-wire-headers w)
text))))
(define (anthropic-retry/delay attempt headers)
(let* ((cap (anthropic/backoff-cap))
(after (string->number
(or (anthropic-header/ref headers "retry-after" #f) "")))
(window (min cap (* (anthropic/backoff) (expt 2 attempt)))))
(cond ((and (real? after) (positive? after))
(min cap (exact->inexact after)))
((anthropic/backoff-jitter)
(* (exact->inexact window) (anthropic-random/unit)))
(else (exact->inexact window)))))
(define (anthropic-send body #!key (stream #f) (betas (anthropic/betas)))
(let ((transport (anthropic/transport))
(url (anthropic/base-url))
(headers (anthropic-request/headers #:betas betas))
(sleep (anthropic/sleep))
(budget (anthropic/retries)))
(let A ((n 0))
(let1 (outcome
(condition-case
(let1 (w (transport url headers body stream))
(cond ((not (anthropic-status/ok? (anthropic-wire-status w)))
(let1 (text (anthropic-wire/drain! w))
(anthropic-raise/api
(anthropic-wire-status w)
(anthropic-wire-headers w)
text)))
(stream (list 'ok w))
(else
(anthropic-wire-port-set!
w
(open-input-string (anthropic-wire/drain! w)))
(list 'ok w))))
(c (anthropic-error) (list 'raised c))))
(match/first
outcome
((ok ,w) w)
((raised ,c)
(if (and (anthropic-error-retryable? c) (< n budget))
(begin
(sleep (anthropic-retry/delay
n
(anthropic-error-ref c 'headers '())))
(A (add1 n)))
(signal c))))))))
(define (anthropic-content c)
(cond ((string? c) c)
((null? c) #())
((vector? c) c)
((pair? c) (list->vector c))
(else c)))
(define (anthropic-message role content)
`((role ,role) (content ,(anthropic-content content))))
(define (anthropic-message/user content) (anthropic-message "user" content))
(define (anthropic-message/assistant content)
(anthropic-message "assistant" content))
(define (anthropic-message-role m) (anthropic-json/ref m 'role #f))
(define (anthropic-message-content m) (anthropic-json/ref m 'content #()))
(define (anthropic-message-blocks m)
(let1 (c (anthropic-message-content m))
(cond ((string? c) (list (anthropic-block/text c)))
((vector? c) (vector->list c))
(else '()))))
(define (anthropic-block/text text) `((type "text") (text ,text)))
(define (anthropic-block/tool-use id name input)
`((type "tool_use") (id ,id) (name ,name) (input ,input)))
(define (anthropic-block/tool-result tool-use-id content #!key (is-error #f))
`((type "tool_result")
(tool_use_id ,tool-use-id)
(content ,(anthropic-content content))
,@(if is-error '((is_error #t)) '())))
(define (anthropic-block-type b) (anthropic-json/ref b 'type #f))
(define (anthropic-block-text b) (anthropic-json/ref b 'text ""))
(define (anthropic-blocks/type type blocks)
(filter (λ (b) (equal? type (anthropic-block-type b))) blocks))
(define (anthropic-messages/validate! messages)
(when (null? messages)
(anthropic-raise/config "messages must not be empty" messages))
(let L ((ms messages) (previous #f))
(when (pair? ms)
(let* ((m (car ms))
(role (anthropic-message-role m))
(content (anthropic-message-content m)))
(unless
(member? role '("user" "assistant"))
(anthropic-raise/config
"a message role must be \"user\" or \"assistant\""
m))
(when (and (not previous) (not (equal? "user" role)))
(anthropic-raise/config
"the first message must have role \"user\""
m))
(when (and (equal? role previous) (equal? role "user"))
(anthropic-raise/config
"messages must not have two consecutive user turns"
m))
(when (and (vector? content) (zero? (vector-length content)))
(anthropic-raise/config
"a message may not have an empty content array"
m))
(when (and (string? content) (string=? "" content))
(anthropic-raise/config
"a message may not have empty text content"
m))
(L (cdr ms) role)))))
(define (anthropic-tool-choice spec #!optional (disable-parallel #f))
(append
(match/first
spec
(auto '((type "auto")))
(any '((type "any")))
(none '((type "none")))
((tool ,name) `((type "tool") (name ,name)))
(else (anthropic-raise/config "unknown tool_choice" spec)))
(if disable-parallel '((disable_parallel_tool_use #t)) '())))
(define (anthropic-request/body
messages
#!key
(model (anthropic/model))
(max-tokens #f)
(system #f)
(tools #f)
(tool-choice #f)
(disable-parallel #f)
(thinking #f)
(effort #f)
(stop-sequences #f)
(stream #f)
(extra '()))
(anthropic-json/merge
`((model ,model)
(max_tokens
,(or max-tokens
(if stream
(anthropic/stream-max-tokens)
(anthropic/max-tokens))))
(messages ,(list->vector messages))
,@(if system `((system ,system)) '())
,@(let1 (tools (if (vector? tools) (vector->list tools) tools))
(if (and tools (pair? tools))
`((tools ,(anthropic-tools->json tools)))
'()))
,@(if tool-choice
`((tool_choice
,(anthropic-tool-choice tool-choice disable-parallel)))
'())
,@(if thinking `((thinking ,thinking)) '())
,@(if effort `((output_config ((effort ,effort)))) '())
,@(if stop-sequences
`((stop_sequences ,(list->vector stop-sequences)))
'())
,@(if stream '((stream #t)) '()))
extra))
(define-record anthropic-response status headers body json input-errors)
(define (anthropic-response/decode
status
headers
body
#!optional
(input-errors '()))
(unless
(anthropic-json/well-formed? body)
(anthropic-raise/decode "the response body is not JSON" body))
(let1 (json (anthropic-json/parse body))
(unless
(equal? "message" (anthropic-json/ref json 'type #f))
(anthropic-raise/decode
"the response body is not a message object"
body))
(make-anthropic-response status headers body json input-errors)))
(define (anthropic-response/of-json status headers json input-errors)
(make-anthropic-response
status
headers
(anthropic-json/write json)
json
input-errors))
(define (anthropic-response-id r)
(anthropic-json/ref (anthropic-response-json r) 'id #f))
(define (anthropic-response-model r)
(anthropic-json/ref (anthropic-response-json r) 'model #f))
(define (anthropic-response-stop-reason r)
(anthropic-json/ref (anthropic-response-json r) 'stop_reason #f))
(define (anthropic-response-stop-details r)
(anthropic-json/ref (anthropic-response-json r) 'stop_details (void)))
(define (anthropic-response-usage r)
(anthropic-json/ref (anthropic-response-json r) 'usage '()))
(define (anthropic-response-request-id r)
(or (anthropic-header/ref (anthropic-response-headers r) "request-id" #f)
(let1 (v (anthropic-json/ref
(anthropic-response-json r)
'request_id
#f))
(and (string? v) v))))
(define (anthropic-response-content r)
(let1 (c (anthropic-json/ref (anthropic-response-json r) 'content #()))
(if (vector? c) c #())))
(define (anthropic-response/blocks r)
(vector->list (anthropic-response-content r)))
(define (anthropic-response/text r)
(foldr/concat-strings
(map anthropic-block-text
(anthropic-blocks/type "text" (anthropic-response/blocks r)))))
(define (anthropic-response/tool-uses r)
(anthropic-blocks/type "tool_use" (anthropic-response/blocks r)))
(define (anthropic-response/thinking r)
(anthropic-blocks/type "thinking" (anthropic-response/blocks r)))
(define (anthropic-response/refusal? r)
(equal? "refusal" (anthropic-response-stop-reason r)))
(define (anthropic-response/message r)
(anthropic-message/assistant (anthropic-response-content r)))
(define (anthropic-usage/ref r key #!optional (default 0))
(let1 (v (anthropic-json/ref (anthropic-response-usage r) key default))
(if (void? v) default v)))
(define (anthropic-usage/input-tokens r)
(anthropic-usage/ref r 'input_tokens))
(define (anthropic-usage/output-tokens r)
(anthropic-usage/ref r 'output_tokens))
(define (anthropic-usage/cache-read-tokens r)
(anthropic-usage/ref r 'cache_read_input_tokens))
(define (anthropic-usage/cache-creation-tokens r)
(anthropic-usage/ref r 'cache_creation_input_tokens))
(define (anthropic-usage/prompt-tokens r)
(+ (anthropic-usage/input-tokens r)
(anthropic-usage/cache-creation-tokens r)
(anthropic-usage/cache-read-tokens r)))
(define-record anthropic-tool name description schema strict procedure)
(define-record anthropic-tool-failure message)
(define (anthropic-tool/error message) (make-anthropic-tool-failure message))
(define anthropic-tool/registry (make-hash-table))
(define (anthropic-tool/register! tool)
(hash-table-set! anthropic-tool/registry (anthropic-tool-name tool) tool)
tool)
(define (anthropic-tool/registered name)
(hash-table-ref/default anthropic-tool/registry name #f))
(define (anthropic-tool->json tool)
`((name ,(anthropic-tool-name tool))
(description ,(anthropic-tool-description tool))
(input_schema ,(anthropic-tool-schema tool))
,@(if (anthropic-tool-strict tool) '((strict #t)) '())))
(define (anthropic-tools->json tools)
(list->vector
(map (λ (t) (if (anthropic-tool? t) (anthropic-tool->json t) t))
(if (vector? tools) (vector->list tools) tools))))
(define (anthropic-tools->table tools)
(let1 (H (make-hash-table))
(for-each
(λ (t)
(when (anthropic-tool? t)
(hash-table-set! H (anthropic-tool-name t) t)))
tools)
H))
(define (anthropic-tool/input-ref input key name)
(if (anthropic-json/has? input key)
(anthropic-json/ref input key)
(anthropic-raise/tool
(conc "tool " name ": missing required argument " key)
`((tool ,name) (argument ,(symbol->string key)) (input ,input)))))
(define (anthropic-tool/apply tool input)
((anthropic-tool-procedure tool) input))
(define (anthropic-tool-result->string v)
(cond ((and (string? v) (positive? (string-length v))) v)
((string? v) "(the tool returned an empty string)")
((void? v) "(the tool returned no value)")
(else (anthropic-json/write v))))
(define-syntax-rule
(define-tool head body ...)
(anthropic-define-tool* #f head body ...))
(define-syntax-rule
(define-tool/strict head body ...)
(anthropic-define-tool* #t head body ...))
(define-macro-ir
(anthropic-define-tool* expr inject compare)
(let* ((strict? (cadr expr))
(head (caddr expr))
(forms (cdddr expr))
(id (car head))
(name (strip-syntax id))
(name* (symbol->string name))
(specs (cdr head))
(description (if (pair? forms) (car forms) #f))
(procbody (if (pair? forms) (cdr forms) '()))
(spec-id (λ (s) (car s)))
(spec-name (λ (s) (strip-syntax (car s))))
(spec-type (λ (s) (cadr s)))
(spec-doc (λ (s) (caddr s)))
(spec-opts (λ (s) (cdddr s)))
(opt-tag (λ (o) (strip-syntax (if (pair? o) (car o) o))))
(optional?
(λ (s)
(and (find (λ (o) (member? (opt-tag o) '(optional default)))
(spec-opts s))
#t)))
(default-of
(λ (s)
(let1 (d (find (λ (o)
(and (pair? o) (eq? 'default (opt-tag o))))
(spec-opts s)))
(if d (cadr d) '(void))))))
(letrec ((enum-type
(λ (vs)
(cond ((every string? vs) "string")
((every exact-integer? vs) "integer")
((every real? vs) "number")
(else
(error (conc "define-tool: an (enum ...) must be all strings, all exact "
"integers or all reals\n\n"
(->string/pretty-print vs)))))))
(T (λ (type)
(match/first
(strip-syntax type)
(string '((type "string")))
(integer '((type "integer")))
(number '((type "number")))
(boolean '((type "boolean")))
((enum unquote vs)
`((type ,(enum-type vs)) (enum ,(list->vector vs))))
((array-of _)
`((type "array") (items ,(T (cadr type)))))
((object . _) (O (cdr type)))
((raw _) (list (list 'unquote-splicing (cadr type))))
(else (error (conc "define-tool: unknown parameter type\n\n"
(->string/pretty-print (strip-syntax type))))))))
(P (λ (s)
(list (spec-name s)
(append
(T (spec-type s))
(list (list 'description (spec-doc s)))))))
(O (λ (ss)
`((type "object")
(properties ,(map P ss))
(required
,(list->vector
(map (λ (s) (symbol->string (spec-name s)))
(remove optional? ss))))
,@(if strict? '((additionalProperties #f)) '())))))
(unless
(string? description)
(error (conc "define-tool: the description of "
name*
" must be a string literal")))
(unless
(pair? procbody)
(error (conc "define-tool: " name* " has no body")))
(unless
(and (<= 1 (string-length name*) 128)
(every (λ (c)
(or (char<=? #\a c #\z)
(char<=? #\A c #\Z)
(char<=? #\0 c #\9)
(memv c (list #\_ #\-))))
(string->list name*)))
(error (conc "define-tool: a tool name must match ^[a-zA-Z0-9_-]{1,128}$\n\n"
name*)))
(let* ((required (remove optional? specs))
(optional (filter optional? specs))
(schema (list 'quasiquote (O specs)))
(formals
(append
(map spec-id required)
(if (null? optional)
'()
(cons '#!key
(map (λ (s) (list (spec-id s) (default-of s)))
optional)))))
(args `(append
(list ,@(map (λ (s)
`(anthropic-tool/input-ref
input
',(spec-name s)
,name*))
required))
,@(map (λ (s)
`(if (anthropic-json/has? input ',(spec-name s))
(list (string->keyword ,(symbol->string (spec-name s)))
(anthropic-json/ref input ',(spec-name s)))
'()))
optional))))
`(begin
(define (,id ,@formals) ,@procbody)
(define ,(inject (string->symbol (string-append name* "/tool")))
(anthropic-tool/register!
(make-anthropic-tool
,name*
,description
,schema
,strict?
(λ (input) (apply ,id ,args)))))
(void))))))
(define (anthropic/messages
messages
#!rest
options
#!key
(betas (anthropic/betas)))
(anthropic-messages/validate! messages)
(let* ((body (anthropic-json/write
(apply anthropic-request/body
messages
#:stream
#f
options)))
(w (anthropic-send body #:betas betas))
(text (anthropic-wire->body! w)))
(anthropic-response/decode
(anthropic-wire-status w)
(anthropic-wire-headers w)
text)))
(define (anthropic/ask prompt . kwargs)
(anthropic-response/text
(apply anthropic/messages
(list (anthropic-message/user prompt))
kwargs)))
(define-record anthropic-event name data raw)
(define (anthropic-sse/field line prefix)
(and (eqv? 0 (substring-index prefix line))
(let1 (v (substring line (string-length prefix)))
(if (and (positive? (string-length v))
(char=? #\space (string-ref v 0)))
(substring v 1)
v))))
(define (anthropic-sse/frame name data)
(let1 (raw (string-intersperse (reverse data) "\n"))
(make-anthropic-event
(if (and (string? name) (not (string=? name ""))) name "message")
(if (anthropic-json/well-formed? raw)
(anthropic-json/parse raw)
(void))
raw)))
(define (anthropic-sse/read port)
(let F ((name #f) (data '()))
(let1 (line (read-line port))
(cond ((eof-object? line)
(if (and (not name) (null? data))
line
(anthropic-sse/frame name data)))
(else
(let1 (line (string-chomp line "\r"))
(cond ((string=? "" line)
(if (and (not name) (null? data))
(F #f '())
(anthropic-sse/frame name data)))
((eqv? 0 (substring-index ":" line))
(F name data))
((anthropic-sse/field line "event:")
=>
(λ (v) (F v data)))
((anthropic-sse/field line "data:")
=>
(λ (v) (F name (cons v data))))
(else (F name data)))))))))
(define (anthropic-sse->§ port #!key (finish void))
(let F ()
(δ (let1 (ev (anthropic-sse/read port))
(if (eof-object? ev) (begin (finish) '()) (cons ev (F)))))))
(define (anthropic-sse/fold f seed §)
(let F ((§ §) (acc seed))
(cond ((promise? §) (F (force §) acc))
((pair? §) (F (cdr §) (f (car §) acc)))
(else acc))))
(define (anthropic-sse/events port)
(let L ((acc '()))
(let1 (ev (anthropic-sse/read port))
(if (eof-object? ev) (reverse acc) (L (cons ev acc))))))
(define (anthropic-event/text ev)
(and (equal? "content_block_delta" (anthropic-event-name ev))
(let1 (delta (anthropic-json/ref
(anthropic-event-data ev)
'delta
'()))
(and (equal? "text_delta" (anthropic-json/ref delta 'type ""))
(anthropic-json/ref delta 'text "")))))
(define-record anthropic-sse-block index template text json signature)
(define-record anthropic-sse-state message open done errors stopped)
(define (anthropic-accumulator/fresh)
(make-anthropic-sse-state '() '() '() '() #f))
(define anthropic-sse/structural-events
'("message_start"
"content_block_start"
"content_block_delta"
"content_block_stop"
"message_delta"
"error"))
(define (anthropic-sse-block->json blk st)
(let* ((template (anthropic-sse-block-template blk))
(text (foldr/concat-strings
(reverse (anthropic-sse-block-text blk))))
(signature
(foldr/concat-strings
(reverse (anthropic-sse-block-signature blk))))
(raw (foldr/concat-strings
(reverse (anthropic-sse-block-json blk)))))
(match/first
(anthropic-json/ref template 'type "text")
("text" (anthropic-json/set template 'text text))
("thinking"
(let1 (t (anthropic-json/set template 'thinking text))
(if (string=? "" signature)
t
(anthropic-json/set t 'signature signature))))
("redacted_thinking" template)
(else (cond ((string=? "" raw)
(if (null? (anthropic-sse-block-text blk))
template
(anthropic-json/set template 'text text)))
((anthropic-json/well-formed? raw)
(anthropic-json/set
template
'input
(anthropic-json/parse raw)))
(else
(anthropic-sse-state-errors-set!
st
(cons (list (anthropic-json/ref template 'id #f) raw)
(anthropic-sse-state-errors st)))
(anthropic-json/set template 'input '())))))))
(define (anthropic-accumulator/event st ev)
(let* ((name (anthropic-event-name ev))
(data (anthropic-event-data ev))
(index (anthropic-json/ref data 'index -1)))
(when (and (void? data) (member? name anthropic-sse/structural-events))
(anthropic-raise/sse
(conc "the " name " frame carries no decodable data")
(anthropic-event-raw ev)))
(match/first
name
("message_start"
(anthropic-sse-state-message-set!
st
(anthropic-json/ref data 'message '()))
st)
("content_block_start"
(anthropic-sse-state-open-set!
st
(cons (cons index
(make-anthropic-sse-block
index
(anthropic-json/ref data 'content_block '())
'()
'()
'()))
(anthropic-sse-state-open st)))
st)
("content_block_delta"
(let1 (cell (assv index (anthropic-sse-state-open st)))
(when (pair? cell)
(let* ((blk (cdr cell))
(delta (anthropic-json/ref data 'delta '())))
(match/first
(anthropic-json/ref delta 'type "")
("text_delta"
(anthropic-sse-block-text-set!
blk
(cons (anthropic-json/ref delta 'text "")
(anthropic-sse-block-text blk))))
("thinking_delta"
(anthropic-sse-block-text-set!
blk
(cons (anthropic-json/ref delta 'thinking "")
(anthropic-sse-block-text blk))))
("input_json_delta"
(anthropic-sse-block-json-set!
blk
(cons (anthropic-json/ref delta 'partial_json "")
(anthropic-sse-block-json blk))))
("signature_delta"
(anthropic-sse-block-signature-set!
blk
(cons (anthropic-json/ref delta 'signature "")
(anthropic-sse-block-signature blk))))
(else (void)))))
st))
("content_block_stop"
(let1 (cell (assv index (anthropic-sse-state-open st)))
(when (pair? cell)
(let1 (finished (anthropic-sse-block->json (cdr cell) st))
(anthropic-sse-state-open-set!
st
(remove
(λ (c) (eqv? index (car c)))
(anthropic-sse-state-open st)))
(anthropic-sse-state-done-set!
st
(cons (cons index finished)
(anthropic-sse-state-done st)))))
st))
("message_delta"
(let* ((m (anthropic-json/merge
(anthropic-sse-state-message st)
(anthropic-json/ref data 'delta '())))
(usage (anthropic-json/ref data 'usage '()))
(m (if (pair? usage)
(anthropic-json/set
m
'usage
(anthropic-json/merge
(anthropic-json/ref m 'usage '())
usage))
m)))
(anthropic-sse-state-message-set! st m)
st))
("message_stop" (anthropic-sse-state-stopped-set! st #t) st)
("ping" st)
("error"
(let1 (err (anthropic-json/ref data 'error '()))
(signal
(anthropic-condition
'anthropic-api-error
(equal?
"overloaded_error"
(anthropic-json/ref err 'type ""))
(anthropic-message/error
"stream error"
`((type ,(anthropic-json/ref err 'type "api_error"))
(message ,(anthropic-json/ref err 'message ""))))
`(status
,(void)
type
,(anthropic-json/ref err 'type "api_error")
api-message
,(anthropic-json/ref err 'message "")
request-id
#f
headers
()
body
,(anthropic-event-raw ev))))))
(else st))))
(define (anthropic-accumulator->message st)
(let1 (blocks
(sort (reverse (anthropic-sse-state-done st))
(λ (a b) (< (car a) (car b)))))
(anthropic-json/set
(anthropic-sse-state-message st)
'content
(list->vector (map cdr blocks)))))
(define (anthropic-sse->message § #!key (on-event void))
(let1 (st (anthropic-sse/fold
(λ (ev acc) (on-event ev) (anthropic-accumulator/event acc ev))
(anthropic-accumulator/fresh)
§))
(unless
(null? (anthropic-sse-state-open st))
(anthropic-raise/sse
"the stream ended with an unfinished content block"
(map car (anthropic-sse-state-open st))))
(unless
(anthropic-sse-state-stopped st)
(anthropic-raise/sse
"the stream ended without a message_stop event"
(anthropic-sse-state-message st)))
(values
(anthropic-accumulator->message st)
(reverse (anthropic-sse-state-errors st)))))
(define (anthropic/stream
messages
#!rest
options
#!key
(betas (anthropic/betas))
(on-event void)
(on-text void))
(anthropic-messages/validate! messages)
(let* ((body (anthropic-json/write
(apply anthropic-request/body
messages
#:stream
#t
options)))
(transport (anthropic/transport))
(w (transport
(anthropic/base-url)
(anthropic-request/headers #:betas betas)
body
#t)))
(unless
(anthropic-status/ok? (anthropic-wire-status w))
(anthropic-wire->body! w))
(let1 (ct (anthropic-header/ref
(anthropic-wire-headers w)
"content-type"
""))
(unless
(substring-index "text/event-stream" ct)
(anthropic-wire/close! w)
(anthropic-raise/decode
"a streaming request did not get an SSE response"
ct)))
(let ((message #f) (errors '()) (exit-status 0) (diagnostics ""))
(dynamic-wind
(τ (void))
(τ (receive
(m e)
(anthropic-sse->message
(anthropic-sse->§ (anthropic-wire-port w))
#:on-event
(λ (ev)
(let1 (t (anthropic-event/text ev)) (when t (on-text t)))
(on-event ev)))
(set! message m)
(set! errors e)))
(τ (receive
(s d)
(anthropic-wire/close! w)
(set! exit-status s)
(set! diagnostics d))))
(unless
(zero? exit-status)
(anthropic-raise/transport
(anthropic-transport/diagnosis exit-status)
(anthropic-wire-origin w)
exit-status
diagnostics))
(anthropic-response/of-json
(anthropic-wire-status w)
(anthropic-wire-headers w)
message
errors))))
(define (anthropic-tool/around name input run) (run))
(define (anthropic-tool-use->result block table around input-errors)
(let* ((id (anthropic-json/ref block 'id #f))
(name (anthropic-json/ref block 'name #f))
(input (anthropic-json/ref block 'input '()))
(broken (assoc id input-errors))
(tool (hash-table-ref/default table name #f))
(outcome
(cond ((pair? broken)
(anthropic-tool/error
(anthropic-json/write `((INVALID_JSON ,(cadr broken))))))
((not tool)
(anthropic-tool/error (conc "no such tool: " name)))
(else
(condition-case
(around name input (τ (anthropic-tool/apply tool input)))
(c (exn)
(anthropic-tool/error
(anthropic/redact
(letport/output-string
p
(print-error-message c p)))))
(c ()
(anthropic-tool/error (->string/pretty-print c)))))))
(failed? (anthropic-tool-failure? outcome)))
(anthropic-block/tool-result
id
(if failed?
(anthropic-tool-failure-message outcome)
(anthropic-tool-result->string outcome))
#:is-error
failed?)))
(define (anthropic-converse/append transcript r)
(let1 (content (anthropic-response-content r))
(if (zero? (vector-length content))
transcript
(append transcript (list (anthropic-message/assistant content))))))
(define (anthropic/converse
messages
#!rest
options
#!key
(tools '())
(max-iterations 8)
(max-pauses 5)
(around anthropic-tool/around)
(on-response (λ_ (void))))
(anthropic-messages/validate! messages)
(unless
(equal? "user" (anthropic-message-role (last messages)))
(anthropic-raise/config
"the initial transcript must end with a user message"
(last messages)))
(let* ((tools (if (vector? tools) (vector->list tools) tools))
(table (anthropic-tools->table tools)))
(let L ((transcript messages) (request 1) (pauses 0))
(when (> request max-iterations)
(anthropic-raise/loop
'max-iterations
(conc "too many requests in one conversation (max-iterations "
max-iterations
")")
#f))
(let* ((r (apply anthropic/messages
transcript
#:tools
(if (pair? tools) tools #f)
options))
(content (anthropic-response-content r)))
(on-response r request)
(match/first
(anthropic-response-stop-reason r)
("end_turn" (values r (anthropic-converse/append transcript r)))
("stop_sequence"
(values r (anthropic-converse/append transcript r)))
("tool_use"
(let1 (uses (anthropic-response/tool-uses r))
(when (null? uses)
(anthropic-raise/loop
'no-tool-use
"stop_reason is tool_use but no tool_use block was returned"
r))
(let1 (results
(let R ((bs uses) (acc '()))
(if (null? bs)
(reverse acc)
(R (cdr bs)
(cons (anthropic-tool-use->result
(car bs)
table
around
(anthropic-response-input-errors r))
acc)))))
(L (append
(anthropic-converse/append transcript r)
(list (anthropic-message/user (list->vector results))))
(add1 request)
pauses))))
("pause_turn"
(when (>= pauses max-pauses)
(anthropic-raise/loop
'max-pauses
"too many pause_turn continuations"
r))
(when (zero? (vector-length content))
(anthropic-raise/loop
'empty-pause
"pause_turn with an empty content array"
r))
(L (anthropic-converse/append transcript r)
(add1 request)
(add1 pauses)))
("max_tokens"
(if (null? (anthropic-response/tool-uses r))
(values r (anthropic-converse/append transcript r))
(anthropic-raise/loop
'max-tokens
"the turn was truncated while a tool call was being written"
r)))
("refusal"
(anthropic-raise/loop 'refusal "the model refused to continue" r))
("model_context_window_exceeded"
(anthropic-raise/loop
'context-window-exceeded
"the conversation exceeded the model's context window"
r))
(else (anthropic-raise/loop
'unknown-stop-reason
(conc "unknown stop_reason: "
(->string (anthropic-response-stop-reason r)))
r))))))))
test/anthropic/json/well-formed: pass
(define (test/anthropic/json/well-formed _)
(⊨ (anthropic-json/well-formed? "{}"))
(⊨ (anthropic-json/well-formed? "[]"))
(⊨ (anthropic-json/well-formed? "{\"a\":1}"))
(⊨ (anthropic-json/well-formed?
" {\"a\":[1,2,{\"b\":null}],\"c\":-1.5e+3} "))
(⊨ (anthropic-json/well-formed? "{\"a\":\"\\u00e8 \\n \\\" \\\\\"}"))
(⊨ (anthropic-json/well-formed? fixture/response/text)))
((eta 0.0) (memory #(12582912 5881424 1048576)) (stdout "") (stderr ""))
test/anthropic/json/ill-formed: pass
A bare scalar document is rejected on purpose: no API response is one, although (aux simdjson) decodes 42 as a number. Sniffing for a leading { -- which the truncated fixture passes -- is not a guard.
(define (test/anthropic/json/ill-formed _)
(⊭ (anthropic-json/well-formed? fixture/error/truncated))
(⊭ (anthropic-json/well-formed? fixture/error/non-json))
(⊭ (anthropic-json/well-formed? ""))
(⊭ (anthropic-json/well-formed? "42"))
(⊭ (anthropic-json/well-formed? "null"))
(⊭ (anthropic-json/well-formed? "\"x\""))
(⊭ (anthropic-json/well-formed? "{\"a\":1}trailing"))
(⊭ (anthropic-json/well-formed? "{\"a\":01}"))
(⊭ (anthropic-json/well-formed?
(conc "{\"a\":\"" (string (integer->char 1)) "\"}"))))
((eta 0.0) (memory #(12582912 5883320 1048576)) (stdout "") (stderr ""))
test/anthropic/json/parse-raises-rather-than-aborting: pass
(define (test/anthropic/json/parse-raises-rather-than-aborting _)
(⊦raises
(anthropic-decode-error)
(anthropic-json/parse fixture/error/truncated))
(⊦raises
(anthropic-decode-error)
(anthropic-json/parse fixture/error/non-json))
(⊦raises (anthropic-decode-error) (anthropic-json/parse "42")))
((eta 0.0) (memory #(12582912 5888480 1048576)) (stdout "") (stderr ""))
test/anthropic/json/numbers-that-overflow-a-double: pass
simdjson reads a literal with a fraction or an exponent as a double and throws NUMBER_ERROR -- an uncatchable abort until (aux simdjson) caught its exceptions -- when it rounds to infinity, or when its exponent field has twenty digits or more. The grammar alone passed {"a":1e309}, so the gate decides the magnitude too, on the digits: the boundary is the midpoint between the largest double and 2^1024, which rounds up.
(define (test/anthropic/json/numbers-that-overflow-a-double _)
(⊭ (anthropic-json/well-formed? "{\"a\":1e309}"))
(⊭ (anthropic-json/well-formed? "[-1e999]"))
(⊭ (anthropic-json/well-formed? "[1000e306]"))
(⊭ (anthropic-json/well-formed? "[1.797693134862315808e308]"))
(⊭ (anthropic-json/well-formed? "[1e+00000000000000000308]"))
(⊨ (anthropic-json/well-formed? "[1.797693134862315807e308]"))
(⊨ (anthropic-json/well-formed? "[1e+0000000000000000308]"))
(⊨ (anthropic-json/well-formed? "[0.0001e312]"))
(⊨ (anthropic-json/well-formed? "[0e99999, 1e-999]"))
(⊦= #(0.0 0.0) (anthropic-json/parse "[0e99999, 1e-999]"))
(⊦raises (anthropic-decode-error) (anthropic-json/parse "{\"a\":1e309}")))
((eta 0.0) (memory #(12582912 5889352 1048576)) (stdout "") (stderr ""))
test/anthropic/json/integers-beyond-64-bits: pass
An integer literal beyond 64 bits is valid JSON, but the binding refuses simdjson's big_integer with BIGINT_ERROR (it used to leave it untyped), so simdjson->scheme raised a bare error that escaped the anthropic-error taxonomy -- and leaked the parsed document. The gate refuses it now, so every caller that falls back on what the gate refuses does so for it too: an error body keeps its API error and its retry, a frame keeps the stream, a streamed tool input is recorded. Should the conversion still raise, the document is freed and a decode error re-signalled.
(define (test/anthropic/json/integers-beyond-64-bits _)
(let1 (big "123456789012345678901234567890")
(⊭ (anthropic-json/well-formed? (string-append "{\"a\":" big "}")))
(⊭ (anthropic-json/well-formed?
(string-append "[1" (make-string 400 #\0) "]")))
(⊭ (anthropic-json/well-formed? "[18446744073709551616]"))
(⊭ (anthropic-json/well-formed? "[-9223372036854775809]"))
(⊦raises
(anthropic-decode-error)
(anthropic-json/parse (string-append "{\"a\":" big "}")))
(⊦= #(18446744073709551615 9223372036854775808 -9223372036854775808 0)
(anthropic-json/parse
"[18446744073709551615,9223372036854775808,-9223372036854775808,-0]"))
(let1 (w (make-anthropic-wire
529
'()
'(curl)
(open-input-string
(string-append
"{\"type\":\"error\",\"error\":{\"type\":"
"\"overloaded_error\",\"message\":\"x\"},"
"\"n\":"
big
"}"))
(τ (values 0 ""))))
(⊦raises/api (529 "api_error") (anthropic-wire->body! w)))
(⊦= (void)
(anthropic-event-data
(anthropic-sse/frame "ping" (list (string-append "[" big "]")))))
(receive
(message errors)
(anthropic-sse->message
(anthropic-sse/events
(open-input-string fixture/sse/big-integer-input)))
(⊦= '(("toolu_06" "{\"n\":123456789012345678901234567890}")) errors)
(⊦= "tool_use" (anthropic-json/ref message 'stop_reason)))))
((eta 0.0) (memory #(12582912 5889664 1048576)) (stdout "") (stderr ""))
test/anthropic/json/unpaired-surrogate-escapes: pass
A \u escape in the surrogate range must be a high one followed by a low one; simdjson throws STRING_ERROR on anything else, which was an abort until (aux simdjson) caught its exceptions.
(define (test/anthropic/json/unpaired-surrogate-escapes _)
(⊭ (anthropic-json/well-formed? "{\"a\":\"\\ud800\"}"))
(⊭ (anthropic-json/well-formed? "{\"a\":\"\\udc00\"}"))
(⊭ (anthropic-json/well-formed? "{\"a\":\"\\ud800\\u0041\"}"))
(⊭ (anthropic-json/well-formed? "{\"a\":\"\\ud800\\ud800\"}"))
(⊭ (anthropic-json/well-formed? "{\"\\ud800\":1}"))
(⊨ (anthropic-json/well-formed?
"{\"a\":\"\\uD83D\\uDE00 \\ud7ff \\ue000\"}"))
(⊦= '((a "😀")) (anthropic-json/parse "{\"a\":\"\\ud83d\\ude00\"}"))
(⊦raises
(anthropic-decode-error)
(anthropic-json/parse "{\"a\":\"\\ud800\"}")))
((eta 0.0) (memory #(12582912 5894784 1048576)) (stdout "") (stderr ""))
test/anthropic/json/invalid-utf8-bytes: pass
A proxy error page in Latin-1 is not UTF-8. CHICKEN reads each invalid byte B as the character #xDC00 + B, which is at or above #\space and so passed the gate, while the FFI hands simdjson the original bytes and it throws UTF8_ERROR. No valid UTF-8 decodes to a surrogate, so the gate rejects the whole range.
(define (test/anthropic/json/invalid-utf8-bytes _)
(let* ((bytes->string
(λ bs
(read-string #f (open-input-bytevector (apply bytevector bs)))))
(latin1 (bytes->string 123 34 97 34 58 34 195 40 34 125))
(cesu (bytes->string 91 34 237 160 128 34 93))
(utf8 (bytes->string 91 34 195 168 34 93)))
(⊦= #\xdcc3 (string-ref latin1 6))
(⊭ (anthropic-json/well-formed? latin1))
(⊭ (anthropic-json/well-formed? cesu))
(⊦raises (anthropic-decode-error) (anthropic-json/parse latin1))
(⊦= #("è") (anthropic-json/parse utf8))))
((eta 0.0) (memory #(12582912 5893176 1048576)) (stdout "") (stderr ""))
test/anthropic/json/nesting-depth: pass
simdjson's binding recurses in C++ once per nesting level and segfaulted on deep enough input -- 40,000 nested arrays, about 80 KB -- which the gate used to pass; it now refuses nesting past 1024 on its own. Nesting is now bounded at anthropic-json/max-depth.
(define (test/anthropic/json/nesting-depth _)
(let* ((arrays
(λ (n) (string-append (make-string n #\[) "0" (make-string n #\]))))
(objects
(λ (n)
(let L ((k n) (acc "0"))
(if (zero? k)
acc
(L (sub1 k) (string-append "{\"a\":" acc "}")))))))
(⊨ (anthropic-json/well-formed? (arrays anthropic-json/max-depth)))
(⊨ (anthropic-json/well-formed? (objects anthropic-json/max-depth)))
(⊭ (anthropic-json/well-formed? (arrays (add1 anthropic-json/max-depth))))
(⊭ (anthropic-json/well-formed? (objects (add1 anthropic-json/max-depth))))
(⊦raises (anthropic-decode-error) (anthropic-json/parse (arrays 40000)))))
((eta 0.002) (memory #(12582912 6186256 1048576)) (stdout "") (stderr ""))
test/anthropic/json/whitespace-is-four-characters: pass
JSON whitespace is space, tab, line feed and carriage return, nothing else. char-whitespace? is Unicode-aware and let a form feed through the gate, which simdjson answers with TAPE_ERROR -- an abort until (aux simdjson) caught its exceptions.
(define (test/anthropic/json/whitespace-is-four-characters _)
(let1 (between (λ (u) (string-append "[1," (string (integer->char u)) "2]")))
(for-each
(λ (u) (⊨ (anthropic-json/well-formed? (between u))))
'(32 9 10 13))
(for-each
(λ (u) (⊭ (anthropic-json/well-formed? (between u))))
'(11 12 160 8232))
(⊦= #(1 2) (anthropic-json/parse (between 13)))
(⊦raises
(anthropic-decode-error)
(anthropic-json/parse (between 12)))))
((eta 0.0) (memory #(12582912 5895968 1048576)) (stdout "") (stderr ""))
test/anthropic/json/utf8-length: pass
simdjson-parse/ondemand used to hand the C side string-length, a CHARACTER count, where simdjson wants BYTES. This is the number that replaces it.
(define (test/anthropic/json/utf8-length _)
(⊦= 1 (anthropic-json/utf8-length "a"))
(⊦= 6 (anthropic-json/utf8-length "caffè"))
(⊦= 3 (anthropic-json/utf8-length "一"))
(⊦= 0 (anthropic-json/utf8-length ""))
(⊦= 4 (anthropic-json/utf8-length "😀"))
(⊦≠ (string-length "caffè 一 ok") (anthropic-json/utf8-length "caffè 一 ok")))
((eta 0.0) (memory #(12582912 5896792 1048576)) (stdout "") (stderr ""))
test/anthropic/json/round-trip: pass
Decode / encode / decode is the identity. The whole replay strategy rests on it: the loop re-encodes an assistant turn's DECODED content, and a thinking block that comes back modified is a 400.
(define (test/anthropic/json/round-trip _)
(let1 (v `((text "caffè 一 ok")
(lines "l1\nl2\tx\"q\"\\z")
(n 12)
(f 1.5)
(t #t)
(f2 #f)
(nul ,(void))
(arr #(1 "two" ()))
(obj ((a 1)))))
(⊦= v (anthropic-json/parse (anthropic-json/write v)))))
((eta 0.0) (memory #(12582912 5897656 1048576)) (stdout "") (stderr ""))
test/anthropic/json/escapes-control-characters: pass
Tool results are arbitrary program output. ->string/json used to escape with Scheme write and put \x01; on the wire, which the API rejects and which this parser refuses.
(define (test/anthropic/json/escapes-control-characters _)
(let1 (s (conc "a" (string (integer->char 1)) "b"))
(⊦= "{\"text\":\"a\\u0001b\"}" (anthropic-json/write `((text ,s))))
(⊦= `((text ,s))
(anthropic-json/parse (anthropic-json/write `((text ,s))))))
(⊦= "{\"t\":\"l1\\nl2\\tx\\\"q\\\"\\\\z\"}"
(anthropic-json/write '((t "l1\nl2\tx\"q\"\\z"))))
(⊦= "{\"text\":\"caffè 一 ok\"}"
(anthropic-json/write '((text "caffè 一 ok")))))
((eta 0.0) (memory #(12582912 5899704 1048576)) (stdout "") (stderr ""))
test/anthropic/json/numbers: pass
CHICKEN's flonum-print-precision defaults to 15, which turns 3.141592653589793 into 3.14159265358979. A replayed turn must be byte-faithful, so the encoder searches upward for the shortest representation that reads back to the same flonum, and restores the parameter afterwards. A ratnum is coerced; NaN and the infinities raise.
(define (test/anthropic/json/numbers _)
(⊦= "{\"a\":3.141592653589793}"
(anthropic-json/write '((a 3.14159265358979))))
(⊦= "{\"a\":22.0}" (anthropic-json/write '((a 22.0))))
(⊦= "{\"a\":1e+300}" (anthropic-json/write '((a 1e+300))))
(⊦= "{\"a\":0.3333333333333333}" (anthropic-json/write '((a 1/3))))
(⊦= "{\"a\":9223372036854775808}"
(anthropic-json/write '((a 9223372036854775808))))
(⊦= "3.141592653589793" (anthropic-json/flonum->string 3.14159265358979))
(⊦raises (anthropic-encode-error) (anthropic-json/write `((a ,(/ 0.0 0.0)))))
(⊦raises (anthropic-encode-error) (anthropic-json/write '((a +inf.0)))))
((eta 0.0) (memory #(12582912 5905736 1048576)) (stdout "") (stderr ""))
test/anthropic/json/encoder-is-total: pass
Every leaf the encoder cannot represent raises anthropic-encode-error. A non-symbol, non-string KEY raises too -- that is the one the type check above the key is easy to forget.
(define (test/anthropic/json/encoder-is-total _)
(⊦= "{\"role\":\"user\"}" (anthropic-json/write '((role user))))
(⊦= "{\"c\":\"u\"}" (anthropic-json/write '((c #\u))))
(⊦raises (anthropic-encode-error) (anthropic-json/write '((a . 1))))
(⊦raises (anthropic-encode-error) (anthropic-json/write '((a 1 2))))
(⊦raises (anthropic-encode-error) (anthropic-json/write '((7 "v"))))
(⊦raises (anthropic-encode-error) (anthropic-json/write `((a ,car)))))
((eta 0.0) (memory #(12582912 5907400 1048576)) (stdout "") (stderr ""))
test/anthropic/json/accessors: pass
An absent key and a key whose value is JSON null are different things: null decodes to (void), while '() is an empty OBJECT. Confusing them is how a refusal gets misread.
(define (test/anthropic/json/accessors _)
(let1 (obj (anthropic-json/parse
"{\"a\":1,\"b\":null,\"c\":{},\"d\":[],\"e\":[1,2]}"))
(⊦= 1 (anthropic-json/ref obj 'a))
(⊦= (void) (anthropic-json/ref obj 'b))
(⊨ (anthropic-json/null? (anthropic-json/ref obj 'b)))
(⊨ (anthropic-json/has? obj 'b))
(⊦= '() (anthropic-json/ref obj 'c))
(⊭ (anthropic-json/null? (anthropic-json/ref obj 'c)))
(⊦= #() (anthropic-json/ref obj 'd))
(⊦= '(1 2) (anthropic-json/vector->list (anthropic-json/ref obj 'e)))
(⊦= '() (anthropic-json/vector->list (anthropic-json/ref obj 'c)))
(⊦= 'absent (anthropic-json/ref obj 'missing 'absent))
(⊭ (anthropic-json/has? obj 'missing))
(⊦= 'absent (anthropic-json/ref (void) 'a 'absent))))
((eta 0.0) (memory #(12582912 5905712 1048576)) (stdout "") (stderr ""))
test/anthropic/json/set-and-merge: pass
Replacement is in place so that key ORDER survives, which is what makes a streamed message land in the same shape as a blocking one. Keys match by NAME: the encoder accepts string keys, and an extra spelled with them used to be appended as a duplicate key, of which the server keeps one.
(define (test/anthropic/json/set-and-merge _)
(⊦= '((a 1) (b 9)) (anthropic-json/set '((a 1) (b 2)) 'b 9))
(⊦= '((a 1) (b 2)) (anthropic-json/set '((a 1)) 'b 2))
(⊦= '((a 1) (b 3) (c 4))
(anthropic-json/merge '((a 1) (b 2)) '((b 3) (c 4))))
(⊦= '((a 1)) (anthropic-json/merge '((a 1)) '()))
(⊦= "{\"output_config\":2}"
(anthropic-json/write
(anthropic-json/set
(anthropic-json/set '() "output_config" 1)
"output_config"
2)))
(⊦= "{\"model\":\"x\",\"max_tokens\":5}"
(anthropic-json/write
(anthropic-json/merge
'((model "m") (max_tokens 100))
'(("model" "x") (max_tokens 5)))))
(⊦= "{\"model\":\"claude-haiku-4-5\",\"max_tokens\":16000,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}"
(anthropic-json/write
(anthropic-request/body
(list (anthropic-message/user "hi"))
#:extra
'(("model" "claude-haiku-4-5")))))
(let1 (o (anthropic-json/set '((model "m") (max_tokens 1)) "model" "x"))
(⊦= '((model "x") (max_tokens 1)) o)
(⊦= "x" (anthropic-json/ref o 'model 'missing))
(⊦= #t (anthropic-json/has? o 'model))))
((eta 0.0) (memory #(12582912 5907144 1048576)) (stdout "") (stderr ""))
test/anthropic/request/plain: pass
(define (test/anthropic/request/plain _)
(⊦= "{\"model\":\"claude-opus-5\",\"max_tokens\":16000,\"messages\":[{\"role\":\"user\",\"content\":\"What is the capital of France?\"}]}"
(anthropic-json/write
(anthropic-request/body
(list (anthropic-message/user "What is the capital of France?"))))))
((eta 0.0) (memory #(12582912 5908416 1048576)) (stdout "") (stderr ""))
test/anthropic/request/model-and-max-tokens: pass
Model ids are complete as they stand -- never append a date suffix -- and the caller's value must reach the wire verbatim.
(define (test/anthropic/request/model-and-max-tokens _)
(let1 (body (anthropic-request/body
(list (anthropic-message/user "hi"))
#:model
"claude-haiku-4-5"
#:max-tokens
4000))
(⊦= "claude-haiku-4-5" (anthropic-json/ref body 'model))
(⊦= 4000 (anthropic-json/ref body 'max_tokens)))
(parameterize
((anthropic/model "claude-sonnet-5"))
(⊦= "claude-sonnet-5"
(anthropic-json/ref
(anthropic-request/body (list (anthropic-message/user "hi")))
'model))))
((eta 0.0) (memory #(12582912 5911352 1048576)) (stdout "") (stderr ""))
test/anthropic/request/system-is-top-level: pass
system is a sibling of messages, never a message with role system.
(define (test/anthropic/request/system-is-top-level _)
(⊦= "{\"model\":\"claude-opus-5\",\"max_tokens\":16000,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"system\":\"Be terse.\"}"
(anthropic-json/write
(anthropic-request/body
(list (anthropic-message/user "hi"))
#:system
"Be terse."))))
((eta 0.0) (memory #(12582912 5912584 1048576)) (stdout "") (stderr ""))
test/anthropic/request/omits-absent-options: pass
There is no "omit this" value in the encoder, so an absent option must simply not be a key -- (void) would encode as null, a different request. temperature, top_p and top_k are not keyword arguments at all: they are removed on claude-opus-5 and 400 if sent.
(define (test/anthropic/request/omits-absent-options _)
(let1 (body (anthropic-request/body (list (anthropic-message/user "hi"))))
(for-each
(λ (k) (⊦= 'absent (anthropic-json/ref body k 'absent)))
'(system
tools
tool_choice
stream
thinking
output_config
stop_sequences
temperature
top_p
top_k))))
((eta 0.0) (memory #(12582912 5914880 1048576)) (stdout "") (stderr ""))
test/anthropic/request/thinking-and-effort: pass
budget_tokens is removed on current models -- sending it is a 400 -- and effort lives in output_config, not in thinking.
(define (test/anthropic/request/thinking-and-effort _)
(⊦= "{\"model\":\"claude-opus-5\",\"max_tokens\":16000,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking\":{\"type\":\"adaptive\",\"display\":\"summarized\"},\"output_config\":{\"effort\":\"high\"}}"
(anthropic-json/write
(anthropic-request/body
(list (anthropic-message/user "hi"))
#:thinking
'((type "adaptive") (display "summarized"))
#:effort
"high")))
(⊦= 'absent
(anthropic-json/ref
(anthropic-json/ref
(anthropic-request/body
(list (anthropic-message/user "hi"))
#:thinking
'((type "adaptive")))
'thinking)
'budget_tokens
'absent)))
((eta 0.0) (memory #(12582912 5916120 1048576)) (stdout "") (stderr ""))
test/anthropic/request/extra-merges-rather-than-appends: pass
Appending would emit output_config twice. Duplicate keys are legal JSON and the server keeps the LAST, so the caller's effort would vanish with no error at all.
(define (test/anthropic/request/extra-merges-rather-than-appends _)
(⊦= "{\"model\":\"claude-opus-5\",\"max_tokens\":16000,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"output_config\":{\"effort\":\"low\"}}"
(anthropic-json/write
(anthropic-request/body
(list (anthropic-message/user "hi"))
#:effort
"high"
#:extra
'((output_config ((effort "low"))))))))
((eta 0.0) (memory #(12582912 5917808 1048576)) (stdout "") (stderr ""))
test/anthropic/request/tool-choice: pass
(define (test/anthropic/request/tool-choice _)
(⊦= '((type "auto")) (anthropic-tool-choice 'auto))
(⊦= '((type "any")) (anthropic-tool-choice 'any))
(⊦= '((type "none")) (anthropic-tool-choice 'none))
(⊦= '((type "tool") (name "get_weather"))
(anthropic-tool-choice '(tool "get_weather")))
(⊦= '((type "any") (disable_parallel_tool_use #t))
(anthropic-tool-choice 'any #t))
(⊦raises (anthropic-config-error) (anthropic-tool-choice 'banana))
(⊦= "{\"model\":\"claude-opus-5\",\"max_tokens\":16000,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"tool_choice\":{\"type\":\"tool\",\"name\":\"get_weather\",\"disable_parallel_tool_use\":true}}"
(anthropic-json/write
(anthropic-request/body
(list (anthropic-message/user "hi"))
#:tool-choice
'(tool "get_weather")
#:disable-parallel
#t))))
((eta 0.0) (memory #(12582912 5919576 1048576)) (stdout "") (stderr ""))
test/anthropic/request/streaming-defaults: pass
(define (test/anthropic/request/streaming-defaults _)
(let1 (body (anthropic-request/body
(list (anthropic-message/user "Write a haiku"))
#:stream
#t))
(⊦= 64000 (anthropic-json/ref body 'max_tokens))
(⊦= #t (anthropic-json/ref body 'stream)))
(⊦= 16000
(anthropic-json/ref
(anthropic-request/body (list (anthropic-message/user "hi")))
'max_tokens))
(⊦= "{\"model\":\"claude-opus-5\",\"max_tokens\":64000,\"messages\":[{\"role\":\"user\",\"content\":\"Write a haiku\"}],\"stream\":true}"
(anthropic-json/write
(anthropic-request/body
(list (anthropic-message/user "Write a haiku"))
#:stream
#t))))
((eta 0.0) (memory #(12582912 5920400 1048576)) (stdout "") (stderr ""))
test/anthropic/request/tool-result-turn: pass
A user turn may be a plain string on one message and an array of blocks on the next -- both shapes are legal in the same request.
(define (test/anthropic/request/tool-result-turn _)
(⊦= "{\"model\":\"claude-opus-5\",\"max_tokens\":16000,\"messages\":[{\"role\":\"user\",\"content\":\"weather in Paris?\"},{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_01\",\"name\":\"get_weather\",\"input\":{\"location\":\"Paris\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_01\",\"content\":\"72F and sunny\"}]}]}"
(anthropic-json/write
(anthropic-request/body
(list (anthropic-message/user "weather in Paris?")
(anthropic-message/assistant
(vector
(anthropic-block/tool-use
"toolu_01"
"get_weather"
'((location "Paris")))))
(anthropic-message/user
(list (anthropic-block/tool-result
"toolu_01"
"72F and sunny"))))))))
((eta 0.0) (memory #(12582912 5921448 1048576)) (stdout "") (stderr ""))
test/anthropic/request/stop-sequences: pass
(define (test/anthropic/request/stop-sequences _)
(⊦= "{\"model\":\"claude-opus-5\",\"max_tokens\":16000,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"stop_sequences\":[\"END\"]}"
(anthropic-json/write
(anthropic-request/body
(list (anthropic-message/user "hi"))
#:stop-sequences
'("END")))))
((eta 0.0) (memory #(12582912 5922696 1048576)) (stdout "") (stderr ""))
test/anthropic/request/headers: pass
With no betas the header must be ABSENT, not empty. No Authorization header is ever added -- sending it alongside x-api-key is a 401. And content-type is part of the transport CONTRACT, not a curl flag, so a replacement backend that faithfully sends the list it is handed sends it too.
(define (test/anthropic/request/headers _)
(parameterize
((anthropic/api-key "sk-ant-test"))
(⊦= '(("content-type" "application/json")
("x-api-key" "sk-ant-test")
("anthropic-version" "2023-06-01"))
(anthropic-request/headers))
(⊦= '(("content-type" "application/json")
("x-api-key" "sk-ant-test")
("anthropic-version" "2023-06-01")
("anthropic-beta" "files-api-2025-04-14,context-1m-2025-08-07"))
(anthropic-request/headers
#:betas
'("files-api-2025-04-14" "context-1m-2025-08-07")))))
((eta 0.0) (memory #(12582912 5924720 1048576)) (stdout "") (stderr ""))
test/anthropic/request/no-key-is-a-config-error: pass
An EMPTY ANTHROPIC_API_KEY must be treated as unset: "" is truthy in Scheme, and an export ANTHROPIC_API_KEY= that silently produced a 401 would also defeat the live suite's skip gate.
(define (test/anthropic/request/no-key-is-a-config-error _)
(parameterize
((anthropic/env-api-key (λ () #f)))
(parameterize
((anthropic/api-key #f))
(⊦raises (anthropic-config-error) (anthropic-request/headers)))
(parameterize
((anthropic/api-key ""))
(⊦raises (anthropic-config-error) (anthropic-request/headers)))
(parameterize
((anthropic/api-key #f) (anthropic/env-api-key (λ () "")))
(⊦raises (anthropic-config-error) (anthropic-request/headers)))))
((eta 0.0) (memory #(12582912 5930208 1048576)) (stdout "") (stderr ""))
test/anthropic/blocks/constructors: pass
is_error is OMITTED when false rather than sent as false, and a block-list content is normalised to a vector -- a list would encode as a JSON object and take the encoder down the key/value path with (type "text") as a key.
(define (test/anthropic/blocks/constructors _)
(⊦= '((type "text") (text "hi")) (anthropic-block/text "hi"))
(⊦= '((type "tool_use") (id "t1") (name "f") (input ((a 1))))
(anthropic-block/tool-use "t1" "f" '((a 1))))
(⊦= '((type "tool_result") (tool_use_id "t1") (content "ok"))
(anthropic-block/tool-result "t1" "ok"))
(⊦= '((type "tool_result") (tool_use_id "t1") (content "bad") (is_error #t))
(anthropic-block/tool-result "t1" "bad" #:is-error #t))
(⊦= "{\"type\":\"tool_result\",\"tool_use_id\":\"t3\",\"content\":[{\"type\":\"text\",\"text\":\"42\"}]}"
(anthropic-json/write
(anthropic-block/tool-result
"t3"
(list (anthropic-block/text "42"))))))
((eta 0.0) (memory #(12582912 5927896 1048576)) (stdout "") (stderr ""))
test/anthropic/blocks/empty-array-is-a-vector: pass
The single most likely silent 400 in the module: an empty Scheme list encodes as an empty OBJECT.
(define (test/anthropic/blocks/empty-array-is-a-vector _)
(⊦= "{\"content\":[]}" (anthropic-json/write '((content #()))))
(⊦= "{\"content\":{}}" (anthropic-json/write '((content ()))))
(⊦= #() (anthropic-content '()))
(⊦= #(1 2) (anthropic-content '(1 2)))
(⊦= "hi" (anthropic-content "hi")))
((eta 0.0) (memory #(12582912 5929464 1048576)) (stdout "") (stderr ""))
test/anthropic/messages/accessors: pass
anthropic-message-blocks always answers a LIST, and lifts a string content into one text block: match/first has no variable-length vector pattern, so nothing may walk the raw vector.
(define (test/anthropic/messages/accessors _)
(let1 (m (anthropic-message/user "hi"))
(⊦= "user" (anthropic-message-role m))
(⊦= "hi" (anthropic-message-content m))
(⊦= (list (anthropic-block/text "hi")) (anthropic-message-blocks m)))
(let1 (m (anthropic-message/assistant
(list (anthropic-block/text "a") (anthropic-block/text "b"))))
(⊦= "assistant" (anthropic-message-role m))
(⊦= 2 (length (anthropic-message-blocks m)))
(⊦= 2
(length
(anthropic-blocks/type "text" (anthropic-message-blocks m))))
(⊦= '()
(anthropic-blocks/type "tool_use" (anthropic-message-blocks m)))))
((eta 0.0) (memory #(12582912 5931528 1048576)) (stdout "") (stderr ""))
test/anthropic/messages/validate: pass
Three remote 400s turned into local errors. The TRAILING role is deliberately not checked here: a pause_turn continuation legitimately ends with an assistant message. anthropic/converse checks the trailing role of the INITIAL transcript instead.
(define (test/anthropic/messages/validate _)
(⊦raises (anthropic-config-error) (anthropic-messages/validate! '()))
(⊦raises
(anthropic-config-error)
(anthropic-messages/validate! (list (anthropic-message/assistant "hi"))))
(⊦raises
(anthropic-config-error)
(anthropic-messages/validate!
(list (anthropic-message/user "a") (anthropic-message/user "b"))))
(⊦raises
(anthropic-config-error)
(anthropic-messages/validate! (list (anthropic-message/user ""))))
(⊦raises
(anthropic-config-error)
(anthropic-messages/validate! (list (anthropic-message/user '()))))
(⊦raises
(anthropic-config-error)
(anthropic-messages/validate! (list `((role "system") (content "x")))))
(⊦= (void)
(anthropic-messages/validate!
(list (anthropic-message/user "a")
(anthropic-message/assistant "b")
(anthropic-message/assistant "c"))))
(⊦= (void)
(anthropic-messages/validate!
(list (anthropic-message/user "a")
(anthropic-message/assistant "b")
(anthropic-message/user "c")))))
((eta 0.0) (memory #(12582912 5932832 1048576)) (stdout "") (stderr ""))
test/anthropic/response/decode: pass
(define (test/anthropic/response/decode _)
(let1 (r (anthropic-response/decode 200 '() fixture/response/text))
(⊦= 200 (anthropic-response-status r))
(⊦= fixture/response/text (anthropic-response-body r))
(⊦= '() (anthropic-response-input-errors r))
(⊦= "msg_01" (anthropic-response-id r))
(⊦= "claude-opus-5" (anthropic-response-model r))
(⊦= "end_turn" (anthropic-response-stop-reason r))
(⊦= (void) (anthropic-response-stop-details r))
(⊦= #(((type "text") (text "Paris."))) (anthropic-response-content r))
(⊦= "Paris." (anthropic-response/text r))))
((eta 0.0) (memory #(12582912 5934368 1048576)) (stdout "") (stderr ""))
test/anthropic/response/decode-rejects-non-messages: pass
A 200 whose body is a well-formed JSON object but not a "type":"message" is a decode error, not a response.
(define (test/anthropic/response/decode-rejects-non-messages _)
(⊦raises
(anthropic-decode-error)
(anthropic-response/decode 200 '() fixture/error/non-json))
(⊦raises
(anthropic-decode-error)
(anthropic-response/decode 200 '() fixture/error/truncated))
(⊦raises
(anthropic-decode-error)
(anthropic-response/decode 200 '() fixture/error/400)))
((eta 0.0) (memory #(12582912 5937208 1048576)) (stdout "") (stderr ""))
test/anthropic/response/usage: pass
input_tokens is the UNCACHED remainder only; the total prompt size is the sum of the three. The cache counters are absent on a non-cached response and must default to 0, not raise.
(define (test/anthropic/response/usage _)
(let1 (r (anthropic-response/decode 200 '() fixture/response/text))
(⊦= 12 (anthropic-usage/input-tokens r))
(⊦= 4 (anthropic-usage/output-tokens r))
(⊦= 30 (anthropic-usage/cache-read-tokens r))
(⊦= 0 (anthropic-usage/cache-creation-tokens r))
(⊦= 42 (anthropic-usage/prompt-tokens r)))
(let1 (r (anthropic-response/decode 200 '() fixture/response/tool-use))
(⊦= 0 (anthropic-usage/cache-read-tokens r))
(⊦= 40 (anthropic-usage/prompt-tokens r))
(⊦= 7 (anthropic-usage/ref r 'nope 7))))
((eta 0.0) (memory #(12582912 5937776 1048576)) (stdout "") (stderr ""))
test/anthropic/response/text-folds-every-block: pass
A refusal can arrive with an EMPTY content array, so nothing may index content[0].
(define (test/anthropic/response/text-folds-every-block _)
(⊦= "Paris."
(anthropic-response/text
(anthropic-response/decode 200 '() fixture/response/text)))
(⊦= "Let me check the weather."
(anthropic-response/text
(anthropic-response/decode 200 '() fixture/response/tool-use)))
(⊦= "Done."
(anthropic-response/text
(anthropic-response/decode 200 '() fixture/response/thinking)))
(⊦= ""
(anthropic-response/text
(anthropic-response/decode 200 '() fixture/response/refusal)))
(⊦= '()
(anthropic-response/blocks
(anthropic-response/decode 200 '() fixture/response/refusal))))
((eta 0.001) (memory #(12582912 5937152 1048576)) (stdout "") (stderr ""))
test/anthropic/response/tool-uses-and-thinking: pass
(define (test/anthropic/response/tool-uses-and-thinking _)
(let1 (r (anthropic-response/decode
200
'()
fixture/response/parallel-tool-use))
(⊦= '() (anthropic-blocks/type "text" (anthropic-response/blocks r)))
(⊦= 2 (length (anthropic-response/tool-uses r)))
(⊦= '("toolu_01" "toolu_02")
(map (λ (b) (anthropic-json/ref b 'id))
(anthropic-response/tool-uses r)))
(⊦= '((location "Paris"))
(anthropic-json/ref
(first (anthropic-response/tool-uses r))
'input)))
(let1 (r (anthropic-response/decode 200 '() fixture/response/thinking))
(⊦= 1 (length (anthropic-response/thinking r)))
(⊦= ""
(anthropic-json/ref
(first (anthropic-response/thinking r))
'thinking))
(⊦= "EqQBCgIYAhIM"
(anthropic-json/ref
(first (anthropic-response/thinking r))
'signature))))
((eta 0.001) (memory #(12582912 5940736 1048576)) (stdout "") (stderr ""))
test/anthropic/response/refusal-branches-on-stop-reason-only: pass
stop_details is informational and is null even on some refusals, so a predicate that tested it would silently classify a real refusal as a success.
(define (test/anthropic/response/refusal-branches-on-stop-reason-only _)
(⊨ (anthropic-response/refusal?
(anthropic-response/decode 200 '() fixture/response/refusal)))
(⊨ (anthropic-response/refusal?
(anthropic-response/decode
200
'()
fixture/response/refusal/null-details)))
(⊭ (anthropic-response/refusal?
(anthropic-response/decode 200 '() fixture/response/text)))
(⊦= '((category "cyber") (explanation "declined"))
(anthropic-response-stop-details
(anthropic-response/decode 200 '() fixture/response/refusal)))
(⊦= (void)
(anthropic-response-stop-details
(anthropic-response/decode
200
'()
fixture/response/refusal/null-details))))
((eta 0.0) (memory #(12582912 5939944 1048576)) (stdout "") (stderr ""))
test/anthropic/response/unknown-block-survives: pass
Block dispatch needs a total fallthrough: server-tool and fallback blocks must be preserved verbatim, not crash the walker and not be dropped from a replayed turn.
(define (test/anthropic/response/unknown-block-survives _)
(let1 (r (anthropic-response/decode 200 '() fixture/response/unknown-block))
(⊦= "" (anthropic-response/text r))
(⊦= 1 (vector-length (anthropic-response-content r)))
(⊦= "fallback"
(anthropic-block-type (first (anthropic-response/blocks r))))
(⊦= "no_capacity"
(anthropic-json/ref
(first (anthropic-response/blocks r))
'reason))))
((eta 0.0) (memory #(12582912 5943600 1048576)) (stdout "") (stderr ""))
test/anthropic/response/message-is-verbatim: pass
The assistant turn that goes back is the DECODED content, untouched. A thinking block whose text is the empty string must be replayed exactly as received: the API rejects blocks whose content has been MODIFIED, and a dropped block can trigger an ordering or signature 400.
(define (test/anthropic/response/message-is-verbatim _)
(let* ((r (anthropic-response/decode 200 '() fixture/response/thinking))
(m (anthropic-response/message r)))
(⊦= "assistant" (anthropic-message-role m))
(⊦= (anthropic-response-content r) (anthropic-message-content m))
(⊦= (anthropic-json/ref (anthropic-response-json r) 'content)
(anthropic-json/ref
(anthropic-json/parse (anthropic-json/write m))
'content))))
((eta 0.0) (memory #(12582912 5943040 1048576)) (stdout "") (stderr ""))
test/anthropic/response/request-id: pass
The header wins over the body. Neither was observed on a live 401 during the design pass, so nothing may DEPEND on it -- but it is what a user quotes to support, and throwing it away is worse than reading it defensively.
(define (test/anthropic/response/request-id _)
(⊦= "req_hdr"
(anthropic-response-request-id
(anthropic-response/decode
200
'(("request-id" "req_hdr"))
fixture/response/text)))
(⊦= #f
(anthropic-response-request-id
(anthropic-response/decode 200 '() fixture/response/text))))
((eta 0.0) (memory #(12582912 5944224 1048576)) (stdout "") (stderr ""))
test/anthropic/headers/parse: pass
The split is on the FIRST colon, so a Date value's own colons survive, and the name is lowercased on the way in so that lookups can be exact.
(define (test/anthropic/headers/parse _)
(⊦= '("retry-after" "7") (anthropic-header/parse "Retry-After: 7"))
(⊦= '("date" "Tue, 16 Sep 2026 00:00:00 GMT")
(anthropic-header/parse "Date: Tue, 16 Sep 2026 00:00:00 GMT"))
(⊦= '("content-type" "text/event-stream")
(anthropic-header/parse "content-type:text/event-stream"))
(⊦= #f (anthropic-header/parse "no colon here"))
(⊦= #f (anthropic-header/parse ":status"))
(⊦= "7" (anthropic-header/ref '(("retry-after" "7")) "Retry-After"))
(⊦= #f (anthropic-header/ref '(("retry-after" "7")) "x-should-retry"))
(⊦= 'd (anthropic-header/ref '() "anything" 'd)))
((eta 0.0) (memory #(12582912 5946480 1048576)) (stdout "") (stderr ""))
test/anthropic/headers/injection-guard: pass
A header value carrying CR/LF appends headers of the attacker's choosing to the request. The check is at BOTH ends -- in anthropic-request/headers, so every transport inherits it, and again in anthropic-curl/headers->file!, which is public and takes a header list from anywhere -- and each call site is asserted independently, because deleting either one on its own left the suite green.
(define (test/anthropic/headers/injection-guard _)
(⊦raises
(anthropic-config-error)
(anthropic-header/check! "x-api-key" "sk\r\nEvil: 1"))
(⊦raises
(anthropic-config-error)
(anthropic-header/check! "x-api\nkey" "sk"))
(⊦= (void) (anthropic-header/check! "x-api-key" "sk-ant-ok"))
(parameterize
((anthropic/api-key "sk-ant-ok\r\nx-evil: 1"))
(⊦raises (anthropic-config-error) (anthropic-request/headers)))
(parameterize
((anthropic/api-key "sk-ant-ok") (anthropic/betas '("beta\nx-evil: 1")))
(⊦raises (anthropic-config-error) (anthropic-request/headers)))
(parameterize
((anthropic/api-key "sk-ant-ok")
(anthropic/version "2023-06-01\rx-evil: 1"))
(⊦raises (anthropic-config-error) (anthropic-request/headers)))
(parameterize
((anthropic/api-key "sk-ant-ok"))
(⊦= '(("content-type" "application/json")
("x-api-key" "sk-ant-ok")
("anthropic-version" "2023-06-01"))
(anthropic-request/headers))))
((eta 0.001) (memory #(12582912 5949816 1048576)) (stdout "") (stderr ""))
test/anthropic/status/classification: pass
A range test, not membership in {429,500,529}: the whole 5xx class is the server-error class, while 402 billing_error and 413 request_too_large are correctly excluded. x-should-retry was NOT observed on a live response, so it is read defensively and nothing depends on it.
(define (test/anthropic/status/classification _)
(⊨ (anthropic-status/ok? 200))
(⊨ (anthropic-status/ok? 299))
(⊭ (anthropic-status/ok? 300))
(⊭ (anthropic-status/ok? 199))
(for-each
(λ (s) (⊨ (anthropic-status/retryable? s '())))
'(429 500 502 529 599))
(for-each
(λ (s) (⊭ (anthropic-status/retryable? s '())))
'(400 401 402 403 404 413))
(⊭ (anthropic-status/retryable? 500 '(("x-should-retry" "false"))))
(⊨ (anthropic-status/retryable? 400 '(("x-should-retry" "true")))))
((eta 0.0) (memory #(12582912 5951336 1048576)) (stdout "") (stderr ""))
test/anthropic/retry/delay-schedule: pass
retry-after wins, but it is capped like everything else: an uncapped header of 86400 would put the client to sleep for a day, three times.
(define (test/anthropic/retry/delay-schedule _)
(parameterize
((anthropic/backoff-jitter #f)
(anthropic/backoff 1.0)
(anthropic/backoff-cap 30.0))
(⊦= 1.0 (anthropic-retry/delay 0 '()))
(⊦= 2.0 (anthropic-retry/delay 1 '()))
(⊦= 4.0 (anthropic-retry/delay 2 '()))
(⊦= 30.0 (anthropic-retry/delay 9 '()))
(⊦= 7.0 (anthropic-retry/delay 0 '(("retry-after" "7"))))
(⊦= 30.0 (anthropic-retry/delay 0 '(("retry-after" "86400"))))
(⊦= 1.0 (anthropic-retry/delay 0 '(("retry-after" "not-a-number"))))))
((eta 0.0) (memory #(12582912 5953088 1048576)) (stdout "") (stderr ""))
test/anthropic/retry/jitter-stays-inside-the-window: pass
Full jitter, uniform in [0, window), decorrelates a herd of clients that were rate-limited together. The parameter exists so that the schedule above is deterministic under test.
(define (test/anthropic/retry/jitter-stays-inside-the-window _)
(parameterize
((anthropic/backoff-jitter #t)
(anthropic/backoff 1.0)
(anthropic/backoff-cap 30.0))
(for-each
(λ (n)
(let1 (d (anthropic-retry/delay 2 '()))
(⊨ (and (>= d 0.0) (< d 4.001)))))
(ι 20))))
((eta 0.0) (memory #(12582912 5954848 1048576)) (stdout "") (stderr ""))
test/anthropic/transport/diagnosis-and-transient: pass
6 (DNS) and 35 (TLS) are permanent for this request; 7, 18, 28, 52, 55 and 56 are transient. Getting the split wrong costs a retry that cannot help, or a retry that was never attempted. 1, 126 and 127 are local mistakes, never transient: a missing curl surfaces as exit 126 with CHICKEN's own cannot execute process text on the child's stderr -- process* does NOT raise, so nothing else would tell you.
(define (test/anthropic/transport/diagnosis-and-transient _)
(for-each (λ (c) (⊨ (anthropic-transport/transient? c))) '(7 18 28 52 55 56))
(for-each
(λ (c) (⊭ (anthropic-transport/transient? c)))
'(0 1 6 22 23 35 126 127))
(⊦= "could not resolve host" (anthropic-transport/diagnosis 6))
(⊦= "could not connect" (anthropic-transport/diagnosis 7))
(⊦≠ #f
(substring-index
"could not be executed"
(anthropic-transport/diagnosis 126)))
(⊦≠ #f (substring-index "999" (anthropic-transport/diagnosis 999)))
(⊨ (eq? anthropic-transport/transient? anthropic-curl/transient?))
(⊨ (eq? anthropic-transport/diagnosis anthropic-curl/diagnosis)))
((eta 0.0) (memory #(12582912 5954136 1048576)) (stdout "") (stderr ""))
test/anthropic/curl/status-line: pass
HTTP/2 omits the reason phrase and leaves a trailing space; HTTP/1.1 keeps it. Both forms must parse, and a header line must not be mistaken for one.
(define (test/anthropic/curl/status-line _)
(⊦= 429 (anthropic-curl/status-line "HTTP/2 429 "))
(⊦= 200 (anthropic-curl/status-line "HTTP/1.1 200 OK"))
(⊦= 100 (anthropic-curl/status-line "HTTP/1.1 100 Continue"))
(⊦= 503 (anthropic-curl/status-line "HTTP/2 503"))
(⊦= #f (anthropic-curl/status-line "content-type: application/json"))
(⊦= #f (anthropic-curl/status-line "HTTP/2 banana"))
(⊦= #f (anthropic-curl/status-line "")))
((eta 0.0) (memory #(12582912 5955816 1048576)) (stdout "") (stderr ""))
test/anthropic/curl/read-headers-skips-1xx: pass
The port is left positioned at the FIRST BODY BYTE, which is the whole point of --include: the same port then goes either to the slurper or to the SSE decoder, and the blocking and streaming paths are identical up to here. A 100 Continue prelude is its own header block and must not become the answer.
(define (test/anthropic/curl/read-headers-skips-1xx _)
(let1 (p (open-input-string
(conc "HTTP/1.1 100 Continue\r\n\r\n"
"HTTP/2 429 \r\n"
"Content-Type: application/json\r\n"
"Retry-After: 3\r\n"
"Date: Tue, 16 Sep 2026 00:00:00 GMT\r\n"
"\r\n"
"{\"type\":\"error\"}")))
(receive
(status headers)
(anthropic-curl/read-headers p)
(⊦= 429 status)
(⊦= '(("content-type" "application/json")
("retry-after" "3")
("date" "Tue, 16 Sep 2026 00:00:00 GMT"))
headers)
(⊦= "{\"type\":\"error\"}" (anthropic-port->string p)))))
((eta 0.0) (memory #(12582912 5958536 1048576)) (stdout "") (stderr ""))
test/anthropic/curl/argv-never-carries-the-key: pass
Neither the key nor the prompt is ever an argv element, so neither appears in ps. The header file is created under a temporarily tightened file-creation-mode of #o077 -- create-temporary-file otherwise yields 0644 and chmod-after-create leaves a window in which the key is world readable. --fail-with-body is absent on purpose: with it curl exits 22 for every HTTP error and a 429 becomes indistinguishable from a 404. A refused header leaves no file behind, which the registry reader anthropic-temp/pending is what checks.
(define (test/anthropic/curl/argv-never-carries-the-key _)
(parameterize
((anthropic/api-key anthropic-test/key))
(let1 (path (anthropic-curl/headers->file! (anthropic-request/headers)))
(⊦= 384 (file-permissions path))
(⊦= "content-type: application/json\nx-api-key: sk-ant-api03-TESTKEY\nanthropic-version: 2023-06-01\n"
(with-input-from-file
path
(τ (anthropic-port->string (current-input-port)))))
(let1 (argv (anthropic-curl/argv
"https://example.invalid/v1/messages"
path
#f))
(⊭ (any (λ (a) (and (substring-index anthropic-test/key a) #t))
argv))
(⊨ (member? "--data-binary" argv))
(⊨ (member? "@-" argv))
(⊨ (member? "--include" argv))
(⊨ (member? (conc "@" path) argv))
(⊨ (member? "https://example.invalid/v1/messages" argv))
(⊭ (member? "--fail-with-body" argv))
(⊭ (member? "--location" argv))
(⊭ (member? "--no-buffer" argv)))
(⊨ (member?
"--no-buffer"
(anthropic-curl/argv "https://x/" path #t)))
(⊭ (symbolic-link? path))
(⊨ (regular-file? path))
(⊨ (member? path (anthropic-temp/pending)))
(anthropic-temp/delete! path)
(⊭ (file-exists? path))
(⊭ (member? path (anthropic-temp/pending)))))
(⊦raises
(anthropic-config-error)
(anthropic-curl/headers->file! '(("x-api-key" "sk\r\nEvil: 1"))))
(⊦raises
(anthropic-config-error)
(anthropic-curl/headers->file! '(("x-api\nkey" "sk-ant-ok"))))
(⊦= '() (anthropic-temp/pending)))
((eta 0.001) (memory #(12582912 5962608 1048576)) (stdout "") (stderr ""))
test/anthropic/curl/url-is-an-argv-element: pass
The key and the prompt are kept out of argv, but the URL cannot be -- so it is the one argv element that needs a guard of its own. Plain http:// stays allowed: the offline transport probes and any local proxy need it, and the https://-only decision belongs to the caller who sets anthropic/base-url.
(define (test/anthropic/curl/url-is-an-argv-element _)
(⊦raises
(anthropic-config-error)
(anthropic-curl/argv "-K/etc/hostname" "/tmp/h" #f))
(⊦raises
(anthropic-config-error)
(anthropic-url/check! "--output=/etc/passwd"))
(⊦raises
(anthropic-config-error)
(anthropic-url/check! "file:///etc/passwd"))
(⊦raises
(anthropic-config-error)
(anthropic-url/check! "https://x/\r\nHost: evil"))
(⊦raises (anthropic-config-error) (anthropic-url/check! 'not-a-string))
(⊦= "https://api.anthropic.com/v1/messages"
(anthropic-url/check! "https://api.anthropic.com/v1/messages"))
(⊦= "http://127.0.0.1:8080/v1/messages"
(anthropic-url/check! "http://127.0.0.1:8080/v1/messages"))
(parameterize
((anthropic/api-key anthropic-test/key))
(⊦raises
(anthropic-config-error)
(anthropic-transport/curl
"-K/etc/hostname"
(anthropic-request/headers)
"{}"
#f))
(⊦= '() (anthropic-temp/pending))))
((eta 0.0) (memory #(12582912 5965192 1048576)) (stdout "") (stderr ""))
test/anthropic/transport/body-write-survives-a-dead-reader: pass
SIGPIPE's default action kills the process, so a body written to a transport child that already exited took the whole program down -- no handler ran and the 0600 header file with the key stayed on disk. The write now runs with SIGPIPE ignored, which leaves an ordinary error the transport swallows before reading the child's exit status. The disposition is restored when the last of the concurrent writers leaves, not the first, so one thread cannot unguard another.
(define (test/anthropic/transport/body-write-survives-a-dead-reader _)
(receive
(in out)
(create-pipe)
(file-close in)
(let1 (kills? (anthropic-signal/pipe-kills?))
(⊦= 'epipe
(anthropic-signal/without-sigpipe
(τ (condition-case
(begin
(file-write out (make-bytevector 100000 120))
'written)
((exn) 'epipe)))))
(file-close out)
(⊦= kills? (anthropic-signal/pipe-kills?))
(anthropic-signal/enter!)
(anthropic-signal/enter!)
(anthropic-signal/leave!)
(⊦= #f (anthropic-signal/pipe-kills?))
(anthropic-signal/leave!)
(⊦= kills? (anthropic-signal/pipe-kills?)))))
((eta 0.001) (memory #(12582912 5967896 1048576)) (stdout "") (stderr ""))
test/anthropic/transport/wire-record: pass
anthropic-wire/drain! looks only at the transport's exit status; anthropic-wire->body! adds the HTTP status check on top. Splitting them is what lets the streaming path report a cut connection without pretending the HTTP response was an error. The field is called origin and not command because the stub puts (stub URL) there, which is not a command. Note (read-string #f port) answers the EOF OBJECT, not "", on a port that produced nothing.
(define (test/anthropic/transport/wire-record _)
(let1 (w (make-anthropic-wire
200
'(("content-type" "application/json"))
'(curl "--include")
(open-input-string fixture/response/text)
(τ (values 0 ""))))
(⊨ (anthropic-wire? w))
(⊦= 200 (anthropic-wire-status w))
(⊦= '(curl "--include") (anthropic-wire-origin w))
(⊦= fixture/response/text (anthropic-wire->body! w)))
(let1 (w (make-anthropic-wire
500
'()
'(curl)
(open-input-string fixture/error/529)
(τ (values 0 ""))))
(⊦raises/api (500 "overloaded_error") (anthropic-wire->body! w)))
(let1 (w (make-anthropic-wire
200
'()
'(stub "https://x/")
(open-input-string "")
(τ (values 28 "curl: (28) timed out"))))
(⊦raises (anthropic-transport-error) (anthropic-wire/drain! w)))
(parameterize
((anthropic/api-key anthropic-test/key))
(let1 (w (make-anthropic-wire
200
'()
'(stub "https://x/")
(open-input-string "")
(τ (values
7
(conc "curl: (7) sent x-api-key: " anthropic-test/key)))))
(let1 (stored
(condition-case
(begin (anthropic-wire/drain! w) "no-error")
(c (anthropic-transport-error)
(get-condition-property
c
'anthropic-transport-error
'diagnostics))))
(⊭ (substring-index anthropic-test/key stored))
(⊦≠ #f (substring-index "<redacted>" stored))))))
((eta 0.0) (memory #(12582912 5967920 1048576)) (stdout "") (stderr ""))
test/anthropic/transport/port->string-over-a-custom-port: pass
Every blocking body in this module funnels through anthropic-port->string, which is exactly (read-string #f port). Nothing notices today -- the process backend hands back a real file port and the stub hands back a string port, and both are correct across multibyte input, 1 MB bodies and a character straddling the 4096-byte seam. A hand-built port is the one that lies, and it lies quietly: no exception, just NUL padding where the JSON was. So §2 makes read-bytevector: part of the transport contract rather than advice.
(define (test/anthropic/transport/port->string-over-a-custom-port _)
(let1 (payload "{\"ok\":true}")
(let1 (fixture-port
(τ (let1 (i 0)
(make-input-port
(τ (if (< i (string-length payload))
(let1 (c (string-ref payload i))
(set! i (add1 i))
c)
#!eof))
(τ (< i (string-length payload)))
(τ (void))
#:peek
(τ (if (< i (string-length payload))
(string-ref payload i)
#!eof))
#:read-bytevector
(λ (bv start n)
(let L ((k 0))
(if (or (>= k n) (>= i (string-length payload)))
k
(begin
(bytevector-u8-set!
bv
(+ start k)
(char->integer (string-ref payload i)))
(set! i (add1 i))
(L (add1 k))))))))))
(⊦= payload (anthropic-port->string (fixture-port)))
(⊦= payload (anthropic-port->string (open-input-string payload)))
(⊦= "" (anthropic-port->string (open-input-string ""))))))
((eta 0.0) (memory #(12582912 5970440 1048576)) (stdout "") (stderr ""))
test/anthropic/transport/drain-closes-the-wire-when-the-read-raises: pass
A raise from the body read -- not an anthropic-error, so it passes the retry loop untouched -- used to skip the close, leaving the curl child and its descriptors behind. The blocking drain now closes in a dynamic-wind after-thunk, like the streaming path.
(define (test/anthropic/transport/drain-closes-the-wire-when-the-read-raises _)
(let* ((closes 0)
(failing-port
(τ (make-input-port
(τ (error "simulated EIO on body read"))
(τ #t)
(τ (void))
#:read-bytevector
(λ (bv start n) (error "simulated EIO on body read")))))
(transport
(λ (status)
(λ (url headers body stream?)
(make-anthropic-wire
status
'(("content-type" "application/json"))
'(failing)
(failing-port)
(τ (add1! closes) (values 0 ""))))))
(raw-error?
(λ (thunk)
(condition-case
(begin (thunk) #f)
((anthropic-error) #f)
((exn) #t)))))
(parameterize
((anthropic/api-key anthropic-test/key) (anthropic/retries 0))
(parameterize
((anthropic/transport (transport 200)))
(⊨ (raw-error?
(τ (anthropic/messages (list (anthropic-message/user "hi"))))))
(⊦= 1 closes))
(parameterize
((anthropic/transport (transport 500)))
(⊨ (raw-error?
(τ (anthropic/messages (list (anthropic-message/user "hi"))))))
(⊦= 2 closes)))))
((eta 0.0) (memory #(12582912 5977800 1048576)) (stdout "") (stderr ""))
test/anthropic/entry-points-forward-every-request-option: pass
The request options are defined once, by anthropic-request/body, and the entry points forward them as they came. This pins that every one of them reaches the wire from each entry point, so an option added to the body cannot be silently dropped by one of them.
(define (test/anthropic/entry-points-forward-every-request-option _)
(let* ((msgs (list (anthropic-message/user "weather in Paris?")))
(options
(list #:model
"claude-haiku-4-5"
#:max-tokens
123
#:system
"Be terse."
#:tools
(list get_weather/tool)
#:tool-choice
'auto
#:disable-parallel
#t
#:thinking
'((type "adaptive"))
#:effort
"low"
#:stop-sequences
'("END")
#:extra
'((metadata ((user_id "u1"))))))
(expected
(λ (stream?)
(anthropic-json/parse
(anthropic-json/write
(apply anthropic-request/body
msgs
#:stream
stream?
options))))))
(letstub
(stub (anthropic-stub/canned fixture/response/text))
(apply anthropic/messages msgs #:stream #t options)
(⊦= (list (expected #f)) (anthropic-stub/requests stub)))
(letstub
(stub (anthropic-stub/canned fixture/response/text))
(apply anthropic/converse msgs options)
(⊦= (list (expected #f)) (anthropic-stub/requests stub)))
(letstub
(stub (anthropic-stub/canned
fixture/sse/text
#:headers
'(("content-type" "text/event-stream"))))
(apply anthropic/stream msgs #:stream #f options)
(⊦= (list (expected #t)) (anthropic-stub/requests stub)))))
((eta 0.002) (memory #(12582912 5976832 1048576)) (stdout "") (stderr ""))
test/anthropic/messages/create: pass
The headers come back redacted: a failing assertion over them would otherwise print a live key into the HTML report this suite writes.
(define (test/anthropic/messages/create _)
(letstub
(stub (anthropic-stub/canned fixture/response/text))
(let1 (r (anthropic/messages
(list (anthropic-message/user "capital of France?"))))
(⊦= "end_turn" (anthropic-response-stop-reason r))
(⊦= "Paris." (anthropic-response/text r))
(⊦= 1 (anthropic-stub/count stub))
(⊦= '((model "claude-opus-5")
(max_tokens 16000)
(messages #(((role "user") (content "capital of France?")))))
(first (anthropic-stub/requests stub)))
(let1 (call (first (anthropic-stub/calls stub)))
(⊦= "https://api.anthropic.com/v1/messages" (first call))
(⊦= '(("content-type" "application/json")
("x-api-key" "<redacted>")
("anthropic-version" "2023-06-01"))
(second call))
(⊦= #f (fourth call))))))
((eta 0.0) (memory #(12582912 5982120 1048576)) (stdout "") (stderr ""))
test/anthropic/messages/ask: pass
(define (test/anthropic/messages/ask _)
(letstub
(stub (anthropic-stub/canned fixture/response/text))
(⊦= "Paris." (anthropic/ask "capital of France?"))
(⊦= 1 (anthropic-stub/count stub)))
(letstub
(stub (anthropic-stub/canned fixture/response/text))
(⊦= "Paris."
(anthropic/ask "hi" #:model "claude-haiku-4-5" #:max-tokens 4000))
(⊦= "claude-haiku-4-5"
(anthropic-json/ref (first (anthropic-stub/requests stub)) 'model))
(⊦= 4000
(anthropic-json/ref
(first (anthropic-stub/requests stub))
'max_tokens))))
((eta 0.0) (memory #(12582912 5974704 1048576)) (stdout "") (stderr ""))
test/anthropic/messages/betas-header: pass
(define (test/anthropic/messages/betas-header _)
(letstub
(stub (anthropic-stub/canned fixture/response/text))
(anthropic/messages
(list (anthropic-message/user "hi"))
#:betas
'("files-api-2025-04-14"))
(⊦= '(("content-type" "application/json")
("x-api-key" "<redacted>")
("anthropic-version" "2023-06-01")
("anthropic-beta" "files-api-2025-04-14"))
(second (first (anthropic-stub/calls stub))))))
((eta 0.0) (memory #(12582912 5977184 1048576)) (stdout "") (stderr ""))
test/anthropic/messages/stub-exhaustion-raises: pass
An unscripted call is an error, not a silent replay of the last canned response. That is what lets a loop test PROVE the loop terminated.
(define (test/anthropic/messages/stub-exhaustion-raises _)
(letstub
(stub (anthropic-stub/canned fixture/response/text))
(anthropic/messages (list (anthropic-message/user "hi")))
(⊦raises
(anthropic-config-error)
(anthropic/messages (list (anthropic-message/user "hi"))))))
((eta 0.0) (memory #(12582912 5978384 1048576)) (stdout "") (stderr ""))
test/anthropic/errors/400-is-not-retried: pass
(define (test/anthropic/errors/400-is-not-retried _)
(letstub
(stub (anthropic-stub/canned fixture/error/400 #:status 400))
(⊦raises/api
(400 "invalid_request_error")
(anthropic/messages (list (anthropic-message/user "hi"))))
(⊦= 1 (anthropic-stub/count stub))))
((eta 0.0) (memory #(12582912 5984120 1048576)) (stdout "") (stderr ""))
test/anthropic/errors/401-and-404-are-not-retried: pass
(define (test/anthropic/errors/401-and-404-are-not-retried _)
(letstub
(stub (anthropic-stub/canned fixture/error/401 #:status 401))
(⊦raises/api
(401 "authentication_error")
(anthropic/messages (list (anthropic-message/user "hi"))))
(⊦= 1 (anthropic-stub/count stub)))
(letstub
(stub (anthropic-stub/canned fixture/error/404 #:status 404))
(⊦raises/api
(404 "not_found_error")
(anthropic/messages
(list (anthropic-message/user "hi"))
#:model
"claude-does-not-exist"))
(⊦= 1 (anthropic-stub/count stub))))
((eta 0.0) (memory #(12582912 5985216 1048576)) (stdout "") (stderr ""))
test/anthropic/errors/properties: pass
anthropic-error-ref reads a property off whichever specific kind the condition carries, without the caller naming that kind.
(define (test/anthropic/errors/properties _)
(letstub
(stub (anthropic-stub/canned fixture/error/400 #:status 400))
(⊦= (list "messages: roles must alternate between \"user\" and \"assistant\""
"req_011CSHoEeqs5C35K2UUqR7Fy"
#f
'anthropic-api-error)
(condition-case
(begin
(anthropic/messages (list (anthropic-message/user "hi")))
'no-error)
(c (anthropic-api-error)
(list (anthropic-error-ref c 'api-message)
(anthropic-error-ref c 'request-id)
(anthropic-error-retryable? c)
(get-condition-property c 'anthropic-error 'kind)))))))
((eta 0.0) (memory #(12582912 5982928 1048576)) (stdout "") (stderr ""))
test/anthropic/errors/family-predicates: pass
Every condition the module signals carries the kinds (exn anthropic-error KIND), so ⊦⧳ ((exn)) still catches it, the family predicate catches it, and condition-case on the specific kind reaches its properties.
(define (test/anthropic/errors/family-predicates _)
(letstub
(stub (anthropic-stub/canned fixture/error/400 #:status 400))
(⊦= '(#t #t #f)
(condition-case
(begin
(anthropic/messages (list (anthropic-message/user "hi")))
'no-error)
(c (anthropic-api-error)
(list (anthropic-error? c)
(anthropic-api-error? c)
(anthropic-transport-error? c))))))
(⊦raises (exn) (anthropic-json/parse "nope"))
(⊦raises (anthropic-error) (anthropic-json/parse "nope")))
((eta 0.0) (memory #(12582912 5986760 1048576)) (stdout "") (stderr ""))
test/anthropic/errors/non-json-body-never-reaches-simdjson: pass
A proxy's HTML page and a connection cut mid-body must both become this module's conditions. Neither may reach the parser, whose own errors are plain Scheme errors that no anthropic-error handler would see.
(define (test/anthropic/errors/non-json-body-never-reaches-simdjson _)
(parameterize
((anthropic/retries 0))
(letstub
(stub (anthropic-stub/canned
fixture/error/non-json
#:status
502
#:headers
'(("content-type" "text/html"))))
(⊦raises/api
(502 "api_error")
(anthropic/messages (list (anthropic-message/user "hi"))))))
(parameterize
((anthropic/retries 0))
(letstub
(stub (anthropic-stub/canned fixture/error/truncated #:status 200))
(⊦raises
(anthropic-decode-error)
(anthropic/messages (list (anthropic-message/user "hi")))))))
((eta 0.0) (memory #(12582912 5989168 1048576)) (stdout "") (stderr ""))
test/anthropic/retry/429-then-200: pass
(define (test/anthropic/retry/429-then-200 _)
(let1 (waits '())
(parameterize
((anthropic/sleep (λ (s) (push! s waits))) (anthropic/retries 5))
(letstub
(stub (anthropic-stub/canned fixture/error/429 #:status 429)
(anthropic-stub/canned fixture/error/429 #:status 429)
(anthropic-stub/canned fixture/response/text))
(⊦= "Paris."
(anthropic-response/text
(anthropic/messages (list (anthropic-message/user "hi")))))
(⊦= 3 (anthropic-stub/count stub))
(⊦= '(1.0 2.0) (reverse waits))))))
((eta 0.0) (memory #(12582912 5985656 1048576)) (stdout "") (stderr ""))
test/anthropic/retry/honours-retry-after: pass
The header is written Retry-After in the fixture on purpose: anthropic-stub/canned lowercases names so that the stub and the curl backend agree. Without that, a test written the natural way would silently take the backoff path.
(define (test/anthropic/retry/honours-retry-after _)
(let1 (waits '())
(parameterize
((anthropic/sleep (λ (s) (push! s waits))))
(letstub
(stub (anthropic-stub/canned
fixture/error/429
#:status
429
#:headers
'(("Retry-After" "7")))
(anthropic-stub/canned fixture/response/text))
(anthropic/messages (list (anthropic-message/user "hi")))
(⊦= '(7.0) (reverse waits))))))
((eta 0.0) (memory #(12582912 5988680 1048576)) (stdout "") (stderr ""))
test/anthropic/retry/gives-up-after-max: pass
(define (test/anthropic/retry/gives-up-after-max _)
(let1 (waits '())
(parameterize
((anthropic/sleep (λ (s) (push! s waits))) (anthropic/retries 2))
(letstub
(stub (anthropic-stub/canned fixture/error/529 #:status 529)
(anthropic-stub/canned fixture/error/529 #:status 529)
(anthropic-stub/canned fixture/error/529 #:status 529))
(⊦raises/api
(529 "overloaded_error")
(anthropic/messages (list (anthropic-message/user "hi"))))
(⊦= 3 (anthropic-stub/count stub))
(⊦= '(1.0 2.0) (reverse waits))))))
((eta 0.0) (memory #(12582912 5990720 1048576)) (stdout "") (stderr ""))
test/anthropic/retry/400-is-never-slept-on: pass
(define (test/anthropic/retry/400-is-never-slept-on _)
(let1 (waits '())
(parameterize
((anthropic/sleep (λ (s) (push! s waits))) (anthropic/retries 5))
(letstub
(stub (anthropic-stub/canned fixture/error/400 #:status 400))
(⊦raises
(anthropic-api-error)
(anthropic/messages (list (anthropic-message/user "hi"))))
(⊦= 1 (anthropic-stub/count stub))
(⊦= '() waits)))))
((eta 0.0) (memory #(12582912 5996120 1048576)) (stdout "") (stderr ""))
test/anthropic/retry/x-should-retry-false-stops: pass
(define (test/anthropic/retry/x-should-retry-false-stops _)
(let1 (waits '())
(parameterize
((anthropic/sleep (λ (s) (push! s waits))) (anthropic/retries 5))
(letstub
(stub (anthropic-stub/canned
fixture/error/529
#:status
500
#:headers
'(("x-should-retry" "false"))))
(⊦raises
(anthropic-api-error)
(anthropic/messages (list (anthropic-message/user "hi"))))
(⊦= 1 (anthropic-stub/count stub))
(⊦= '() waits)))))
((eta 0.0) (memory #(12582912 5997152 1048576)) (stdout "") (stderr ""))
test/anthropic/retry/truncated-2xx-body: pass
curl's exit 18, a transfer that ended prematurely, is marked retryable -- and a 5xx wire carrying it was retried -- but a 2xx body was read only after the retry loop had returned. It is now read inside the loop.
(define (test/anthropic/retry/truncated-2xx-body _)
(let ((calls 0) (waits '()))
(parameterize
((anthropic/api-key anthropic-test/key)
(anthropic/sleep (λ (s) (push! s waits)))
(anthropic/retries 3)
(anthropic/backoff-jitter #f)
(anthropic/transport
(λ (url headers body stream?)
(add1! calls)
(if (= calls 1)
(make-anthropic-wire
200
'(("content-type" "application/json"))
'(cut)
(open-input-string "{\"id\":\"msg_01\",\"ty")
(τ (values 18 "curl: (18) transfer closed")))
(make-anthropic-wire
200
'(("content-type" "application/json"))
'(ok)
(open-input-string fixture/response/text)
(τ (values 0 "")))))))
(⊦= "Paris."
(anthropic-response/text
(anthropic/messages (list (anthropic-message/user "hi")))))
(⊦= 2 calls)
(⊦= '(1.0) waits))))
((eta 0.001) (memory #(12582912 5993056 1048576)) (stdout "") (stderr ""))
test/anthropic/tools/schema/required-and-enum: pass
Formals carrying optional or (default ...) are dropped from required and nothing else.
(define (test/anthropic/tools/schema/required-and-enum _)
(⊦= "get_weather" (anthropic-tool-name get_weather/tool))
(⊦= "Get the current weather in a given location."
(anthropic-tool-description get_weather/tool))
(⊭ (anthropic-tool-strict get_weather/tool))
(⊦= '((type "object")
(properties
((location
((type "string")
(description "The city and state, e.g. San Francisco, CA")))
(unit ((type "string")
(enum #("celsius" "fahrenheit"))
(description "Unit of temperature")))))
(required #("location")))
(anthropic-tool-schema get_weather/tool))
(⊦= "{\"name\":\"get_weather\",\"description\":\"Get the current weather in a given location.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"location\":{\"type\":\"string\",\"description\":\"The city and state, e.g. San Francisco, CA\"},\"unit\":{\"type\":\"string\",\"enum\":[\"celsius\",\"fahrenheit\"],\"description\":\"Unit of temperature\"}},\"required\":[\"location\"]}}"
(anthropic-json/write (anthropic-tool->json get_weather/tool))))
((eta 0.001) (memory #(12582912 5994960 1048576)) (stdout "") (stderr ""))
test/anthropic/tools/schema/no-parameters: pass
The asymmetry that would otherwise be an opaque 400: properties is '() and encodes as {}, while required is #() and encodes as []. Two different Scheme values; swapping them has no other symptom.
(define (test/anthropic/tools/schema/no-parameters _)
(⊦= '((type "object") (properties ()) (required #()))
(anthropic-tool-schema now/tool))
(⊦= "{\"name\":\"now\",\"description\":\"Return the current time as an ISO-8601 string.\",\"input_schema\":{\"type\":\"object\",\"properties\":{},\"required\":[]}}"
(anthropic-json/write (anthropic-tool->json now/tool))))
((eta 0.0) (memory #(12582912 5997032 1048576)) (stdout "") (stderr ""))
test/anthropic/tools/schema/strict-is-one-switch: pass
strict and additionalProperties: false are one coupled switch, not two independent ones: the API requires them together, on every generated object node.
(define (test/anthropic/tools/schema/strict-is-one-switch _)
(⊨ (anthropic-tool-strict send_email/tool))
(⊦= "{\"name\":\"send_email\",\"description\":\"Send an email.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"to\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},\"description\":\"Recipient addresses\"}},\"required\":[\"to\"],\"additionalProperties\":false},\"strict\":true}"
(anthropic-json/write (anthropic-tool->json send_email/tool))))
((eta 0.0) (memory #(12582912 5998672 1048576)) (stdout "") (stderr ""))
test/anthropic/tools/schema/compound-types: pass
(define (test/anthropic/tools/schema/compound-types _)
(⊦= "{\"name\":\"search\",\"description\":\"Search the index\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"description\":\"What to look for\"},\"limit\":{\"type\":\"integer\",\"description\":\"How many results\"},\"tags\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},\"description\":\"Filter tags\"},\"deep\":{\"type\":\"boolean\",\"description\":\"Recurse?\"}},\"required\":[\"query\",\"limit\",\"tags\",\"deep\"]}}"
(anthropic-json/write (anthropic-tool->json search/tool))))
((eta 0.0) (memory #(12582912 5999976 1048576)) (stdout "") (stderr ""))
test/anthropic/tools/schema/raw-splices: pass
raw is an unquote-splicing node, so the generated (description ...) still lands AFTER the caller's fragment instead of replacing it. Note that numeric constraints such as minimum are not supported under define-tool/strict -- raw and strict do not compose.
(define (test/anthropic/tools/schema/raw-splices _)
(⊦= '((type "object")
(properties
((seconds
((type "integer") (minimum 0) (description "Seconds to sleep")))))
(required #("seconds")))
(anthropic-tool-schema sleep_for/tool)))
((eta 0.0) (memory #(12582912 6002072 1048576)) (stdout "") (stderr ""))
test/anthropic/tools/procedure-stays-a-procedure: pass
The macro defines an ordinary Scheme procedure as well as the tool record, so the body is unit-testable with no JSON anywhere in sight.
(define (test/anthropic/tools/procedure-stays-a-procedure _)
(⊦= "22 celsius in Paris" (get_weather "Paris"))
(⊦= "22 fahrenheit in Paris" (get_weather "Paris" #:unit "fahrenheit"))
(⊦= "2026-09-16T00:00:00Z" (now))
(⊦= "queued 2" (send_email #("a@x" "b@x"))))
((eta 0.0) (memory #(12582912 6003840 1048576)) (stdout "") (stderr ""))
test/anthropic/tools/dispatch: pass
An absent OPTIONAL key falls back to the procedure's own #!key default -- the single source of truth -- while an absent REQUIRED key raises, and the loop turns that raise into an is_error tool result rather than a dead conversation.
(define (test/anthropic/tools/dispatch _)
(⊦= "22 celsius in Paris"
(anthropic-tool/apply get_weather/tool '((location "Paris"))))
(⊦= "22 fahrenheit in Rome"
(anthropic-tool/apply
get_weather/tool
'((location "Rome") (unit "fahrenheit"))))
(⊦= "2026-09-16T00:00:00Z" (anthropic-tool/apply now/tool '()))
(⊦raises (anthropic-tool-error) (anthropic-tool/apply get_weather/tool '()))
(⊦= 'missing
(condition-case
(begin
(anthropic-tool/apply get_weather/tool '((unit "celsius")))
'no-error)
(c (anthropic-tool-error) 'missing))))
((eta 0.0) (memory #(12582912 6008584 1048576)) (stdout "") (stderr ""))
test/anthropic/tools/registry-and-wire-list: pass
anthropic-tools->json accepts a hand-written alist so an un-macro-ed tool can still be advertised, but anthropic-tools->table registers only records, so such a tool is answered with no such tool. That split is deliberate and is pinned here so it cannot drift silently.
(define (test/anthropic/tools/registry-and-wire-list _)
(⊨ (eq? get_weather/tool (anthropic-tool/registered "get_weather")))
(⊨ (eq? now/tool (anthropic-tool/registered "now")))
(⊭ (anthropic-tool/registered "no_such_tool"))
(⊨ (vector? (anthropic-tools->json (list now/tool))))
(⊦= 2
(vector-length (anthropic-tools->json (list now/tool get_weather/tool))))
(let1 (table (anthropic-tools->table
(list get_weather/tool '((name "raw_alist")))))
(⊨ (eq? get_weather/tool
(hash-table-ref/default table "get_weather" #f)))
(⊭ (hash-table-ref/default table "raw_alist" #f))))
((eta 0.0) (memory #(12582912 6009760 1048576)) (stdout "") (stderr ""))
test/anthropic/tools/result->string: pass
Non-strings become JSON, not Scheme syntax: a vector reaching the model as #(1 2) is noise it has to guess at. An EMPTY string is substituted too -- a text-bearing block with empty content is a 400.
(define (test/anthropic/tools/result->string _)
(⊦= "plain" (anthropic-tool-result->string "plain"))
(⊦= "{\"a\":1}" (anthropic-tool-result->string '((a 1))))
(⊦= "[1,2]" (anthropic-tool-result->string #(1 2)))
(⊦≠ "" (anthropic-tool-result->string ""))
(⊦≠ "" (anthropic-tool-result->string (void))))
((eta 0.0) (memory #(12582912 6008712 1048576)) (stdout "") (stderr ""))
test/anthropic/sse/framing: pass
A line beginning with : is a comment -- that is how the API keeps a connection warm -- and it produces no event at all. An event: line whose field is EMPTY is not the same as an absent name: the SSE specification makes both mean the default event type, "message".
(define (test/anthropic/sse/framing _)
(let1 (events (anthropic-sse/events (open-input-string fixture/sse/text)))
(⊦= '("message_start"
"content_block_start"
"ping"
"content_block_delta"
"content_block_delta"
"content_block_stop"
"message_delta"
"message_stop")
(map anthropic-event-name events))
(⊦= '("Hello" " world")
(filter (λ (t) t) (map anthropic-event/text events))))
(let1 (names (λ (s)
(map anthropic-event-name
(anthropic-sse/events (open-input-string s)))))
(⊦= '("message") (names "event:\ndata: {\"type\":\"ping\"}\n\n"))
(⊦= '("message") (names "event: \ndata: {\"type\":\"ping\"}\n\n"))
(⊦= '("message") (names "event:\r\ndata: {\"type\":\"ping\"}\r\n\r\n"))
(⊦= '("message") (names "data: {\"type\":\"ping\"}\n\n"))
(⊦= '("ping") (names "event: ping\ndata: {\"type\":\"ping\"}\n\n"))))
((eta 0.0) (memory #(12582912 6008856 1048576)) (stdout "") (stderr ""))
test/anthropic/sse/crlf-and-multiline-data: pass
Repeated data: lines join with a newline, per the SSE spec. The API sends one today; the joined form is unexercised against the real service and is here so it does not silently break if that changes.
(define (test/anthropic/sse/crlf-and-multiline-data _)
(let1 (events
(anthropic-sse/events
(open-input-string
(conc "event: message_stop\r\ndata: {\"type\":\r\ndata: \"message_stop\"}\r\n\r\n"))))
(⊦= 1 (length events))
(⊦= "message_stop" (anthropic-event-name (first events)))
(⊦= '((type "message_stop")) (anthropic-event-data (first events)))))
((eta 0.0) (memory #(12582912 6011160 1048576)) (stdout "") (stderr ""))
test/anthropic/sse/unknown-frames-are-tolerated: pass
An event whose data is absent or unparseable keeps (void) rather than raising: the wire carries comments, pings and event types that do not exist yet, and none of those may kill a stream. Only the accumulator raises, and only for the frames it claims to understand.
(define (test/anthropic/sse/unknown-frames-are-tolerated _)
(let1 (events
(anthropic-sse/events
(open-input-string
(conc "event: some_future_event\ndata: {\"type\":\"some_future_event\"}\n\n"
"event: weird\ndata: not json at all\n\n"))))
(⊦= '("some_future_event" "weird") (map anthropic-event-name events))
(⊦= (void) (anthropic-event-data (second events)))
(⊦= "not json at all" (anthropic-event-raw (second events)))
(⊦= '() (filter (λ (t) t) (map anthropic-event/text events)))))
((eta 0.0) (memory #(12582912 6013080 1048576)) (stdout "") (stderr ""))
test/anthropic/sse/assembles-a-text-turn: pass
The blocks are installed IN PLACE in the message_start skeleton, so every key the server sent keeps its position. stop_reason arrives on message_delta, never on message_stop, and the final usage is the MERGE of the two -- message_delta carries only output_tokens.
(define (test/anthropic/sse/assembles-a-text-turn _)
(receive
(message errors)
(anthropic-sse->message
(anthropic-sse/events (open-input-string fixture/sse/text)))
(⊦= '() errors)
(⊦= `((id "msg_s1")
(type "message")
(role "assistant")
(model "claude-opus-5")
(content #(((type "text") (text "Hello world"))))
(stop_reason "end_turn")
(stop_sequence ,(void))
(stop_details ,(void))
(usage ((input_tokens 10) (output_tokens 12))))
message)))
((eta 0.0) (memory #(12582912 6015784 1048576)) (stdout "") (stderr ""))
test/anthropic/sse/assembles-thinking-text-and-tool-use: pass
Three indices, reassembled in index order. The input_json_delta fragments split {"location": "Paris"} mid-token and are buffered as raw TEXT, parsed exactly once at content_block_stop -- feeding fragments to the parser incrementally cannot work: each fragment would only raise a decode error. id and name come from content_block_start, not from any delta, and a thinking block's signature arrives on its own signature_delta.
(define (test/anthropic/sse/assembles-thinking-text-and-tool-use _)
(receive
(message errors)
(anthropic-sse->message
(anthropic-sse/events (open-input-string fixture/sse/tool-use)))
(⊦= '() errors)
(⊦= #(((type "thinking") (thinking "Look it up.") (signature "EqQBCgIY"))
((type "text") (text "Checking."))
((type "tool_use")
(id "toolu_01")
(name "get_weather")
(input ((location "Paris")))))
(anthropic-json/ref message 'content))
(⊦= "tool_use" (anthropic-json/ref message 'stop_reason))
(⊦= 45
(anthropic-json/ref
(anthropic-json/ref message 'usage)
'output_tokens))))
((eta 0.0) (memory #(12582912 6019200 1048576)) (stdout "") (stderr ""))
test/anthropic/sse/tool-use-with-no-input-deltas: pass
A parameterless tool streams content_block_start with "input":{} and NO input_json_delta at all. A stop that parsed its empty buffer unconditionally would hand "" to the parser, which refuses it, and turn a valid call into an input error.
(define (test/anthropic/sse/tool-use-with-no-input-deltas _)
(receive
(message errors)
(anthropic-sse->message
(anthropic-sse/events (open-input-string fixture/sse/no-input)))
(⊦= '() errors)
(⊦= #(((type "tool_use") (id "toolu_02") (name "now") (input ())))
(anthropic-json/ref message 'content))))
((eta 0.0) (memory #(12582912 6022168 1048576)) (stdout "") (stderr ""))
test/anthropic/sse/tool-use-with-an-empty-input-delta: pass
The documented streams open every tool input with an input_json_delta whose partial_json is "", and for a parameterless tool that is the only one. An accumulation that adds up to nothing is no JSON at all, exactly like no delta: it keeps the template's input. Treating it as unparseable answered every streamed no-argument call with an INVALID_JSON error instead of running the tool.
(define (test/anthropic/sse/tool-use-with-an-empty-input-delta _)
(receive
(message errors)
(anthropic-sse->message
(anthropic-sse/events (open-input-string fixture/sse/empty-input-delta)))
(⊦= '() errors)
(⊦= #(((type "tool_use") (id "toolu_04") (name "now") (input ())))
(anthropic-json/ref message 'content)))
(letstub
(stub (anthropic-stub/canned
fixture/sse/empty-input-delta
#:headers
'(("content-type" "text/event-stream"))))
(let1 (r (anthropic/stream
(list (anthropic-message/user "time?"))
#:tools
(list now/tool)))
(⊦= '() (anthropic-response-input-errors r))
(⊦= "2026-09-16T00:00:00Z"
(anthropic-json/ref
(anthropic-tool-use->result
(car (anthropic-response/tool-uses r))
(anthropic-tools->table (list now/tool))
anthropic-tool/around
(anthropic-response-input-errors r))
'content)))))
((eta 0.0) (memory #(12582912 6024488 1048576)) (stdout "") (stderr ""))
test/anthropic/sse/unparseable-tool-input-is-recorded-not-raised: pass
Raising here would lose the turn's sibling blocks, its stop_reason and its usage -- and the caller needs the stop_reason precisely because max_tokens with a tool_use present means STOP, retry with a bigger budget, never run the tool.
(define (test/anthropic/sse/unparseable-tool-input-is-recorded-not-raised _)
(receive
(message errors)
(anthropic-sse->message
(anthropic-sse/events (open-input-string fixture/sse/broken-input)))
(⊦= '(("toolu_03" "{\"location\": \"Par")) errors)
(⊦= '()
(anthropic-json/ref
(vector-ref (anthropic-json/ref message 'content) 0)
'input))
(⊦= "max_tokens" (anthropic-json/ref message 'stop_reason))))
((eta 0.0) (memory #(12582912 6027752 1048576)) (stdout "") (stderr ""))
test/anthropic/sse/cut-stream-raises: pass
A connection cut at 90% of a long answer must be an error, not a plausible shorter answer: an unfinished content block, or a missing message_stop, both raise.
(define (test/anthropic/sse/cut-stream-raises _)
(⊦raises
(anthropic-sse-error)
(anthropic-sse->message
(anthropic-sse/events (open-input-string fixture/sse/cut))))
(⊦raises
(anthropic-sse-error)
(anthropic-sse->message
(anthropic-sse/events
(open-input-string
(conc "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"m\"}}\n\n"
"event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}\n\n"))))))
((eta 0.0) (memory #(12582912 6024200 1048576)) (stdout "") (stderr ""))
test/anthropic/sse/error-frame-raises: pass
An SSE error frame arrives inside an HTTP 200, so it has no status of its own. It carries (void) rather than an invented 529 -- the retryability comes from the error TYPE instead.
(define (test/anthropic/sse/error-frame-raises _)
(⊦= (list (void) "overloaded_error")
(condition-case
(begin
(anthropic-sse->message
(anthropic-sse/events (open-input-string fixture/sse/error)))
'no-error)
(c (anthropic-api-error)
(list (anthropic-error-ref c 'status (void))
(anthropic-error-ref c 'type))))))
((eta 0.0) (memory #(12582912 6028616 1048576)) (stdout "") (stderr ""))
test/anthropic/sse/lazy-stream-runs-finish-once: pass
The stream memoises, so a second traversal re-reads the same events and finish runs exactly once. A caller who abandons the stream early never reaches eof and must release the wire itself.
(define (test/anthropic/sse/lazy-stream-runs-finish-once _)
(let1 (finished 0)
(let1 (§ (anthropic-sse->§
(open-input-string fixture/sse/text)
#:finish
(τ (add1! finished))))
(⊦= 8 (anthropic-sse/fold (λ (ev n) (add1 n)) 0 §))
(⊦= 8 (anthropic-sse/fold (λ (ev n) (add1 n)) 0 §))
(⊦= 1 finished))))
((eta 0.0) (memory #(12582912 6032816 1048576)) (stdout "") (stderr ""))
test/anthropic/stream/end-to-end-over-the-stub: pass
The value that comes back is an ordinary anthropic-response, so every accessor works unchanged whether the turn was streamed or not. The stream? flag reaches the transport AND "stream":true reaches the body -- a request missing either would return ordinary JSON that the frame reader silently swallows into an empty message.
(define (test/anthropic/stream/end-to-end-over-the-stub _)
(let ((fragments '()) (events '()))
(letstub
(stub (anthropic-stub/canned
fixture/sse/tool-use
#:headers
'(("content-type" "text/event-stream"))))
(let1 (r (anthropic/stream
(list (anthropic-message/user "weather in Paris?"))
#:tools
(list get_weather/tool)
#:on-text
(λ (t) (push! t fragments))
#:on-event
(λ (ev) (push! (anthropic-event-name ev) events))))
(⊨ (anthropic-response? r))
(⊦= "tool_use" (anthropic-response-stop-reason r))
(⊦= "Checking." (anthropic-response/text r))
(⊦= '("Checking.") (reverse fragments))
(⊦= 15 (length events))
(⊦= '(("get_weather" ((location "Paris"))))
(map (λ (b)
(list (anthropic-json/ref b 'name)
(anthropic-json/ref b 'input)))
(anthropic-response/tool-uses r)))
(⊦= 45 (anthropic-usage/output-tokens r))
(⊦= 1 (anthropic-stub/count stub))
(⊦= 1 (anthropic-stub/closes stub))
(let1 (call (first (anthropic-stub/calls stub)))
(⊨ (fourth call))
(⊦= #t
(anthropic-json/ref
(anthropic-json/parse (third call))
'stream))
(⊦= 64000
(anthropic-json/ref
(anthropic-json/parse (third call))
'max_tokens)))))))
((eta 0.0) (memory #(12582912 6032096 1048576)) (stdout "") (stderr ""))
test/anthropic/stream/rejects-a-non-sse-response: pass
A non-2xx on a streaming request answers with an ordinary JSON error body, not with SSE, so it is routed through the blocking error path before anyone starts reading frames. And a 200 whose content-type is not text/event-stream is a decode error, never an empty message. Both of those early exits release the wire before they raise.
(define (test/anthropic/stream/rejects-a-non-sse-response _)
(letstub
(stub (anthropic-stub/canned fixture/response/text))
(⊦raises
(anthropic-decode-error)
(anthropic/stream (list (anthropic-message/user "hi"))))
(⊦= 1 (anthropic-stub/closes stub)))
(letstub
(stub (anthropic-stub/canned fixture/error/429 #:status 429))
(⊦raises/api
(429 "rate_limit_error")
(anthropic/stream (list (anthropic-message/user "hi"))))
(⊦= 1 (anthropic-stub/closes stub))))
((eta 0.0) (memory #(12582912 6039136 1048576)) (stdout "") (stderr ""))
test/anthropic/stream/releases-the-wire-when-the-fold-raises: pass
An abandoned stream is the expensive leak, not the tidy one: the transport's close thunk is what deletes the #o600 file holding the key and what terminates the child, and until it runs a streaming request holds both for up to --max-time -- thirty minutes at the default.
(define (test/anthropic/stream/releases-the-wire-when-the-fold-raises _)
(letstub
(stub (anthropic-stub/canned
fixture/sse/error
#:headers
'(("content-type" "text/event-stream"))))
(⊦raises
(anthropic-api-error)
(anthropic/stream (list (anthropic-message/user "hi"))))
(⊦= 1 (anthropic-stub/closes stub)))
(letstub
(stub (anthropic-stub/canned
fixture/sse/cut
#:headers
'(("content-type" "text/event-stream"))))
(⊦raises
(anthropic-sse-error)
(anthropic/stream (list (anthropic-message/user "hi"))))
(⊦= 1 (anthropic-stub/closes stub))))
((eta 0.001) (memory #(12582912 6035360 1048576)) (stdout "") (stderr ""))
test/anthropic/loop/single-tool-request-sequence: pass
The loop invariant asserted on the bytes: the transcript alternates user / assistant / user, the assistant turn is replayed as its WHOLE content array (the text block is not dropped), and tools is repeated on the follow-up request -- omitting it there is a 400.
(define (test/anthropic/loop/single-tool-request-sequence _)
(letstub
(stub (anthropic-stub/canned fixture/response/tool-use)
(anthropic-stub/canned fixture/response/text))
(receive
(r transcript)
(anthropic/converse
(list (anthropic-message/user "weather in Paris?"))
#:tools
(list get_weather/tool))
(⊦= "end_turn" (anthropic-response-stop-reason r))
(⊦= "Paris." (anthropic-response/text r))
(⊦= 2 (anthropic-stub/count stub))
(⊦= '("user" "assistant" "user" "assistant")
(map anthropic-message-role transcript))
(⊦= (list #(((role "user") (content "weather in Paris?")))
#(((role "user") (content "weather in Paris?"))
((role "assistant")
(content
#(((type "text") (text "Let me check the weather."))
((type "tool_use")
(id "toolu_01")
(name "get_weather")
(input ((location "Paris")))))))
((role "user")
(content
#(((type "tool_result")
(tool_use_id "toolu_01")
(content "22 celsius in Paris")))))))
(map (λ (body) (anthropic-json/ref body 'messages))
(anthropic-stub/requests stub)))
(⊭ (any (λ (body) (not (anthropic-json/has? body 'tools)))
(anthropic-stub/requests stub))))))
((eta 0.001) (memory #(12582912 6044656 1048576)) (stdout "") (stderr ""))
test/anthropic/loop/a-vector-of-tools: pass
anthropic/messages accepts tools: as a vector; the loop died on one with a bare "not a list", and would then have sent its requests with no tools at all.
(define (test/anthropic/loop/a-vector-of-tools _)
(letstub
(stub (anthropic-stub/canned fixture/response/tool-use)
(anthropic-stub/canned fixture/response/text))
(receive
(r transcript)
(anthropic/converse
(list (anthropic-message/user "weather in Paris?"))
#:tools
(vector get_weather/tool))
(⊦= "Paris." (anthropic-response/text r))
(⊦= '(#t #t)
(map (λ (body) (anthropic-json/has? body 'tools))
(anthropic-stub/requests stub)))
(⊦= #(((type "tool_result")
(tool_use_id "toolu_01")
(content "22 celsius in Paris")))
(anthropic-message-content (caddr transcript))))))
((eta 0.0) (memory #(12582912 6048896 1048576)) (stdout "") (stderr ""))
test/anthropic/loop/parallel-tools-in-one-user-message: pass
Two tool_use blocks produce ONE user message holding two tool_result blocks, in block order, each echoing its matching tool_use_id. Splitting them across two user messages is a 400 on the alternation rule.
(define (test/anthropic/loop/parallel-tools-in-one-user-message _)
(letstub
(stub (anthropic-stub/canned fixture/response/parallel-tool-use)
(anthropic-stub/canned fixture/response/text))
(anthropic/converse
(list (anthropic-message/user "Paris and Rome?"))
#:tools
(list get_weather/tool))
(let1 (msgs (anthropic-json/ref
(second (anthropic-stub/requests stub))
'messages))
(⊦= 3 (vector-length msgs))
(⊦= `((role "user")
(content
#(((type "tool_result")
(tool_use_id "toolu_01")
(content "22 celsius in Paris"))
((type "tool_result")
(tool_use_id "toolu_02")
(content "22 fahrenheit in Rome")))))
(vector-ref msgs 2)))))
((eta 0.001) (memory #(12582912 6049288 1048576)) (stdout "") (stderr ""))
test/anthropic/loop/failures-become-is-error: pass
A condition raised by one tool must not abort the map and lose its siblings' results: an unanswered tool_use_id is a 400 on the very next request. A tool that raises, and a name with no matching tool, both come back as ordinary results with is_error true.
(define (test/anthropic/loop/failures-become-is-error _)
(letstub
(stub (anthropic-stub/canned fixture/response/mixed-tool-use)
(anthropic-stub/canned fixture/response/text))
(anthropic/converse
(list (anthropic-message/user "go"))
#:tools
(list get_weather/tool explode/tool))
(let1 (results
(anthropic-json/vector->list
(anthropic-json/ref
(vector-ref
(anthropic-json/ref
(second (anthropic-stub/requests stub))
'messages)
2)
'content)))
(⊦= 3 (length results))
(⊦= '("toolu_01" "toolu_02" "toolu_03")
(map (λ (b) (anthropic-json/ref b 'tool_use_id)) results))
(⊦= '(absent #t #t)
(map (λ (b) (anthropic-json/ref b 'is_error 'absent)) results))
(⊦= "22 celsius in Paris"
(anthropic-json/ref (first results) 'content))
(⊦≠ #f
(substring-index
"boom: now"
(anthropic-json/ref (second results) 'content)))
(⊦≠ #f
(substring-index
"ghost"
(anthropic-json/ref (third results) 'content))))))
((eta 0.0) (memory #(12582912 6050392 1048576)) (stdout "") (stderr ""))
test/anthropic/loop/around-hook-denies: pass
Dispatch is an explicit left-to-right fold, not map -- application order is unspecified and these calls have side effects. Denial is a RETURN VALUE convention: (anthropic-tool/error ...) denies, (run) executes, and returning a plain string denies nothing at all -- the string becomes a SUCCESSFUL result. That is easy to get wrong in an approval hook, which is exactly where getting it wrong is dangerous.
(define (test/anthropic/loop/around-hook-denies _)
(let1 (seen '())
(letstub
(stub (anthropic-stub/canned fixture/response/parallel-tool-use)
(anthropic-stub/canned fixture/response/text))
(anthropic/converse
(list (anthropic-message/user "go"))
#:tools
(list get_weather/tool)
#:around
(λ (name input run)
(push! (anthropic-json/ref input 'location) seen)
(if (equal? "Rome" (anthropic-json/ref input 'location))
(anthropic-tool/error "denied by policy")
(run))))
(⊦= '("Paris" "Rome") (reverse seen))
(let1 (results
(anthropic-json/vector->list
(anthropic-json/ref
(vector-ref
(anthropic-json/ref
(second (anthropic-stub/requests stub))
'messages)
2)
'content)))
(⊦= '(absent #t)
(map (λ (b) (anthropic-json/ref b 'is_error 'absent))
results))
(⊦= "denied by policy"
(anthropic-json/ref (second results) 'content))))))
((eta 0.001) (memory #(12582912 6048632 1048576)) (stdout "") (stderr ""))
test/anthropic/tools/invalid-streamed-tool-input: pass
The documented recovery shape: a JSON document nested inside a JSON string, built with the encoder rather than by concatenation so that the quotes in the bad input are escaped. The tool is NOT run.
(define (test/anthropic/tools/invalid-streamed-tool-input _)
(let1 (result
(anthropic-tool-use->result
'((type "tool_use")
(id "toolu_03")
(name "get_weather")
(input ()))
(anthropic-tools->table (list get_weather/tool))
anthropic-tool/around
'(("toolu_03" "{\"location\": \"Par"))))
(⊦= "toolu_03" (anthropic-json/ref result 'tool_use_id))
(⊦= #t (anthropic-json/ref result 'is_error))
(⊦= "{\"INVALID_JSON\":\"{\\\"location\\\": \\\"Par\"}"
(anthropic-json/ref result 'content))))
((eta 0.0) (memory #(12582912 6049424 1048576)) (stdout "") (stderr ""))
test/anthropic/stream/carries-input-errors-into-the-response: pass
A tool_use block whose input_json_delta stream was cut leaves the block with an EMPTY input, which is indistinguishable from a tool that legitimately takes no arguments. The id and the partial text ride back on the response record so that anthropic/converse can answer that particular tool_use_id with an is_error result instead of running the tool on nothing.
(define (test/anthropic/stream/carries-input-errors-into-the-response _)
(letstub
(stub (anthropic-stub/canned
fixture/sse/broken-input
#:headers
'(("content-type" "text/event-stream"))))
(let1 (r (anthropic/stream
(list (anthropic-message/user "weather?"))
#:tools
(list get_weather/tool)))
(⊦= '(("toolu_03" "{\"location\": \"Par"))
(anthropic-response-input-errors r))
(⊦= "max_tokens" (anthropic-response-stop-reason r))
(⊦= 1 (anthropic-stub/closes stub))))
(letstub
(stub (anthropic-stub/canned fixture/response/tool-use))
(⊦= '()
(anthropic-response-input-errors
(anthropic/messages
(list (anthropic-message/user "weather?"))
#:tools
(list get_weather/tool))))))
((eta 0.001) (memory #(12582912 6043184 1048576)) (stdout "") (stderr ""))
test/anthropic/loop/max-tokens-with-a-tool-call-does-not-execute: pass
A truncated tool input often still parses as a valid partial object, so stop_reason is checked BEFORE the dispatch. Getting that ordering wrong runs side-effecting tools on truncated arguments. The counter is a module-level tool, not a local let1 that nothing could ever mutate.
(define (test/anthropic/loop/max-tokens-with-a-tool-call-does-not-execute _)
(let1 (before tool/calls)
(letstub
(stub (anthropic-stub/canned fixture/response/max-tokens/tool-use))
(⊦raises/loop
'max-tokens
(anthropic/converse
(list (anthropic-message/user "go"))
#:tools
(list counted/tool)))
(⊦= 1 (anthropic-stub/count stub)))
(⊦= before tool/calls))
(letstub
(stub (anthropic-stub/canned fixture/response/max-tokens/text))
(receive
(r transcript)
(anthropic/converse
(list (anthropic-message/user "go"))
#:tools
(list get_weather/tool))
(⊦= "max_tokens" (anthropic-response-stop-reason r))
(⊦= "trunc" (anthropic-response/text r))
(⊦= 2 (length transcript)))))
((eta 0.0) (memory #(12582912 6044800 1048576)) (stdout "") (stderr ""))
test/anthropic/loop/refusal-does-not-execute-tools: pass
(define (test/anthropic/loop/refusal-does-not-execute-tools _)
(let1 (before tool/calls)
(letstub
(stub (anthropic-stub/canned fixture/response/refusal))
(⊦raises/loop
'refusal
(anthropic/converse
(list (anthropic-message/user "go"))
#:tools
(list counted/tool)))
(⊦= 1 (anthropic-stub/count stub)))
(⊦= before tool/calls)))
((eta 0.0) (memory #(12582912 6048864 1048576)) (stdout "") (stderr ""))
test/anthropic/loop/pause-turn-appends-no-user-message: pass
The one branch that appends an assistant turn with NO following user message: the API detects the trailing server_tool_use block and resumes by itself. Injecting a "Continue." user message is wrong, and the resend's trailing block is what makes it not-prefill. It gets its own counter because a server-side loop could otherwise pause forever.
(define (test/anthropic/loop/pause-turn-appends-no-user-message _)
(letstub
(stub (anthropic-stub/canned fixture/response/pause-turn)
(anthropic-stub/canned fixture/response/text))
(receive
(r transcript)
(anthropic/converse
(list (anthropic-message/user "search"))
#:tools
(list get_weather/tool))
(⊦= "Paris." (anthropic-response/text r))
(⊦= '("user" "assistant" "assistant")
(map anthropic-message-role transcript))
(⊦= 2 (anthropic-stub/count stub))
(⊦= #(((role "user") (content "search"))
((role "assistant")
(content
#(((type "text") (text "Searching..."))
((type "server_tool_use")
(id "srvtoolu_01")
(name "web_search")
(input ((query "weather"))))))))
(anthropic-json/ref
(second (anthropic-stub/requests stub))
'messages))))
(letstub
(stub (anthropic-stub/canned fixture/response/pause-turn)
(anthropic-stub/canned fixture/response/pause-turn))
(⊦raises/loop
'max-pauses
(anthropic/converse
(list (anthropic-message/user "search"))
#:tools
(list get_weather/tool)
#:max-pauses
1))))
((eta 0.001) (memory #(12582912 6057472 1048576)) (stdout "") (stderr ""))
test/anthropic/loop/max-iterations-guard: pass
A model that keeps asking for tools must not loop forever. Exactly max-iterations requests are made, then it raises.
(define (test/anthropic/loop/max-iterations-guard _)
(letstub
(stub (anthropic-stub/canned fixture/response/tool-use)
(anthropic-stub/canned fixture/response/tool-use)
(anthropic-stub/canned fixture/response/tool-use))
(⊦raises/loop
'max-iterations
(anthropic/converse
(list (anthropic-message/user "go"))
#:tools
(list get_weather/tool)
#:max-iterations
3))
(⊦= 3 (anthropic-stub/count stub))))
((eta 0.001) (memory #(12582912 6058832 1048576)) (stdout "") (stderr ""))
test/anthropic/loop/terminal-and-unknown-stop-reasons: pass
An unrecognised stop_reason raises rather than being silently treated as end_turn, which would truncate the answer. model_context_window_exceeded gets its own reason because it is actionable -- compact or split -- rather than a protocol violation.
(define (test/anthropic/loop/terminal-and-unknown-stop-reasons _)
(letstub
(stub (anthropic-stub/canned fixture/response/unknown-stop))
(⊦raises/loop
'unknown-stop-reason
(anthropic/converse (list (anthropic-message/user "go")))))
(letstub
(stub (anthropic-stub/canned fixture/response/context-window))
(⊦raises/loop
'context-window-exceeded
(anthropic/converse (list (anthropic-message/user "go")))))
(letstub
(stub (anthropic-stub/canned fixture/response/text))
(receive
(r transcript)
(anthropic/converse (list (anthropic-message/user "go")))
(⊦= 2 (length transcript)))))
((eta 0.0) (memory #(12582912 6058488 1048576)) (stdout "") (stderr ""))
test/anthropic/loop/rejects-assistant-prefill: pass
Assistant prefill is a 400 on every current model, so the INITIAL transcript must end with the user. A pause_turn continuation legitimately does not, which is why this check lives in the loop and not in anthropic-messages/validate!.
(define (test/anthropic/loop/rejects-assistant-prefill _)
(letstub
(stub (anthropic-stub/canned fixture/response/text))
(⊦raises
(anthropic-config-error)
(anthropic/converse
(list (anthropic-message/user "hi")
(anthropic-message/assistant "Sure, "))))
(⊦= 0 (anthropic-stub/count stub))))
((eta 0.0) (memory #(12582912 6061096 1048576)) (stdout "") (stderr ""))
test/anthropic/loop/unicode-survives-the-round-trip: pass
A tool result with characters outside ASCII goes through the encoder, back through the byte-length-correct parser, and comes out identical. With a character count in place of the byte count, as simdjson-parse/ondemand used to pass, this case would fail: the body parsed would stop short of its end.
(define (test/anthropic/loop/unicode-survives-the-round-trip _)
(letstub
(stub (anthropic-stub/canned fixture/response/unicode-tool)
(anthropic-stub/canned fixture/response/text))
(anthropic/converse
(list (anthropic-message/user "motto?"))
#:tools
(list motto/tool))
(⊦= "caffè 一 espresso"
(anthropic-json/ref
(vector-ref
(anthropic-json/ref
(vector-ref
(anthropic-json/ref
(second (anthropic-stub/requests stub))
'messages)
2)
'content)
0)
'content))))
((eta 0.0) (memory #(12582912 6058656 1048576)) (stdout "") (stderr ""))
test/anthropic/redaction/replaces-the-key: pass
A key shorter than eight characters is left alone: redacting a common substring would corrupt every diagnostic. Real keys are far longer.
(define (test/anthropic/redaction/replaces-the-key _)
(parameterize
((anthropic/api-key anthropic-test/key))
(⊦= "x-api-key: <redacted>"
(anthropic/redact (conc "x-api-key: " anthropic-test/key)))
(⊦= #f
(substring-index
anthropic-test/key
(anthropic/redact (conc "curl: (60) sent " anthropic-test/key))))
(⊦= "nothing to hide" (anthropic/redact "nothing to hide")))
(parameterize
((anthropic/api-key #f))
(⊦= "no key set" (anthropic/redact "no key set")))
(parameterize
((anthropic/api-key "short"))
(⊦= "short" (anthropic/redact "short"))))
((eta 0.0) (memory #(12582912 6061088 1048576)) (stdout "") (stderr ""))
test/anthropic/redaction/api-error-message-is-clean: pass
A 401 is exactly the error a user is most likely to paste into a bug report.
(define (test/anthropic/redaction/api-error-message-is-clean _)
(letstub
(stub (anthropic-stub/canned fixture/error/401 #:status 401))
(⊦= 'clean
(condition-case
(begin
(anthropic/messages (list (anthropic-message/user "hi")))
'no-error)
(c (anthropic-api-error)
(if (substring-index
anthropic-test/key
(get-condition-property c 'exn 'message))
'leaked
'clean))))))
((eta 0.0) (memory #(12582912 6066816 1048576)) (stdout "") (stderr ""))
test/anthropic/redaction/stub-log-is-clean: pass
Every assertion over the stub log happens INSIDE the parameterize, because the redaction resolves the CURRENT key. Outside it the raw value would come back and a failing case would write it into testsuite-anthropic-suite.html.
(define (test/anthropic/redaction/stub-log-is-clean _)
(letstub
(stub (anthropic-stub/canned fixture/response/text))
(anthropic/messages (list (anthropic-message/user "hi")))
(⊦= #f
(substring-index
anthropic-test/key
(->string/pretty-print (anthropic-stub/calls stub))))))
((eta 0.0) (memory #(12582912 6056584 1048576)) (stdout "") (stderr ""))
test/anthropic/loop/around-hook-approving-string-is-a-SUCCESS: pass
A hook that returns a string has APPROVED the call with that string as the result. Denial is (anthropic-tool/error ...) and nothing else.
(define (test/anthropic/loop/around-hook-approving-string-is-a-SUCCESS _)
(letstub
(stub (anthropic-stub/canned fixture/response/tool-use)
(anthropic-stub/canned fixture/response/text))
(anthropic/converse
(list (anthropic-message/user "weather?"))
#:tools
(list get_weather/tool)
#:around
(λ (tool input run) "approved"))
(let1 (blocks
(anthropic-json/ref
(vector-ref
(anthropic-json/ref
(second (anthropic-stub/requests stub))
'messages)
2)
'content))
(⊦= "approved" (anthropic-json/ref (vector-ref blocks 0) 'content))
(⊦= (void) (anthropic-json/ref (vector-ref blocks 0) 'is_error)))))
((eta 0.0) (memory #(12582912 6069128 1048576)) (stdout "") (stderr ""))
test/anthropic/transport/close-is-idempotent: pass
The accessor memoises the thunk's values into the record's close field, so a second release answers from the cache rather than reaping a pid that is already gone. The discriminant is procedure? on the thunk and not pair? on the memo, because a thunk that answers zero values memoises '() and the second release would then call it.
(define (test/anthropic/transport/close-is-idempotent _)
(let1 (closed 0)
(let1 (w (make-anthropic-wire
200
'()
'(test)
(open-input-string "")
(τ (set! closed (add1 closed)) (values 23 "once"))))
(receive (s d) (anthropic-wire/close! w) (⊦= 23 s) (⊦= "once" d))
(receive (s d) (anthropic-wire/close! w) (⊦= 23 s) (⊦= "once" d))
(⊦= 1 closed)))
(let1 (closed 0)
(let1 (w (make-anthropic-wire
200
'()
'(test)
(open-input-string "")
(τ (set! closed (add1 closed)) (values))))
(⊦= '() (receive vals (anthropic-wire/close! w) vals))
(⊦= '() (receive vals (anthropic-wire/close! w) vals))
(⊦= 1 closed))))
((eta 0.0) (memory #(12582912 6070904 1048576)) (stdout "") (stderr ""))
test/anthropic/transport/still-running-is-pid-zero: pass
Reverting this to (not pid) -- which is what the shape of process-wait in most other languages suggests -- silently restores the full --max-time stall when a stream is abandoned. The offline suite spawns no processes, so this pins the SPELLING; the stall itself was measured by hand against a server that sends a header block and then holds the socket silent (0.0 s and exit 128 with the fix, 8.0 s and exit 28 without it, at --max-time 8).
(define (test/anthropic/transport/still-running-is-pid-zero _)
(⊨ (anthropic-transport/still-running? 0))
(⊭ (anthropic-transport/still-running? #f))
(⊭ (anthropic-transport/still-running? 49345)))
((eta 0.0) (memory #(12582912 6072568 1048576)) (stdout "") (stderr ""))
test/anthropic/curl/timeouts-differ-by-mode: pass
The streaming arm gets thirty minutes and --no-buffer; the blocking arm gets ten and does not. The streaming figure is also the worst case for abandoning a wire, which is why §4.8 kills the child before it waits for it.
(define (test/anthropic/curl/timeouts-differ-by-mode _)
(let1 (argv (anthropic-curl/argv "https://example.invalid" "/tmp/h" #f))
(⊦= "600"
(list-ref
argv
(add1 (list-index (λ (a) (equal? a "--max-time")) argv))))
(⊭ (member? "--no-buffer" argv)))
(let1 (argv (anthropic-curl/argv "https://example.invalid" "/tmp/h" #t))
(⊦= "1800"
(list-ref
argv
(add1 (list-index (λ (a) (equal? a "--max-time")) argv))))
(⊨ (member? "--no-buffer" argv))))
((eta 0.0) (memory #(12582912 6075040 1048576)) (stdout "") (stderr ""))
test/anthropic/loop/empty-assistant-content-is-not-appended: pass
An end_turn carrying "content":[] ends the conversation without appending an assistant turn: an empty content array is a 400 the moment it is sent back.
(define (test/anthropic/loop/empty-assistant-content-is-not-appended _)
(letstub
(stub (anthropic-stub/canned fixture/response/empty-end-turn))
(receive
(r transcript)
(anthropic/converse (list (anthropic-message/user "hi")))
(⊦= "end_turn" (anthropic-response-stop-reason r))
(⊦= 1 (length transcript)))))
((eta 0.0) (memory #(12582912 6069784 1048576)) (stdout "") (stderr ""))
test/anthropic/loop/stop-sequence-terminates: pass
stop_sequence terminates the loop exactly like end_turn. Falling through to the unknown stop_reason branch instead would turn a normal, requested stop into a raised condition.
(define (test/anthropic/loop/stop-sequence-terminates _)
(letstub
(stub (anthropic-stub/canned fixture/response/stop-sequence))
(receive
(r transcript)
(anthropic/converse (list (anthropic-message/user "hi")))
(⊦= "stop_sequence" (anthropic-response-stop-reason r))
(⊦= "END"
(anthropic-json/ref (anthropic-response-json r) 'stop_sequence))
(⊦= 2 (length transcript)))))
((eta 0.0) (memory #(12582912 6074200 1048576)) (stdout "") (stderr ""))
test/anthropic/loop/on-response-hook-fires-once-per-turn: pass
The hook sees every turn, in order, with its 1-based request number -- which is what makes it usable for a progress display and for enforcing a budget the max-iterations guard alone cannot express.
(define (test/anthropic/loop/on-response-hook-fires-once-per-turn _)
(let1 (seen '())
(letstub
(stub (anthropic-stub/canned fixture/response/tool-use)
(anthropic-stub/canned fixture/response/text))
(anthropic/converse
(list (anthropic-message/user "weather?"))
#:tools
(list get_weather/tool)
#:on-response
(λ (r n)
(set! seen
(cons (list n (anthropic-response-stop-reason r)) seen))))
(⊦= '((1 "tool_use") (2 "end_turn")) (reverse seen)))))
((eta 0.001) (memory #(12582912 6077544 1048576)) (stdout "") (stderr ""))
test/anthropic/loop/options-reach-the-request: pass
anthropic/converse forwards eleven keywords to anthropic/messages by hand. Dropping one is silent: the call still succeeds, it just ignores what the caller asked for.
(define (test/anthropic/loop/options-reach-the-request _)
(letstub
(stub (anthropic-stub/canned fixture/response/text))
(anthropic/converse
(list (anthropic-message/user "hi"))
#:system
"be terse"
#:max-tokens
128
#:stop-sequences
'("END")
#:effort
"low")
(let1 (req (first (anthropic-stub/requests stub)))
(⊦= "be terse" (anthropic-json/ref req 'system))
(⊦= 128 (anthropic-json/ref req 'max_tokens))
(⊦= #("END") (anthropic-json/ref req 'stop_sequences))
(⊦= "low"
(anthropic-json/ref
(anthropic-json/ref req 'output_config)
'effort)))))
((eta 0.0) (memory #(12582912 6081272 1048576)) (stdout "") (stderr ""))
test/anthropic/sse/orphan-delta-and-stop-are-ignored: pass
A content_block_delta or content_block_stop for an index that was never opened is DROPPED, not raised on: the accumulator looks the index up with assv and does nothing when the lookup fails. Raising would throw away a turn that is otherwise complete, over a frame the caller cannot act on anyway.
(define (test/anthropic/sse/orphan-delta-and-stop-are-ignored _)
(receive
(message errors)
(anthropic-sse->message
(anthropic-sse/events (open-input-string fixture/sse/orphan-index)))
(⊦= '() errors)
(⊦= #(((type "text") (text "ok"))) (anthropic-json/ref message 'content))
(⊦= "end_turn" (anthropic-json/ref message 'stop_reason))))
((eta 0.0) (memory #(12582912 6076496 1048576)) (stdout "") (stderr ""))
test/anthropic/sse/redacted-thinking-round-trips: pass
A redacted_thinking block has no delta of any kind: its content_block_start template IS the whole block and must pass through untouched, because the API rejects a thinking block that was modified.
(define (test/anthropic/sse/redacted-thinking-round-trips _)
(receive
(message errors)
(anthropic-sse->message
(anthropic-sse/events (open-input-string fixture/sse/redacted-thinking)))
(⊦= '() errors)
(let1 (blk (vector-ref (anthropic-json/ref message 'content) 0))
(⊦= "redacted_thinking" (anthropic-block-type blk))
(⊨ (string? (anthropic-json/ref blk 'data)))
(⊦= "EncryptedBlob" (anthropic-json/ref blk 'data)))))
((eta 0.0) (memory #(12582912 6079120 1048576)) (stdout "") (stderr ""))
test/anthropic/tools/schema/integer-enum: pass
An (enum ...) of exact integers derives "type":"integer", not "type":"string". The interesting part is where it would break: exact-integer? is called while the macro EXPANDS, so it has to be in import-for-syntax, and the failure is a compile error rather than a red case.
(define (test/anthropic/tools/schema/integer-enum _)
(⊦= '((type "object")
(properties
((level ((type "integer")
(enum #(1 2 3))
(description "How urgent")))))
(required #("level")))
(anthropic-tool-schema set_priority/tool))
(⊦= "{\"name\":\"set_priority\",\"description\":\"Set a priority.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"level\":{\"type\":\"integer\",\"enum\":[1,2,3],\"description\":\"How urgent\"}},\"required\":[\"level\"]}}"
(anthropic-json/write (anthropic-tool->json set_priority/tool)))
(⊦= "priority 2" (set_priority 2)))
((eta 0.0) (memory #(12582912 6079808 1048576)) (stdout "") (stderr ""))