okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
0a3eddd
main
1"""Provider requests: one durable row per attempt albedo sends, recording what cache 2and quota analysis cannot recover from the transcript. 3 4Catches: usage counts that never reach a request row (cache reads, TTL-split 5writes, reasoning), the account/provider identity of each call, the prefix 6identity that distinguishes a warm append from a compaction rewrite, an HTTP 7failure recorded as a quota reading, and the seq link to the transcript. None 8of this is observable from events or history alone. 9""" 10 11import json 12import unittest 13from datetime import datetime 14 15from harness import Albedo ,Provider ,Reply ,text 16 17FIRST = { 18"input_tokens" :100 , 19"output_tokens" :20 , 20"input_tokens_details" : {"cached_tokens" :40 }, 21"output_tokens_details" : {"reasoning_tokens" :7 }, 22} 23TOOL = { 24"input_tokens" :200 , 25"output_tokens" :30 , 26"input_tokens_details" : {"cached_tokens" :80 }, 27"output_tokens_details" : {"reasoning_tokens" :11 }, 28} 29AFTER = { 30"input_tokens" :300 , 31"output_tokens" :40 , 32"input_tokens_details" : {"cached_tokens" :120 }, 33"output_tokens_details" : {"reasoning_tokens" :3 }, 34} 35THIRD = { 36"input_tokens" :400 , 37"output_tokens" :50 , 38"input_tokens_details" : {"cached_tokens" :160 }, 39"output_tokens_details" : {"reasoning_tokens" :5 }, 40} 41SUMMARY = {"input_tokens" :60 ,"output_tokens" :10 } 42POST = { 43"input_tokens" :70 , 44"output_tokens" :15 , 45"input_tokens_details" : {"cached_tokens" :30 }, 46"output_tokens_details" : {"reasoning_tokens" :2 }, 47} 48 49 50def reply (request ): 51inputs = request ["input" ] 52# The tool result arrives while the newest user message is still the one 53# that asked for the tool, so it is told apart by input shape. 54if inputs [- 1 ].get ("type" )== "function_call_output" : 55return text ("tool done" ,usage = AFTER ) 56user = next ( 57 ( 58str (item .get ("content" ,"" )) 59for item in reversed (inputs ) 60if item .get ("role" )== "user" 61 ), 62"" , 63 ) 64if "<newly-evicted-history>" in user : 65return text ("older conversation summary" ,usage = SUMMARY ) 66if user == "use the tool" : 67return Reply ("python" ,"2 + 2" ,usage = TOOL ) 68if user == "hit the limit" : 69return Reply ("error" ,"limit exceeded" ,status = 429 ) 70if user == "after compaction" : 71return text ("post compaction reply" ,usage = POST ) 72if user == "third" : 73return text ("third reply" ,usage = THIRD ) 74return text ("first reply" ,usage = FIRST ) 75 76 77def instant (timestamp ): 78return datetime .fromisoformat (timestamp .replace ("Z" ,"+00:00" )) 79 80 81class ProviderRequestsTest (unittest .TestCase ): 82def rows (self ,app ,session ,query = "" ): 83with app .api (f"/sessions/ { session } /context?view=requests { query } " )as response : 84return json .load (response ) 85 86def history (self ,app ,session ): 87items ,after = [],0 88while True : 89with app .api ( 90f"/sessions/ { session } /history?after= { after } &limit=100" 91 )as response : 92page = json .load (response ) 93items .extend (page ["items" ]) 94if page ["newer" ]is None : 95return items 96after = items [- 1 ]["position" ] 97 98def test_rows_record_usage_identity_prefix_and_projection (self ): 99provider = Provider (reply ) 100try : 101with Albedo (provider ,protocol = "responses" )as app : 102session = app .session () 103for prompt in ("first" ,"use the tool" ,"third" ,"hit the limit" ): 104app .prompt (session ,prompt ).close () 105app .idle (session ) 106with app .api (f"/sessions/ { session } /compaction" , {})as response : 107self .assertEqual (json .load (response )["state" ],"compacted" ) 108app .idle (session ) 109app .prompt (session ,"after compaction" ).close () 110app .idle (session ) 111 112page = self .rows (app ,session ) 113rows = page ["items" ] 114self .assertEqual ( 115 [row ["kind" ]for row in rows ], 116 [ 117"turn" , 118"turn" , 119"turn" , 120"turn" , 121"turn" , 122"summarizer" , 123"summarizer" , 124"turn" , 125 ], 126 ) 127# Every call carries its own reported counts, split as sent. 128self .assertEqual ( 129 [ 130 ( 131row ["prompt_tokens" ]["observed" ], 132row ["completion_tokens" ]["observed" ], 133row ["cached_tokens" ]["observed" ], 134row ["reasoning_tokens" ]["observed" ], 135 ) 136for row in rows 137 ], 138 [ 139 (100 ,20 ,40 ,7 ), 140 (200 ,30 ,80 ,11 ), 141 (300 ,40 ,120 ,3 ), 142 (400 ,50 ,160 ,5 ), 143 (None ,None ,None ,None ), 144 (60 ,10 ,None ,None ), 145 (60 ,10 ,None ,None ), 146 (70 ,15 ,30 ,2 ), 147 ], 148 ) 149# The head (instructions and tools) never changed, so one hash. 150heads = {row ["head_hash" ]for row in rows if row ["kind" ]== "turn" } 151self .assertEqual (len (heads ),1 ) 152self .assertNotIn (rows [5 ]["head_hash" ],heads ) 153 154for row in rows : 155self .assertIsNone (row ["account_id" ]) 156# The shared harness names the fixture profile per route. 157self .assertTrue (row ["provider_profile" ].startswith ("fixture" )) 158self .assertEqual (row ["model" ],"fixture-model" ) 159self .assertTrue (row ["provider" ].startswith ("responses:" )) 160self .assertLessEqual (row ["started_at" ],row ["ended_at" ]) 161# An OpenAI-protocol provider caches on its own; albedo marks nothing. 162self .assertEqual (row ["cache_marks" ], []) 163 164for row in rows [:4 ]+ [rows [7 ]]: 165self .assertEqual (row ["outcome" ],"completed" ) 166self .assertIsNone (row ["http_status" ]) 167self .assertIsNone (row ["failure" ]) 168 169for row in rows [:4 ]: 170# Nothing replaced yet: the identity is a plain append. 171self .assertEqual (row ["replaced_input_count" ],0 ) 172self .assertIsNone (row ["projection_hash" ]) 173 174# A 429 is a quota reading, with no usage and no transcript row. 175limited = rows [4 ] 176self .assertEqual (limited ["outcome" ],"failed" ) 177self .assertEqual (limited ["http_status" ],429 ) 178self .assertEqual (set (limited ["failure" ]), {"code" ,"detail" }) 179self .assertEqual (limited ["failure" ]["code" ],"provider_request_failed" ) 180self .assertIn ("limit exceeded" ,limited ["failure" ]["detail" ]) 181self .assertLessEqual (len (limited ["failure" ]["detail" ]),4096 ) 182self .assertEqual (limited ["transcript_positions" ], []) 183 184# Each turn row's seq is the transcript row its response 185# committed, exactly: the assistant row that turn produced, 186# found again through the tree route straight from the 187# transcript. 188entries = self .history (app ,session ) 189assistant = { 190part ["text" ]:entry ["position" ] 191for entry in entries 192if entry ["kind" ]== "assistant" 193for part in entry ["content" ] 194if part ["kind" ]== "text" 195 } 196for index ,message in ( 197 (0 ,"first reply" ), 198 (2 ,"tool done" ), 199 (3 ,"third reply" ), 200 (7 ,"post compaction reply" ), 201 ): 202self .assertIn ( 203assistant [message ],rows [index ]["transcript_positions" ] 204 ) 205calls = [entry for entry in entries if entry ["kind" ]== "tool_call" ] 206self .assertEqual (len (calls ),1 ) 207self .assertIn (calls [0 ]["position" ],rows [1 ]["transcript_positions" ]) 208tool_output = entries [entries .index (calls [0 ])+ 1 ] 209self .assertEqual (tool_output ["kind" ],"tool_result" ) 210self .assertNotIn ( 211tool_output ["position" ],rows [1 ]["transcript_positions" ] 212 ) 213linked = [ 214position for row in rows for position in row ["transcript_positions" ] 215 ] 216self .assertEqual (linked ,sorted (linked )) 217 218# The summary and the notes rewrite are their own kind, 219# outside the session projection. 220for summary in rows [5 :7 ]: 221self .assertEqual (summary ["transcript_positions" ], []) 222self .assertIsNone (summary ["replaced_input_count" ]) 223self .assertIsNone (summary ["projection_hash" ]) 224self .assertEqual (summary ["input_count" ],1 ) 225 226# The projection identity changes once compaction rewrites the 227# head the verbatim tail hangs from. 228after = rows [7 ] 229self .assertGreater (after ["replaced_input_count" ],0 ) 230self .assertIsNotNone (after ["projection_hash" ]) 231self .assertTrue (after ["strategy" ]) 232 233# Paging: continue after the first row, bounded by limit. 234second = self .rows ( 235app ,session ,f"&after= { rows [ 0 ][ 'sequence' ] } &limit=2" 236 ) 237self .assertEqual ( 238 [row ["sequence" ]for row in second ["items" ]], 239 [row ["sequence" ]for row in rows [1 :3 ]], 240 ) 241self .assertIsNotNone (second ["next" ]) 242finally : 243provider .close () 244 245def test_the_latest_usage_reports_its_calls_span_and_rate (self ): 246provider = Provider (reply ) 247try : 248with Albedo (provider ,protocol = "responses" )as app : 249session = app .session () 250app .prompt (session ,"third" ).close () 251app .idle (session ) 252row = self .rows (app ,session )["items" ][- 1 ] 253with app .api (f"/sessions/ { session } ?tail=0" )as response : 254usage = json .load (response )["usage" ] 255started ,ended = instant (row ["started_at" ]),instant (row ["ended_at" ]) 256span = round ((ended - started ).total_seconds ()* 1000 ) 257self .assertEqual (usage ["completion_tokens" ],50 ) 258self .assertEqual (usage ["elapsed_ms" ],span ) 259if span : 260self .assertAlmostEqual (usage ["tokens_per_second" ],50 * 1000 / span ) 261else : 262self .assertIsNone (usage ["tokens_per_second" ]) 263finally : 264provider .close () 265 266def test_a_failing_gateway_is_retried_with_doubling_waits (self ): 267failures = [] 268 269def script (request ): 270if len (failures )< 4 : 271failures .append (1 ) 272return Reply ("error" ,"gateway down" ,status = 503 ) 273return text ("recovered" ) 274 275provider = Provider (script ) 276try : 277with Albedo (provider ,protocol = "responses" )as app : 278session = app .session () 279app .prompt (session ,"hello" ).close () 280app .idle (session ,timeout = 60 ) 281rows = self .rows (app ,session )["items" ] 282self .assertEqual ( 283 [row ["outcome" ]for row in rows ], ["failed" ]* 4 + ["completed" ] 284 ) 285self .assertEqual ({row ["http_status" ]for row in rows [:4 ]}, {503 }) 286self .assertTrue (rows [4 ]["transcript_positions" ]) 287# Each wait doubles from 250ms, measured from the failed 288# attempt's end to the next one's start. 289gaps = [ 290 ( 291datetime .fromisoformat ( 292after ["started_at" ].replace ("Z" ,"+00:00" ) 293 ) 294- datetime .fromisoformat ( 295before ["ended_at" ].replace ("Z" ,"+00:00" ) 296 ) 297 ).total_seconds () 298* 1000 299for before ,after in zip (rows ,rows [1 :]) 300 ] 301for gap ,wait in zip (gaps , (250 ,500 ,1000 ,2000 )): 302self .assertGreaterEqual (gap ,wait ) 303finally : 304provider .close ()