@extentos/mcp-server 0.0.77 → 0.0.79

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -57,17 +57,23 @@ class CoachHandler(
57
57
  val question = recording.transcript.trim()
58
58
  if (question.isEmpty() || "stop" in question.lowercase()) break
59
59
 
60
- // ask() returns LlmResult. Pattern-match so AuthFailure,
61
- // NetworkError, and Empty each speak a distinct line — the
62
- // F-R2-10 bug was collapsing all three into one message.
60
+ // ask() returns LlmResult 6 variants, each with its own
61
+ // user-facing message. F-R2-10 + F-R3-10: collapsing any of
62
+ // them into one "try again" line confuses users (was it
63
+ // wifi? key? the AI?) and the debugging agent (is retry
64
+ // going to help?). Compile-time exhaustive \`when\` makes
65
+ // adding a new variant break every caller until they
66
+ // handle it explicitly.
63
67
  val spoken = when (val r = anthropic.ask(
64
68
  question = question,
65
69
  workoutHistory = workouts.recent(),
66
70
  )) {
67
- is LlmResult.Ok -> r.text
68
- LlmResult.AuthFailure -> "Your Anthropic key isn't set up — add it to local.properties and rebuild."
69
- LlmResult.NetworkError -> "I couldn't reach the AI. Try again."
70
- LlmResult.Empty -> "I didn't get an answer. Try rephrasing."
71
+ is LlmResult.Ok -> r.text
72
+ LlmResult.AuthFailure -> "Your Anthropic key isn't set up — add it to local.properties and rebuild."
73
+ LlmResult.Connectivity -> "I can't reach the network. Check your connection."
74
+ LlmResult.Transient -> "The AI is slow right now — try again in a moment."
75
+ LlmResult.ClientBug -> "Something's off with the request — check logcat AnthropicClient:E."
76
+ LlmResult.Empty -> "I didn't get an answer. Try rephrasing."
71
77
  }
72
78
  glasses.audio.speak(spoken)
73
79
  }
@@ -114,15 +120,19 @@ class CoachHandler(
114
120
  let question = recording.transcript.trimmingCharacters(in: .whitespaces)
115
121
  if question.isEmpty || question.lowercased().contains("stop") { break }
116
122
 
117
- // ask() returns LlmResult. Switch so AuthFailure, NetworkError,
118
- // and Empty each speak a distinct line — the F-R2-10 bug was
119
- // collapsing all three into one user-facing message.
123
+ // ask() returns LlmResult 6 variants, each with its own
124
+ // user-facing message. F-R2-10 + F-R3-10: collapsing any of
125
+ // them into one "try again" line confuses users and the
126
+ // debugging agent. Exhaustive switch breaks compilation if a
127
+ // new variant is added — that's the point.
120
128
  let spoken: String
121
129
  switch await anthropic.ask(question: question, workoutHistory: workouts.recent()) {
122
- case .ok(let text): spoken = text
123
- case .authFailure: spoken = "Your Anthropic key isn't set up — check Info.plist."
124
- case .networkError: spoken = "I couldn't reach the AI. Try again."
125
- case .empty: spoken = "I didn't get an answer. Try rephrasing."
130
+ case .ok(let text): spoken = text
131
+ case .authFailure: spoken = "Your Anthropic key isn't set up — check Info.plist."
132
+ case .connectivity: spoken = "I can't reach the network. Check your connection."
133
+ case .transient: spoken = "The AI is slow right now — try again in a moment."
134
+ case .clientBug: spoken = "Something's off with the request — check OSLog AnthropicClient."
135
+ case .empty: spoken = "I didn't get an answer. Try rephrasing."
126
136
  }
127
137
  _ = await glasses.audio.speak(spoken)
128
138
  }
@@ -144,7 +154,7 @@ class CoachHandler(
144
154
  "speak() blocks until done by default. If you want speak + listen-for-barge-in simultaneously, see the barge_in_speak pattern.",
145
155
  "BuildConfig API-key fields don't reflect rotations because kotlinc inlines them at compile time. Use resValue / R.string.X for Android secrets. See getCredentialGuide.",
146
156
  "AnthropicClient is a USER-PROVIDED stub — there's no first-party Anthropic Kotlin SDK. Use getCodeExample('byok_anthropic') for a paste-ready minimal OkHttp / URLSession wrapper around POST /v1/messages, and getCredentialGuide('anthropic') for the key-storage pattern. WorkoutRepository is your app's existing data layer — swap in whatever shape your app uses.",
147
- "ask() returns LlmResult, not String. Pattern-match each failure mode (AuthFailure, NetworkError, Empty) into a distinct spoken line flattening them is the R2 F-R2-10 finding (user can't distinguish 'fix your key' from 'AI didn't understand').",
157
+ "ask() returns LlmResult 6 variants: Ok / AuthFailure / Connectivity / Transient / ClientBug / Empty. Pattern-match each into a distinct spoken line. R2 F-R2-10 + R3 F-R3-10: collapsing them obscures both the user's remediation (was it wifi? key? AI?) and the debugging agent's signal (was a retry going to help?).",
148
158
  ],
149
159
  relatedFeatures: ["voice_command", "transcription_incremental", "record_audio"],
150
160
  };
@@ -276,10 +286,12 @@ class VisionHandler(
276
286
  mediaType = mediaType,
277
287
  prompt = "Describe this scene briefly, conversationally.",
278
288
  )) {
279
- is LlmResult.Ok -> r.text
280
- LlmResult.AuthFailure -> "Your Anthropic key isn't set up — add it to local.properties and rebuild."
281
- LlmResult.NetworkError -> "I couldn't reach the vision service. Try again."
282
- LlmResult.Empty -> "I couldn't describe that one. Try a different angle."
289
+ is LlmResult.Ok -> r.text
290
+ LlmResult.AuthFailure -> "Your Anthropic key isn't set up — add it to local.properties and rebuild."
291
+ LlmResult.Connectivity -> "I can't reach the network. Check your connection."
292
+ LlmResult.Transient -> "The vision service is slow right now — try again in a moment."
293
+ LlmResult.ClientBug -> "Something's off with the request — check logcat AnthropicClient:E."
294
+ LlmResult.Empty -> "I couldn't describe that one. Try a different angle."
283
295
  }
284
296
  glasses.audio.speak(spoken)
285
297
  }
@@ -319,14 +331,17 @@ class VisionHandler(
319
331
  // your vision LLM however it expects them (base64, Data).
320
332
  let image = await photo.loadImage()
321
333
  // describe() returns LlmResult — switch so each failure mode
322
- // gets the right user-facing message. Folding all three into
323
- // one "I couldn't describe that one." is the F-R2-10 bug.
334
+ // gets the right user-facing message. F-R2-10 + F-R3-10:
335
+ // collapsing variants into one line confuses both users and
336
+ // the debugging agent.
324
337
  let spoken: String
325
338
  switch await vision.describe(image: image, prompt: "Describe this scene briefly, conversationally.") {
326
- case .ok(let text): spoken = text
327
- case .authFailure: spoken = "Your Anthropic key isn't set up — check Info.plist."
328
- case .networkError: spoken = "I couldn't reach the vision service. Try again."
329
- case .empty: spoken = "I couldn't describe that one. Try a different angle."
339
+ case .ok(let text): spoken = text
340
+ case .authFailure: spoken = "Your Anthropic key isn't set up — check Info.plist."
341
+ case .connectivity: spoken = "I can't reach the network. Check your connection."
342
+ case .transient: spoken = "The vision service is slow right now — try again in a moment."
343
+ case .clientBug: spoken = "Something's off with the request — check OSLog AnthropicClient."
344
+ case .empty: spoken = "I couldn't describe that one. Try a different angle."
330
345
  }
331
346
  _ = await glasses.audio.speak(spoken)
332
347
  }
@@ -343,7 +358,7 @@ class VisionHandler(
343
358
  "Android: Photos.loadBase64(uri) bridges both data: and file: URIs into bytes ready for Claude Vision / GPT-4V request bodies. iOS: `photo.loadImage()` (extension on Photo) returns a UIImage; for base64 you encode the JPEG/PNG data yourself via Data.base64EncodedString().",
344
359
  "Vision LLM response time is 2-8 seconds p95; the user is staring at the glasses waiting. Consider an earcon (`glasses.audio.earcon(EarconSound.START)`) at capture time so they know it worked.",
345
360
  "VisionClient is a USER-PROVIDED stub — see getCodeExample('byok_anthropic') for the canonical Anthropic Claude Vision wrapper (request shape: content blocks with type:image / source:base64 + type:text). The byok_anthropic pattern is paste-ready in both Kotlin and Swift.",
346
- "describe() returns LlmResult, not String. Pattern-match (Kotlin `when` / Swift `switch`) so AuthFailure, NetworkError, and Empty each get a distinct user-facing line. The R2 dogfood showed users hearing 'I couldn't describe that one.' equally for 'API key not set' and 'AI was unsure' the sealed type forces the agent to give each its own message.",
361
+ "describe() returns LlmResult 6 variants: Ok / AuthFailure / Connectivity / Transient / ClientBug / Empty. Pattern-match (Kotlin `when` / Swift `switch`) each into a distinct user-facing line. The R2 dogfood showed users hearing 'I couldn't describe that one.' equally for 'API key not set' and 'AI was unsure'; R3 added the 502-vs-DNS gap. The sealed type forces the agent to give each its own message AND lets the built-in retry loop handle Transient / Connectivity transparently before they surface to your handler.",
347
362
  ],
348
363
  relatedFeatures: ["capture_photo", "voice_command", "transcription_incremental"],
349
364
  };
@@ -580,11 +595,13 @@ struct ContentView: View {
580
595
  };
581
596
  const BYOK_ANTHROPIC = {
582
597
  pattern: "byok_anthropic",
583
- title: "BYOK Anthropic Claude API client (text + Vision, OkHttp / URLSession)",
584
- description: "Minimal AnthropicClient that voice_qa_assistant and photo_describe_voice reference. Two methods: `ask(question, history)` for text-only Q&A, `describe(imageBase64, mediaType, prompt)` for Claude Vision (image content blocks). There is no first-party Anthropic Kotlin SDK; this is the canonical OkHttp + kotlinx.serialization wrapper around POST /v1/messages. iOS uses URLSession + Codable. Paste, then wire the API key through resValue (Android) / Info.plist (iOS) per getCredentialGuide.",
598
+ title: "BYOK Anthropic Claude API client (text + Vision, OkHttp / URLSession, with retry + observability)",
599
+ description: "Minimal AnthropicClient that voice_qa_assistant and photo_describe_voice reference. Two methods: `ask(question, history)` for text-only Q&A, `describe(imageBase64, mediaType, prompt)` for Claude Vision (image content blocks). There is no first-party Anthropic Kotlin SDK; this is the canonical OkHttp + kotlinx.serialization wrapper around POST /v1/messages. iOS uses URLSession + Codable. Returns LlmResult (Ok / AuthFailure / Connectivity / Transient / ClientBug / Empty) — distinct failure variants for distinct user-facing remediations. Built-in retry-with-backoff (200ms / 800ms / 3.2s, 3 attempts) for Transient (429 + 5xx) and Connectivity (IOException) failures so most upstream blips never reach the caller. Optional `observability: glasses.observability` wiring — pass it and every call surfaces under getEventLog's 'ai' filter chip. Paste, then wire the API key through resValue (Android) / Info.plist (iOS) per getCredentialGuide.",
585
600
  code: {
586
- kotlin: `import java.io.IOException
601
+ kotlin: `import com.extentos.glasses.core.ObservabilityClient
602
+ import java.io.IOException
587
603
  import kotlinx.coroutines.Dispatchers
604
+ import kotlinx.coroutines.delay
588
605
  import kotlinx.coroutines.withContext
589
606
  import kotlinx.serialization.SerialName
590
607
  import kotlinx.serialization.Serializable
@@ -608,27 +625,42 @@ import okhttp3.RequestBody.Companion.toRequestBody
608
625
  * exhaustively checked by the compiler — adding a new variant forces
609
626
  * every handler to acknowledge the new case.
610
627
  *
611
- * The four variants distinguish failure modes that produce the SAME
612
- * empty-string symptom if you collapse them, and that need DIFFERENT
613
- * user-facing responses on a voice-glasses app:
614
- * Ok — speak / display the text.
615
- * AuthFailure key missing, invalid, or no billing. Actionable by the
616
- * dev (rotate / configure the key); not actionable by
617
- * the user. UX: "Your Anthropic key isn't set up."
618
- * NetworkError— transient (no internet, DNS, timeout, 429 rate-limit,
619
- * 5xx server). UX: "Try again in a moment."
620
- * Empty — 2xx with no text content. Usually means the model
621
- * couldn't make sense of the input. UX: "I couldn't
622
- * describe that one."
628
+ * F-R3-10 splits the previous \`NetworkError\` lump into three failure
629
+ * modes that have DIFFERENT remediations collapsing them into one
630
+ * "try again" message confuses both the user (was it wifi? was it the
631
+ * AI?) and the agent debugging the flow (is retry-with-backoff going
632
+ * to help?).
623
633
  *
624
- * R2 dogfood F-R2-10: returning bare String collapses all three failure
625
- * modes into "" and the user hears the same recorded message for every
626
- * one they can't tell "fix your key" from "the AI didn't understand".
634
+ * Ok — speak / display the text.
635
+ * AuthFailure — 401/403 OR blank key. Actionable by the dev (rotate
636
+ * / configure the key); not by the user. Never retried.
637
+ * UX: "Your Anthropic key isn't set up."
638
+ * Connectivity — IOException (DNS / no internet / TLS / socket
639
+ * timeout). Retry won't help until network returns.
640
+ * UX: "I can't reach the network — check your connection."
641
+ * Transient — 429 rate-limit OR 5xx server error. RETRIED inside
642
+ * post() with exponential backoff (200ms / 800ms /
643
+ * 3.2s). If still failing after the retry budget,
644
+ * surfaces as this variant. UX: "The AI is slow right
645
+ * now — try again in a moment."
646
+ * ClientBug — 400 / 404 / 422 / any 4xx that isn't 401/403/429.
647
+ * The request itself is malformed; retry will never
648
+ * fix it. Surface loud in logs; UX is best-effort:
649
+ * "Something's off with the request" + log it for the
650
+ * dev to investigate.
651
+ * Empty — 2xx with no text content. Model couldn't make sense
652
+ * of the input. UX: "I couldn't describe that one."
653
+ *
654
+ * R2 dogfood F-R2-10 + R3 dogfood F-R3-10: bare String / single
655
+ * NetworkError variant produced the same user-facing message regardless
656
+ * of cause.
627
657
  */
628
658
  sealed interface LlmResult {
629
659
  data class Ok(val text: String) : LlmResult
630
660
  data object AuthFailure : LlmResult
631
- data object NetworkError : LlmResult
661
+ data object Connectivity : LlmResult
662
+ data object Transient : LlmResult
663
+ data object ClientBug : LlmResult
632
664
  data object Empty : LlmResult
633
665
  }
634
666
 
@@ -637,6 +669,23 @@ class AnthropicClient(
637
669
  private val model: String = "claude-opus-4-7",
638
670
  private val maxTokens: Int = 1024,
639
671
  private val client: OkHttpClient = OkHttpClient(),
672
+ /**
673
+ * Optional: when provided, every ask() / describe() call is wrapped
674
+ * via observability.aiCall("anthropic.<method>") so it appears in
675
+ * getEventLog under the "ai" filter chip. Pass \`glasses.observability\`
676
+ * from your ExtentosGlasses instance. Default null = no wrapping
677
+ * (call is invisible to the simulator event log; you'll need adb
678
+ * logcat to diagnose failures).
679
+ */
680
+ private val observability: ObservabilityClient? = null,
681
+ /**
682
+ * Retry budget for Transient (5xx, 429) and Connectivity (IOException)
683
+ * failures. Each retry waits longer (200ms → 800ms → 3.2s). After
684
+ * the budget is exhausted the failure surfaces as a variant.
685
+ * Default 3 attempts total (initial + 2 retries) cap end-to-end at
686
+ * ~1.2s if all transient.
687
+ */
688
+ private val maxAttempts: Int = 3,
640
689
  ) {
641
690
  private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
642
691
 
@@ -676,14 +725,16 @@ class AnthropicClient(
676
725
  /** Text-only Q&A. Anthropic's content field accepts either a bare String or a
677
726
  * list of ContentBlock; we use the list shape so this client can also do Vision. */
678
727
  suspend fun ask(question: String, history: List<String> = emptyList()): LlmResult =
679
- post(
680
- messages = buildList {
681
- for (turn in history) {
682
- add(Message("user", listOf(ContentBlock.Text(turn))))
683
- }
684
- add(Message("user", listOf(ContentBlock.Text(question))))
685
- },
686
- )
728
+ wrap("anthropic.ask") {
729
+ post(
730
+ messages = buildList {
731
+ for (turn in history) {
732
+ add(Message("user", listOf(ContentBlock.Text(turn))))
733
+ }
734
+ add(Message("user", listOf(ContentBlock.Text(question))))
735
+ },
736
+ )
737
+ }
687
738
 
688
739
  /** Vision: send one image (base64) + a text prompt. Use Photos.loadBase64(uri) and
689
740
  * Photos.mediaTypeFromUri(uri) (from com.extentos.glasses.core.Photos) to source
@@ -693,83 +744,147 @@ class AnthropicClient(
693
744
  mediaType: String,
694
745
  prompt: String,
695
746
  system: String? = null,
696
- ): LlmResult = post(
697
- messages = listOf(
698
- Message(
699
- "user",
700
- listOf(
701
- ContentBlock.Image(ImageSource(mediaType = mediaType, data = imageBase64)),
702
- ContentBlock.Text(prompt),
747
+ ): LlmResult = wrap("anthropic.describe", mapOf("media_type" to mediaType)) {
748
+ post(
749
+ messages = listOf(
750
+ Message(
751
+ "user",
752
+ listOf(
753
+ ContentBlock.Image(ImageSource(mediaType = mediaType, data = imageBase64)),
754
+ ContentBlock.Text(prompt),
755
+ ),
703
756
  ),
704
757
  ),
705
- ),
706
- system = system,
707
- )
758
+ system = system,
759
+ )
760
+ }
708
761
 
709
- // ---- Transport ----
762
+ // ---- Wrap each call in observability.aiCall when wired ----
763
+
764
+ private suspend fun wrap(
765
+ label: String,
766
+ metadata: Map<String, String> = emptyMap(),
767
+ block: suspend () -> LlmResult,
768
+ ): LlmResult {
769
+ val obs = observability ?: return block()
770
+ val baseMeta = buildMap {
771
+ put("model", model)
772
+ putAll(metadata)
773
+ }
774
+ return obs.aiCall(label, baseMeta) { block() }
775
+ }
710
776
 
711
- private suspend fun post(messages: List<Message>, system: String? = null): LlmResult =
712
- withContext(Dispatchers.IO) {
713
- if (apiKey.isBlank()) return@withContext LlmResult.AuthFailure
714
- val body = json.encodeToString(
715
- Req.serializer(),
716
- Req(model = model, maxTokens = maxTokens, messages = messages, system = system),
717
- )
718
- val request = Request.Builder()
719
- .url("https://api.anthropic.com/v1/messages")
720
- .header("x-api-key", apiKey)
721
- .header("anthropic-version", "2023-06-01")
722
- .header("content-type", "application/json")
723
- .post(body.toRequestBody("application/json".toMediaType()))
724
- .build()
725
- try {
726
- client.newCall(request).execute().use { response ->
727
- val payload = response.body?.string().orEmpty()
728
- when {
729
- // 401/403: invalid / missing key, no billing on the
730
- // account. Caller fixes config; user can't.
731
- response.code == 401 || response.code == 403 -> {
732
- android.util.Log.w("AnthropicClient", "Auth failure (\${response.code}): \${payload.take(500)}")
733
- LlmResult.AuthFailure
734
- }
735
- // 429 + 5xx + any other non-2xx: transient from the
736
- // app's perspective. Retry-with-backoff is the right
737
- // strategy; the user-facing message is "try again".
738
- !response.isSuccessful -> {
739
- android.util.Log.w("AnthropicClient", "HTTP \${response.code}: \${payload.take(500)}")
740
- LlmResult.NetworkError
741
- }
742
- else -> {
743
- val text = json.decodeFromString(Res.serializer(), payload)
744
- .content.firstOrNull { it.type == "text" }?.text.orEmpty().trim()
745
- if (text.isEmpty()) LlmResult.Empty else LlmResult.Ok(text)
746
- }
777
+ // ---- Transport with retry-with-backoff ----
778
+
779
+ private suspend fun post(messages: List<Message>, system: String? = null): LlmResult {
780
+ if (apiKey.isBlank()) return LlmResult.AuthFailure
781
+ val body = json.encodeToString(
782
+ Req.serializer(),
783
+ Req(model = model, maxTokens = maxTokens, messages = messages, system = system),
784
+ )
785
+ var attempt = 0
786
+ var lastFailure: LlmResult = LlmResult.Transient
787
+ while (attempt < maxAttempts) {
788
+ val result = withContext(Dispatchers.IO) { postOnce(body) }
789
+ when (result) {
790
+ // Definitive outcomes — return immediately, no retry.
791
+ is LlmResult.Ok,
792
+ LlmResult.AuthFailure,
793
+ LlmResult.ClientBug,
794
+ LlmResult.Empty -> return result
795
+ // Retryable: hold onto the variant, back off, try again.
796
+ LlmResult.Transient,
797
+ LlmResult.Connectivity -> {
798
+ lastFailure = result
799
+ attempt++
800
+ if (attempt < maxAttempts) {
801
+ // 200ms, 800ms, 3.2s geometric x4. Caps total
802
+ // wait at 4.2s across 3 retries. Tune via
803
+ // maxAttempts if your UX wants more / less.
804
+ delay(200L * (1L shl (2 * (attempt - 1))))
747
805
  }
748
806
  }
749
- } catch (e: IOException) {
750
- // No HTTP exchange happened — DNS failure, no internet,
751
- // socket timeout. Same UX as a transient 5xx.
752
- android.util.Log.w("AnthropicClient", "Network error: \${e.message}")
753
- LlmResult.NetworkError
754
807
  }
755
808
  }
809
+ return lastFailure
810
+ }
811
+
812
+ private fun postOnce(body: String): LlmResult {
813
+ val request = Request.Builder()
814
+ .url("https://api.anthropic.com/v1/messages")
815
+ .header("x-api-key", apiKey)
816
+ .header("anthropic-version", "2023-06-01")
817
+ .header("content-type", "application/json")
818
+ .post(body.toRequestBody("application/json".toMediaType()))
819
+ .build()
820
+ return try {
821
+ client.newCall(request).execute().use { response ->
822
+ val payload = response.body?.string().orEmpty()
823
+ when {
824
+ // 401/403: invalid / missing key, no billing on the
825
+ // account. Caller fixes config; user can't. Never retried.
826
+ response.code == 401 || response.code == 403 -> {
827
+ android.util.Log.w("AnthropicClient", "Auth failure (\${response.code}): \${payload.take(500)}")
828
+ LlmResult.AuthFailure
829
+ }
830
+ // 429 + 5xx: retryable upstream blip. The wrapper
831
+ // loops with backoff before this surfaces to the caller.
832
+ response.code == 429 || response.code in 500..599 -> {
833
+ android.util.Log.w("AnthropicClient", "Transient (\${response.code}): \${payload.take(500)}")
834
+ LlmResult.Transient
835
+ }
836
+ // Other 4xx (400, 404, 422, …): malformed request.
837
+ // Retry will never fix it; surface loud so the dev
838
+ // sees the bug in their request shape.
839
+ response.code in 400..499 -> {
840
+ android.util.Log.e("AnthropicClient", "Client bug (\${response.code}): \${payload.take(500)}")
841
+ LlmResult.ClientBug
842
+ }
843
+ !response.isSuccessful -> {
844
+ // Catch-all for the few non-2xx outside 4xx/5xx
845
+ // (1xx/3xx in practice). Treat as transient.
846
+ android.util.Log.w("AnthropicClient", "Unexpected status (\${response.code}): \${payload.take(500)}")
847
+ LlmResult.Transient
848
+ }
849
+ else -> {
850
+ val text = json.decodeFromString(Res.serializer(), payload)
851
+ .content.firstOrNull { it.type == "text" }?.text.orEmpty().trim()
852
+ if (text.isEmpty()) LlmResult.Empty else LlmResult.Ok(text)
853
+ }
854
+ }
855
+ }
856
+ } catch (e: IOException) {
857
+ // No HTTP exchange happened — DNS failure, no internet,
858
+ // socket timeout. UX is "check your connection," not "the
859
+ // AI is slow" — distinguish in the sealed type.
860
+ android.util.Log.w("AnthropicClient", "Connectivity: \${e.message}")
861
+ LlmResult.Connectivity
862
+ }
863
+ }
756
864
  }
757
865
 
758
866
  // Wire-up — your handler retrieves the API key from the resource string
759
867
  // emitted by your build.gradle.kts (see getCredentialGuide('anthropic')
760
868
  // for the resValue pattern), then passes it to a single client instance
761
- // held at Application scope. Both ask() and describe() return LlmResult;
762
- // pattern-match to give the user the right message per failure mode.
869
+ // held at Application scope. Pass glasses.observability so each call
870
+ // surfaces under getEventLog's "ai" filter (F-R3-11). Both ask() and
871
+ // describe() return LlmResult; pattern-match to give the user the
872
+ // right message per failure mode.
763
873
  //
764
874
  // val apiKey = context.getString(R.string.anthropic_api_key)
765
- // val anthropic = AnthropicClient(apiKey)
875
+ // val anthropic = AnthropicClient(
876
+ // apiKey = apiKey,
877
+ // observability = glasses.observability, // surfaces in getEventLog "ai" chip
878
+ // )
766
879
  //
767
880
  // // text:
768
881
  // val spoken = when (val r = anthropic.ask("How many bench reps today?")) {
769
- // is LlmResult.Ok -> r.text
770
- // LlmResult.AuthFailure -> "Your Anthropic key isn't set up — add it to local.properties and rebuild."
771
- // LlmResult.NetworkError -> "I couldn't reach the AI. Try again."
772
- // LlmResult.Empty -> "I didn't get an answer. Try rephrasing."
882
+ // is LlmResult.Ok -> r.text
883
+ // LlmResult.AuthFailure -> "Your Anthropic key isn't set up — add it to local.properties and rebuild."
884
+ // LlmResult.Connectivity -> "I can't reach the network. Check your connection."
885
+ // LlmResult.Transient -> "The AI is slow right now — try again in a moment."
886
+ // LlmResult.ClientBug -> "Something's off with the request — check logcat AnthropicClient:E for the response body."
887
+ // LlmResult.Empty -> "I didn't get an answer. Try rephrasing."
773
888
  // }
774
889
  // glasses.audio.speak(spoken)
775
890
  //
@@ -783,31 +898,41 @@ class AnthropicClient(
783
898
  // val result = anthropic.describe(b64, mt, "What card is showing?")
784
899
  // // when(result) { is LlmResult.Ok -> result.text; ...other cases... }`,
785
900
  swift: `import Foundation
901
+ import GlassesCore
786
902
 
787
903
  /// Outcome of a single ask/describe call. Sealed via Swift enum so a
788
904
  /// \`switch result\` on this is exhaustively checked — adding a new
789
905
  /// case forces every handler to acknowledge it.
790
906
  ///
791
- /// The four cases distinguish failure modes that produce the SAME
792
- /// empty-string symptom if you flatten them, and that need DIFFERENT
793
- /// user-facing responses on a voice-glasses app:
794
- /// .ok — speak / display the text.
795
- /// .authFailure key missing, invalid, or no billing. Actionable by
796
- /// the dev; not by the user. UX: "Your Anthropic key
797
- /// isn't set up."
798
- /// .networkError— transient (no internet, DNS, timeout, 429, 5xx).
799
- /// UX: "Try again in a moment."
800
- /// .empty — 2xx with no text content. Usually means the model
801
- /// couldn't make sense of the input. UX: "I couldn't
802
- /// describe that one."
907
+ /// F-R3-10 splits the previous \`networkError\` lump into three failure
908
+ /// modes that have DIFFERENT remediations collapsing them into one
909
+ /// "try again" message confuses both the user (was it wifi? was it the
910
+ /// AI?) and the agent debugging the flow (is retry-with-backoff going
911
+ /// to help?).
803
912
  ///
804
- /// R2 dogfood F-R2-10: throwing/empty-string semantics collapsed all
805
- /// three failure modes into one, so the user heard the same recorded
806
- /// message regardless of cause.
913
+ /// .ok — speak / display the text.
914
+ /// .authFailure — 401/403 OR blank key. Actionable by the dev (rotate
915
+ /// / configure the key); not by the user. Never retried.
916
+ /// UX: "Your Anthropic key isn't set up."
917
+ /// .connectivity — URLSession threw (DNS / no internet / TLS / timeout).
918
+ /// Retry won't help until network returns.
919
+ /// UX: "I can't reach the network — check your connection."
920
+ /// .transient — 429 rate-limit OR 5xx server error. RETRIED inside
921
+ /// post() with exponential backoff (200ms / 800ms /
922
+ /// 3.2s). If still failing after the retry budget,
923
+ /// surfaces as this case. UX: "The AI is slow right
924
+ /// now — try again in a moment."
925
+ /// .clientBug — 400 / 404 / 422 / any 4xx that isn't 401/403/429.
926
+ /// The request itself is malformed; retry will never
927
+ /// fix it. Surface loud in logs; UX is best-effort.
928
+ /// .empty — 2xx with no text content. Model couldn't make sense
929
+ /// of the input. UX: "I couldn't describe that one."
807
930
  enum LlmResult {
808
931
  case ok(String)
809
932
  case authFailure
810
- case networkError
933
+ case connectivity
934
+ case transient
935
+ case clientBug
811
936
  case empty
812
937
  }
813
938
 
@@ -815,12 +940,26 @@ actor AnthropicClient {
815
940
  private let apiKey: String
816
941
  private let model: String
817
942
  private let maxTokens: Int
943
+ private let observability: (any ObservabilityClient)?
944
+ private let maxAttempts: Int
818
945
  private let endpoint = URL(string: "https://api.anthropic.com/v1/messages")!
819
946
 
820
- init(apiKey: String, model: String = "claude-opus-4-7", maxTokens: Int = 1024) {
947
+ /// Pass \`glasses.observability\` from your ExtentosGlasses instance to
948
+ /// surface each call in getEventLog under the "ai" filter chip (F-R3-11).
949
+ /// \`maxAttempts\` controls the retry budget for .transient / .connectivity
950
+ /// failures (200ms / 800ms / 3.2s exponential backoff).
951
+ init(
952
+ apiKey: String,
953
+ model: String = "claude-opus-4-7",
954
+ maxTokens: Int = 1024,
955
+ observability: (any ObservabilityClient)? = nil,
956
+ maxAttempts: Int = 3
957
+ ) {
821
958
  self.apiKey = apiKey
822
959
  self.model = model
823
960
  self.maxTokens = maxTokens
961
+ self.observability = observability
962
+ self.maxAttempts = maxAttempts
824
963
  }
825
964
 
826
965
  // ---- Wire schema ----
@@ -872,7 +1011,9 @@ actor AnthropicClient {
872
1011
  Message(role: "user", content: [.text($0)])
873
1012
  }
874
1013
  messages.append(Message(role: "user", content: [.text(question)]))
875
- return await post(messages: messages, system: nil)
1014
+ return await wrap(label: "anthropic.ask") {
1015
+ await post(messages: messages, system: nil)
1016
+ }
876
1017
  }
877
1018
 
878
1019
  /// Vision: one image (base64) + a text prompt. Source the imageBase64 +
@@ -893,25 +1034,67 @@ actor AnthropicClient {
893
1034
  ],
894
1035
  ),
895
1036
  ]
896
- return await post(messages: messages, system: system)
1037
+ return await wrap(label: "anthropic.describe", metadata: ["media_type": mediaType]) {
1038
+ await post(messages: messages, system: system)
1039
+ }
1040
+ }
1041
+
1042
+ // ---- Wrap each call in observability.aiCall when wired ----
1043
+
1044
+ private func wrap(
1045
+ label: String,
1046
+ metadata: [String: String] = [:],
1047
+ block: () async -> LlmResult
1048
+ ) async -> LlmResult {
1049
+ guard let obs = observability else { return await block() }
1050
+ var meta = metadata
1051
+ meta["model"] = model
1052
+ return await obs.aiCall(label: label, metadata: meta) {
1053
+ await block()
1054
+ }
897
1055
  }
898
1056
 
899
- // ---- Transport ----
1057
+ // ---- Transport with retry-with-backoff ----
900
1058
 
901
1059
  private func post(messages: [Message], system: String?) async -> LlmResult {
902
1060
  guard !apiKey.isEmpty else { return .authFailure }
903
- var request = URLRequest(url: endpoint)
904
- request.httpMethod = "POST"
1061
+ let body: Data
905
1062
  do {
906
- request.httpBody = try JSONEncoder().encode(
1063
+ body = try JSONEncoder().encode(
907
1064
  Req(model: model, max_tokens: maxTokens, messages: messages, system: system)
908
1065
  )
909
1066
  } catch {
910
- // Encoding error — typically a programming bug, not a network
911
- // condition. Surface as networkError since it's not auth and
912
- // not "the model returned nothing"; the handler will retry.
913
- return .networkError
1067
+ // Encoding error — programming bug. Surface as clientBug so
1068
+ // the dev sees it loud; retry will never fix it.
1069
+ return .clientBug
1070
+ }
1071
+
1072
+ var attempt = 0
1073
+ var lastFailure: LlmResult = .transient
1074
+ while attempt < maxAttempts {
1075
+ let result = await postOnce(body: body)
1076
+ switch result {
1077
+ // Definitive — return immediately, no retry.
1078
+ case .ok, .authFailure, .clientBug, .empty:
1079
+ return result
1080
+ // Retryable.
1081
+ case .transient, .connectivity:
1082
+ lastFailure = result
1083
+ attempt += 1
1084
+ if attempt < maxAttempts {
1085
+ // 200ms, 800ms, 3.2s.
1086
+ let backoffMs = UInt64(200) << (2 * UInt64(attempt - 1))
1087
+ try? await Task.sleep(nanoseconds: backoffMs * 1_000_000)
1088
+ }
1089
+ }
914
1090
  }
1091
+ return lastFailure
1092
+ }
1093
+
1094
+ private func postOnce(body: Data) async -> LlmResult {
1095
+ var request = URLRequest(url: endpoint)
1096
+ request.httpMethod = "POST"
1097
+ request.httpBody = body
915
1098
  request.setValue(apiKey, forHTTPHeaderField: "x-api-key")
916
1099
  request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version")
917
1100
  request.setValue("application/json", forHTTPHeaderField: "content-type")
@@ -921,20 +1104,24 @@ actor AnthropicClient {
921
1104
  do {
922
1105
  (data, response) = try await URLSession.shared.data(for: request)
923
1106
  } catch {
924
- // No HTTP exchange — DNS / no internet / timeout. Same UX
925
- // as a transient 5xx.
926
- return .networkError
1107
+ // No HTTP exchange — DNS / no internet / TLS / timeout.
1108
+ return .connectivity
927
1109
  }
928
1110
 
929
1111
  if let http = response as? HTTPURLResponse {
930
1112
  switch http.statusCode {
931
1113
  case 401, 403:
932
1114
  return .authFailure
1115
+ case 429, 500...599:
1116
+ return .transient
1117
+ case 400...499:
1118
+ // Other 4xx — malformed request, not retryable.
1119
+ return .clientBug
933
1120
  case 200..<300:
934
1121
  break
935
1122
  default:
936
- // 429 + 5xx + everything else non-2xx → retryable.
937
- return .networkError
1123
+ // 1xx / 3xx unexpected treat as transient.
1124
+ return .transient
938
1125
  }
939
1126
  }
940
1127
 
@@ -944,8 +1131,9 @@ actor AnthropicClient {
944
1131
  return text.isEmpty ? .empty : .ok(text)
945
1132
  } catch {
946
1133
  // 2xx with an undecodable body — treat as empty rather than
947
- // network so the user-facing message reflects "got something
948
- // back, didn't understand it" rather than "couldn't reach".
1134
+ // a network class so the user-facing message reflects "got
1135
+ // something back, didn't understand it" rather than "couldn't
1136
+ // reach".
949
1137
  return .empty
950
1138
  }
951
1139
  }
@@ -953,20 +1141,26 @@ actor AnthropicClient {
953
1141
 
954
1142
  // Wire-up — your handler reads the API key from Info.plist (which gets
955
1143
  // it via xcconfig / env var — see getCredentialGuide for the storage
956
- // pattern), then passes it to a single actor instance. Both ask() and
957
- // describe() return LlmResult; switch on it to give the user the right
958
- // message per failure mode.
1144
+ // pattern), then passes it to a single actor instance. Pass
1145
+ // glasses.observability so each call surfaces under getEventLog's "ai"
1146
+ // filter (F-R3-11). Both ask() and describe() return LlmResult; switch
1147
+ // on it to give the user the right message per failure mode.
959
1148
  //
960
1149
  // let key = Bundle.main.object(forInfoDictionaryKey: "ANTHROPIC_API_KEY") as? String ?? ""
961
- // let anthropic = AnthropicClient(apiKey: key)
1150
+ // let anthropic = AnthropicClient(
1151
+ // apiKey: key,
1152
+ // observability: glasses.observability // surfaces in getEventLog "ai" chip
1153
+ // )
962
1154
  //
963
1155
  // // text:
964
1156
  // let spoken: String
965
1157
  // switch await anthropic.ask(question: "How many bench reps today?") {
966
- // case .ok(let text): spoken = text
967
- // case .authFailure: spoken = "Your Anthropic key isn't set up — check Info.plist."
968
- // case .networkError: spoken = "I couldn't reach the AI. Try again."
969
- // case .empty: spoken = "I didn't get an answer. Try rephrasing."
1158
+ // case .ok(let text): spoken = text
1159
+ // case .authFailure: spoken = "Your Anthropic key isn't set up — check Info.plist."
1160
+ // case .connectivity: spoken = "I can't reach the network. Check your connection."
1161
+ // case .transient: spoken = "The AI is slow right now — try again in a moment."
1162
+ // case .clientBug: spoken = "Something's off with the request — check OSLog AnthropicClient for the response body."
1163
+ // case .empty: spoken = "I didn't get an answer. Try rephrasing."
970
1164
  // }
971
1165
  // _ = await glasses.audio.speak(spoken)
972
1166
  //
@@ -985,7 +1179,7 @@ actor AnthropicClient {
985
1179
  // )
986
1180
  // // switch answer { case .ok(let t): ...; case .authFailure: ...; ... }`,
987
1181
  },
988
- explanation: "Minimal direct client supporting BOTH text Q&A (`ask`) and Claude Vision (`describe`). There's no first-party Anthropic Kotlin SDK; this is the wire-protocol wrapper voice_qa_assistant and photo_describe_voice patterns reference as `AnthropicClient`. Kotlin uses OkHttp + kotlinx-serialization with a `JsonClassDiscriminator(\"type\")` sealed ContentBlock that mirrors Anthropic's polymorphic content shape (text + image). Swift uses URLSession + Codable with manual encode/decode for the same polymorphism. The customer holds one instance at Application scope and passes it to handlers via DI.",
1182
+ explanation: "Minimal direct client supporting BOTH text Q&A (`ask`) and Claude Vision (`describe`). There's no first-party Anthropic Kotlin SDK; this is the wire-protocol wrapper voice_qa_assistant and photo_describe_voice patterns reference as `AnthropicClient`. Kotlin uses OkHttp + kotlinx-serialization with a `JsonClassDiscriminator(\"type\")` sealed ContentBlock that mirrors Anthropic's polymorphic content shape (text + image). Swift uses URLSession + Codable with manual encode/decode for the same polymorphism. The customer holds one instance at Application scope and passes it to handlers via DI. F-R3-10 split the failure mode out of one `NetworkError` into five distinct LlmResult variants (Connectivity / Transient / ClientBug / AuthFailure / Empty) so different remediations show up as different user-facing messages, and added a retry-with-backoff loop (200ms / 800ms / 3.2s, 3 attempts) for Transient + Connectivity cases. F-R3-11 added optional `observability: glasses.observability` wiring — pass it and every call surfaces under getEventLog's 'ai' filter chip with timing + success/error metadata.",
989
1183
  gotchas: [
990
1184
  "API key MUST come from a build-injected resource (resValue on Android, xcconfig on iOS). Don't paste literals — they leak into git. See getCredentialGuide for the storage pattern.",
991
1185
  "Anthropic-version header is required and version-pinned — bump '2023-06-01' when Anthropic releases a new API version that adds features you need.",
@@ -993,7 +1187,9 @@ actor AnthropicClient {
993
1187
  "max_tokens defaults to 1024 here — bump for longer answers, but the glasses TTS engine will speak whatever comes back, so consider trimming on your side before speak() rather than asking for unbounded length. For Vision specifically, 256 is often plenty (you typically want a short action: hit / stand / split, not paragraphs).",
994
1188
  "history is a list of prior user turns. For real multi-turn you'll likely want to interleave 'user' and 'assistant' roles — extend the schema's role enum and pass alternating turns.",
995
1189
  "OkHttp's .execute() blocks the calling thread — wrapping in withContext(Dispatchers.IO) keeps it off the main dispatcher. Don't call ask() / describe() from the UI thread directly.",
996
- "Vision LLM response p95 is 2-8 seconds. Play an earcon (`glasses.audio.earcon(EarconSound.START)`) BEFORE the call so the user knows the request fired.",
1190
+ "Vision LLM response p95 is 2-8 seconds. Play an earcon (`glasses.audio.earcon(EarconSound.START)`) BEFORE the call so the user knows the request fired. The retry-with-backoff loop adds up to ~4.2s in the worst-case Transient path, so plan UX timing accordingly (or lower maxAttempts).",
1191
+ "LlmResult has 6 variants — Ok / AuthFailure / Connectivity / Transient / ClientBug / Empty. Each has a distinct user-facing message; don't collapse them. The compile-time exhaustive `when` (Kotlin) / `switch` (Swift) makes the requirement explicit — adding a new variant breaks every caller until they handle it. Don't try/catch around the call; the sealed type IS the error channel.",
1192
+ "Pass `observability: glasses.observability` to surface each call in getEventLog under the 'ai' filter chip. Without it, BYOK calls are invisible to the simulator event log — debugging a silent failure requires `adb logcat AnthropicClient:V` (Android) or OSLog filtered by `AnthropicClient` (iOS). The wrapper is zero-overhead off-simulator transports (RealMeta / LocalSim).",
997
1193
  ],
998
1194
  relatedFeatures: ["capture_photo"],
999
1195
  requiredDependencies: {