@extentos/mcp-server 0.0.78 → 0.0.80
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.
- package/dist/cli/setup.d.ts.map +1 -1
- package/dist/cli/setup.js +47 -17
- package/dist/cli/setup.js.map +1 -1
- package/dist/tools/data/capabilities.d.ts +30 -11
- package/dist/tools/data/capabilities.d.ts.map +1 -1
- package/dist/tools/data/capabilities.js +31 -12
- package/dist/tools/data/capabilities.js.map +1 -1
- package/dist/tools/data/capabilityPatterns.d.ts.map +1 -1
- package/dist/tools/data/capabilityPatterns.js +180 -7
- package/dist/tools/data/capabilityPatterns.js.map +1 -1
- package/dist/tools/data/codeExamples.d.ts.map +1 -1
- package/dist/tools/data/codeExamples.js +352 -156
- package/dist/tools/data/codeExamples.js.map +1 -1
- package/dist/tools/data/version.js +7 -7
- package/dist/tools/definitions.d.ts.map +1 -1
- package/dist/tools/definitions.js +48 -15
- package/dist/tools/definitions.js.map +1 -1
- package/dist/tools/docs/index.d.ts +12 -0
- package/dist/tools/docs/index.d.ts.map +1 -1
- package/dist/tools/docs/index.js +41 -4
- package/dist/tools/docs/index.js.map +1 -1
- package/dist/tools/handlers/generateConnectionModule.d.ts.map +1 -1
- package/dist/tools/handlers/generateConnectionModule.js +136 -61
- package/dist/tools/handlers/generateConnectionModule.js.map +1 -1
- package/dist/tools/handlers/getPlatformInfo.d.ts.map +1 -1
- package/dist/tools/handlers/getPlatformInfo.js +8 -8
- package/dist/tools/handlers/getPlatformInfo.js.map +1 -1
- package/dist/tools/handlers/injectTranscript.d.ts +3 -0
- package/dist/tools/handlers/injectTranscript.d.ts.map +1 -0
- package/dist/tools/handlers/injectTranscript.js +89 -0
- package/dist/tools/handlers/injectTranscript.js.map +1 -0
- package/dist/tools/handlers/searchDocs.d.ts.map +1 -1
- package/dist/tools/handlers/searchDocs.js +36 -11
- package/dist/tools/handlers/searchDocs.js.map +1 -1
- package/dist/tools/handlers/validateIntegration.d.ts.map +1 -1
- package/dist/tools/handlers/validateIntegration.js +90 -21
- package/dist/tools/handlers/validateIntegration.js.map +1 -1
- package/dist/tools/registry.d.ts.map +1 -1
- package/dist/tools/registry.js +2 -0
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/templates/androidBootstrap.d.ts.map +1 -1
- package/dist/tools/templates/androidBootstrap.js +16 -1
- package/dist/tools/templates/androidBootstrap.js.map +1 -1
- package/dist/tools/util/permissions.d.ts.map +1 -1
- package/dist/tools/util/permissions.js +70 -30
- package/dist/tools/util/permissions.js.map +1 -1
- package/package.json +1 -1
|
@@ -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
|
|
61
|
-
//
|
|
62
|
-
//
|
|
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
|
|
68
|
-
LlmResult.AuthFailure
|
|
69
|
-
LlmResult.
|
|
70
|
-
LlmResult.
|
|
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
|
|
118
|
-
//
|
|
119
|
-
//
|
|
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):
|
|
123
|
-
case .authFailure:
|
|
124
|
-
case .
|
|
125
|
-
case .
|
|
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
|
|
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
|
|
280
|
-
LlmResult.AuthFailure
|
|
281
|
-
LlmResult.
|
|
282
|
-
LlmResult.
|
|
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.
|
|
323
|
-
//
|
|
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):
|
|
327
|
-
case .authFailure:
|
|
328
|
-
case .
|
|
329
|
-
case .
|
|
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
|
|
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
|
|
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
|
-
*
|
|
612
|
-
*
|
|
613
|
-
* user
|
|
614
|
-
*
|
|
615
|
-
*
|
|
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
|
-
*
|
|
625
|
-
*
|
|
626
|
-
*
|
|
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
|
|
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
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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 =
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
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
|
-
|
|
707
|
-
|
|
758
|
+
system = system,
|
|
759
|
+
)
|
|
760
|
+
}
|
|
708
761
|
|
|
709
|
-
// ----
|
|
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
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
)
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
.
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
//
|
|
736
|
-
//
|
|
737
|
-
//
|
|
738
|
-
|
|
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.
|
|
762
|
-
//
|
|
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(
|
|
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
|
|
770
|
-
// LlmResult.AuthFailure
|
|
771
|
-
// LlmResult.
|
|
772
|
-
// LlmResult.
|
|
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
|
-
///
|
|
792
|
-
///
|
|
793
|
-
/// user
|
|
794
|
-
///
|
|
795
|
-
///
|
|
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
|
-
///
|
|
805
|
-
///
|
|
806
|
-
///
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
904
|
-
request.httpMethod = "POST"
|
|
1061
|
+
let body: Data
|
|
905
1062
|
do {
|
|
906
|
-
|
|
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 —
|
|
911
|
-
//
|
|
912
|
-
|
|
913
|
-
|
|
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.
|
|
925
|
-
|
|
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
|
-
//
|
|
937
|
-
return .
|
|
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
|
|
948
|
-
// back, didn't understand it" rather than "couldn't
|
|
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.
|
|
957
|
-
//
|
|
958
|
-
//
|
|
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(
|
|
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):
|
|
967
|
-
// case .authFailure:
|
|
968
|
-
// case .
|
|
969
|
-
// case .
|
|
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: {
|