@huynle/hub 0.0.0-stage → 0.6.0

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.
Files changed (76) hide show
  1. package/CHANGELOG.md +269 -0
  2. package/Dockerfile +51 -0
  3. package/LICENSE +21 -0
  4. package/README.md +1029 -2
  5. package/bin/hub.ts +20 -0
  6. package/docker/hub-deps.ts +42 -0
  7. package/package.json +85 -4
  8. package/rpc.ts +2 -0
  9. package/scripts/hub-local.sh +28 -0
  10. package/scripts/io.github.huynle.hub.plist +42 -0
  11. package/server.ts +3 -0
  12. package/src/central/assets.ts +74 -0
  13. package/src/central/auth.ts +271 -0
  14. package/src/central/config.ts +310 -0
  15. package/src/central/http.ts +1247 -0
  16. package/src/central/main.ts +136 -0
  17. package/src/central/oidc.ts +295 -0
  18. package/src/central/push-target.ts +97 -0
  19. package/src/central/push.ts +285 -0
  20. package/src/central/registry.ts +1153 -0
  21. package/src/central/speech/models.ts +89 -0
  22. package/src/central/speech/openai.ts +66 -0
  23. package/src/central/speech/openrouter.ts +71 -0
  24. package/src/central/speech/provider.ts +74 -0
  25. package/src/central/speech/service.ts +168 -0
  26. package/src/central/speech/settings.ts +375 -0
  27. package/src/central/spoke-ws.ts +300 -0
  28. package/src/central/store.ts +653 -0
  29. package/src/config.ts +170 -0
  30. package/src/env.ts +63 -0
  31. package/src/index.ts +236 -0
  32. package/src/key.ts +17 -0
  33. package/src/loopback.ts +41 -0
  34. package/src/migrate.ts +105 -0
  35. package/src/openrouter.ts +3 -0
  36. package/src/options.ts +33 -0
  37. package/src/protocol.ts +650 -0
  38. package/src/reply.ts +27 -0
  39. package/src/rpc.ts +98 -0
  40. package/src/setup.ts +124 -0
  41. package/src/speakable.ts +35 -0
  42. package/src/spoke/client.ts +272 -0
  43. package/src/spoke/conversation.ts +93 -0
  44. package/src/spoke/enroll.ts +79 -0
  45. package/src/spoke/herdr.ts +346 -0
  46. package/src/spoke/listing.ts +108 -0
  47. package/src/spoke/localhub.ts +287 -0
  48. package/src/spoke/locations.ts +34 -0
  49. package/src/spoke/own-server.ts +32 -0
  50. package/src/spoke/presence.ts +45 -0
  51. package/src/spoke/singleton.ts +103 -0
  52. package/src/spoke/spoke.ts +1212 -0
  53. package/src/spoke/summary.ts +117 -0
  54. package/src/spoke/timeline.ts +93 -0
  55. package/src/spoke/tracker.ts +345 -0
  56. package/src/spoke/watch.ts +78 -0
  57. package/src/status.ts +232 -0
  58. package/src/tui-commands.ts +53 -0
  59. package/src/tui-forms.ts +77 -0
  60. package/src/tui-presence.ts +75 -0
  61. package/src/tui.ts +479 -0
  62. package/src/turns.ts +94 -0
  63. package/src/voice-command.ts +65 -0
  64. package/tui.ts +2 -0
  65. package/web/dist/apple-touch-icon.png +0 -0
  66. package/web/dist/assets/index-DClBvfLL.css +1 -0
  67. package/web/dist/assets/index-Djo_yF0a.js +12 -0
  68. package/web/dist/favicon.ico +0 -0
  69. package/web/dist/icon-192.png +0 -0
  70. package/web/dist/icon-512.png +0 -0
  71. package/web/dist/icon-maskable-512.png +0 -0
  72. package/web/dist/icon.svg +18 -0
  73. package/web/dist/index.html +25 -0
  74. package/web/dist/manifest.webmanifest +18 -0
  75. package/web/dist/sw.js +151 -0
  76. package/web/dist/vad-worklet.js +27 -0
@@ -0,0 +1,89 @@
1
+ // Speech model lists of OpenAI-compatible providers. Their GET /models usually lists every model of the
2
+ // gateway (chat, image, embedding, music…) without a speech filter, so the models are classified here: speech-
3
+ // to-text by name ("transcribe", "whisper", "stt", "asr") or as audio-in/text-out audio models; text-to-speech by
4
+ // name ("tts", "text-to-speech", "speech", "voice") or by speech/audio output, never music or audio chat models.
5
+ // The configured model is always offered first (gateways may serve models they do not list), and when nothing is
6
+ // labeled for a kind the full list is shown with a note.
7
+
8
+ export type SpeechModelKind = "stt" | "tts"
9
+
10
+ export interface SpeechModelInfo {
11
+ readonly id: string
12
+ readonly name: string
13
+ /** The model configured for this kind right now. */
14
+ readonly current?: true
15
+ }
16
+
17
+ export interface SpeechModelList {
18
+ readonly models: SpeechModelInfo[]
19
+ /** Set when the list could not be narrowed to speech models. */
20
+ readonly note?: string
21
+ }
22
+
23
+ export const SPEECH_UNLABELED_NOTE = "This provider doesn't label speech models; showing all"
24
+
25
+ const STT_NAME = /transcri|whisper|(^|[^a-z])stt|speech-to-text|(^|[^a-z])asr/i
26
+ const TTS_NAME = /(^|[^a-z])tts|text-to-speech|speech(?!-to-text)|voice/i
27
+ const EXPLICIT_TTS = /(^|[^a-z])tts|text-to-speech/i
28
+ const MUSIC = /lyria|music|gpt-audio\b/i
29
+ const NOT_SPEECH_TYPES = new Set(["chat", "embedding", "image", "rerank"])
30
+
31
+ interface RawModel {
32
+ readonly id: string
33
+ readonly name: string
34
+ readonly type?: string
35
+ readonly output: readonly string[] | undefined
36
+ }
37
+
38
+ const strings = (value: unknown): string[] | undefined => (Array.isArray(value) ? value.filter((x): x is string => typeof x === "string").map((x) => x.toLowerCase()) : undefined)
39
+
40
+ /** `{data: [{id, name?, type?, output_modalities?}]}` entries with an id. */
41
+ function rawModels(body: unknown): RawModel[] {
42
+ const data = (body as { data?: unknown } | null | undefined)?.data
43
+ if (!Array.isArray(data)) return []
44
+ const out: RawModel[] = []
45
+ const seen = new Set<string>()
46
+ for (const entry of data) {
47
+ const e = entry as Record<string, unknown> | null
48
+ const id = typeof e?.id === "string" ? e.id : ""
49
+ if (!id || seen.has(id)) continue
50
+ seen.add(id)
51
+ const name = typeof e!.name === "string" && e!.name ? e!.name : id
52
+ out.push({ id, name, ...(typeof e!.type === "string" ? { type: e!.type.toLowerCase() } : {}), output: strings(e!.output_modalities) })
53
+ }
54
+ return out
55
+ }
56
+
57
+ function isStt(model: RawModel): boolean {
58
+ if (model.type && NOT_SPEECH_TYPES.has(model.type)) return false
59
+ const label = `${model.id} ${model.name}`
60
+ if (STT_NAME.test(label) || model.output?.includes("transcription")) return true
61
+ // Unlabeled audio models that only answer with text (nova, parakeet, chirp…).
62
+ return model.type === "audio" && !TTS_NAME.test(label) && !MUSIC.test(label) && (!model.output || model.output.every((x) => x === "text"))
63
+ }
64
+
65
+ function isTts(model: RawModel): boolean {
66
+ if (model.type && NOT_SPEECH_TYPES.has(model.type)) return false
67
+ const label = `${model.id} ${model.name}`
68
+ if (STT_NAME.test(label)) return false
69
+ if (MUSIC.test(label) && !EXPLICIT_TTS.test(label)) return false
70
+ if (TTS_NAME.test(label) || model.output?.includes("speech")) return true
71
+ return model.type === "audio" && !!model.output?.includes("audio")
72
+ }
73
+
74
+ /** The models of `kind` in a provider's /models answer, with the configured model `current` first. */
75
+ export function speechModelList(kind: SpeechModelKind, body: unknown, current: string): SpeechModelList {
76
+ const all = rawModels(body)
77
+ const matching = all.filter(kind === "stt" ? isStt : isTts)
78
+ const unlabeled = all.length > 0 && matching.length === 0
79
+ return withCurrent((unlabeled ? all : matching).map(({ id, name }) => ({ id, name })), current, unlabeled ? SPEECH_UNLABELED_NOTE : undefined)
80
+ }
81
+
82
+ /** Puts the configured model first, marked current (added when the list does not have it). */
83
+ export function withCurrent(models: readonly SpeechModelInfo[], current: string, note?: string): SpeechModelList {
84
+ const id = current.trim()
85
+ const rest = models.filter((m) => m.id !== id).map(({ id, name }) => ({ id, name }))
86
+ const found = models.find((m) => m.id === id)
87
+ const list = id ? [{ id, name: found?.name ?? id, current: true as const }, ...rest] : rest
88
+ return { models: list, ...(note ? { note } : {}) }
89
+ }
@@ -0,0 +1,66 @@
1
+ // OpenAI-compatible speech endpoints (OpenAI, Groq, local whisper/kokoro servers, ...):
2
+ // STT as multipart POST {baseURL}/audio/transcriptions (file, model, language?), TTS as
3
+ // POST {baseURL}/audio/speech {model, input, voice, response_format: "mp3", speed?}.
4
+ import { audioMime, SpeechError, upstreamDetail, withSpeedRetry, type SpeechProvider } from "./provider"
5
+
6
+ export interface OpenAICompatibleOptions {
7
+ /** Base URL including the version path, e.g. https://api.openai.com/v1 (no trailing slash). */
8
+ readonly baseURL: string
9
+ /** Optional: local servers often need no key. */
10
+ readonly apiKey: () => Promise<string | undefined>
11
+ readonly sttModel: string
12
+ readonly ttsModel: string
13
+ readonly voice: string
14
+ readonly language?: string
15
+ readonly fetch?: typeof globalThis.fetch
16
+ }
17
+
18
+ export class OpenAICompatible implements SpeechProvider {
19
+ private readonly fetch: typeof globalThis.fetch
20
+
21
+ constructor(private readonly options: OpenAICompatibleOptions) {
22
+ this.fetch = options.fetch ?? globalThis.fetch
23
+ }
24
+
25
+ async transcribe(audio: Uint8Array, format: string, signal?: AbortSignal): Promise<string> {
26
+ const form = new FormData()
27
+ form.append("file", new File([new Uint8Array(audio)], `audio.${format}`, { type: audioMime(format) }))
28
+ form.append("model", this.options.sttModel)
29
+ if (this.options.language) form.append("language", this.options.language)
30
+ const response = await this.fetch(`${this.options.baseURL}/audio/transcriptions`, {
31
+ method: "POST",
32
+ headers: await this.headers(),
33
+ body: form,
34
+ signal,
35
+ })
36
+ if (!response.ok) throw await failure("transcription", response)
37
+ const body = (await response.json()) as { text?: unknown }
38
+ return String(body.text ?? "").trim()
39
+ }
40
+
41
+ async speak(text: string, speed?: number, signal?: AbortSignal): Promise<Uint8Array<ArrayBuffer>> {
42
+ const { ttsModel: model, voice } = this.options
43
+ const headers = { ...(await this.headers()), "content-type": "application/json" }
44
+ const response = await withSpeedRetry(
45
+ (s) =>
46
+ this.fetch(`${this.options.baseURL}/audio/speech`, {
47
+ method: "POST",
48
+ headers,
49
+ body: JSON.stringify({ model, input: text, voice, response_format: "mp3", ...(s !== undefined ? { speed: s } : {}) }),
50
+ signal,
51
+ }),
52
+ speed,
53
+ )
54
+ if (!response.ok) throw await failure("speech", response)
55
+ return new Uint8Array(await response.arrayBuffer())
56
+ }
57
+
58
+ private async headers(): Promise<Record<string, string>> {
59
+ const key = await this.options.apiKey()
60
+ return key ? { authorization: `Bearer ${key}` } : {}
61
+ }
62
+ }
63
+
64
+ async function failure(kind: string, response: Response): Promise<SpeechError> {
65
+ return new SpeechError(`Speech provider ${kind} failed (${response.status}): ${await upstreamDetail(response)}`, response.status)
66
+ }
@@ -0,0 +1,71 @@
1
+ // OpenRouter speech-to-text (/audio/transcriptions, JSON with base64 input_audio) and text-to-speech
2
+ // (/audio/speech).
3
+ import { SpeechError, upstreamDetail, withSpeedRetry, type SpeechProvider } from "./provider"
4
+
5
+ export const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
6
+
7
+ export interface OpenRouterOptions {
8
+ readonly apiKey: () => Promise<string | undefined>
9
+ readonly sttModel: string
10
+ readonly ttsModel: string
11
+ readonly voice: string
12
+ readonly language?: string
13
+ readonly baseURL?: string
14
+ readonly fetch?: typeof globalThis.fetch
15
+ }
16
+
17
+ export class OpenRouterError extends SpeechError {
18
+ constructor(message: string, status?: number) {
19
+ super(message, status)
20
+ this.name = "OpenRouterError"
21
+ }
22
+ }
23
+
24
+ export class OpenRouter implements SpeechProvider {
25
+ private readonly baseURL: string
26
+ private readonly fetch: typeof globalThis.fetch
27
+
28
+ constructor(private readonly options: OpenRouterOptions) {
29
+ this.baseURL = options.baseURL ?? OPENROUTER_BASE_URL
30
+ this.fetch = options.fetch ?? globalThis.fetch
31
+ }
32
+
33
+ async transcribe(audio: Uint8Array, format: string, signal?: AbortSignal): Promise<string> {
34
+ const { sttModel: model, language } = this.options
35
+ const input_audio = { data: Buffer.from(audio).toString("base64"), format }
36
+ const response = await this.post("/audio/transcriptions", { model, input_audio, ...(language ? { language } : {}) }, signal)
37
+ if (!response.ok) throw await failure("transcription", response)
38
+ const body = (await response.json()) as { text?: unknown }
39
+ return String(body.text ?? "").trim()
40
+ }
41
+
42
+ async speak(text: string, speed?: number, signal?: AbortSignal): Promise<Uint8Array<ArrayBuffer>> {
43
+ const { ttsModel: model, voice } = this.options
44
+ const response = await withSpeedRetry(
45
+ (s) =>
46
+ this.post("/audio/speech", { model, input: text, voice, response_format: "mp3", ...(s !== undefined ? { speed: s } : {}) }, signal),
47
+ speed,
48
+ )
49
+ if (!response.ok) throw await failure("speech", response)
50
+ return new Uint8Array(await response.arrayBuffer())
51
+ }
52
+
53
+ private async post(path: string, body: unknown, signal?: AbortSignal): Promise<Response> {
54
+ const key = await this.options.apiKey()
55
+ if (!key) {
56
+ throw new OpenRouterError(
57
+ "No OpenRouter API key: set one in the speech settings, set OPENROUTER_API_KEY, or connect OpenRouter in OpenCode",
58
+ )
59
+ }
60
+ return this.fetch(`${this.baseURL}${path}`, {
61
+ method: "POST",
62
+ headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
63
+ body: JSON.stringify(body),
64
+ signal,
65
+ })
66
+ }
67
+ }
68
+
69
+ async function failure(kind: string, response: Response): Promise<OpenRouterError> {
70
+ return new OpenRouterError(`OpenRouter ${kind} failed (${response.status}): ${await upstreamDetail(response)}`, response.status)
71
+ }
@@ -0,0 +1,74 @@
1
+ // Speech provider interface shared by OpenRouter and OpenAI-compatible endpoints, the typed upstream
2
+ // error (mapped to 502 by the hub), audio format detection, and the speed-retry rule.
3
+
4
+ /** Speech-to-text and text-to-speech against one configured provider. */
5
+ export interface SpeechProvider {
6
+ transcribe(audio: Uint8Array, format: string, signal?: AbortSignal): Promise<string>
7
+ /** MP3 audio. `speed` is optional; providers that reject it get one retry without it. */
8
+ speak(text: string, speed?: number, signal?: AbortSignal): Promise<Uint8Array<ArrayBuffer>>
9
+ }
10
+
11
+ /** The speech provider refused or failed a request (or is not configured). Never carries the API key. */
12
+ export class SpeechError extends Error {
13
+ constructor(
14
+ message: string,
15
+ readonly status?: number,
16
+ ) {
17
+ super(message)
18
+ this.name = "SpeechError"
19
+ }
20
+ }
21
+
22
+ const FORMATS: Record<string, string> = {
23
+ "audio/webm": "webm",
24
+ "audio/mp4": "m4a",
25
+ "audio/x-m4a": "m4a",
26
+ "audio/m4a": "m4a",
27
+ "audio/aac": "aac",
28
+ "audio/ogg": "ogg",
29
+ "audio/wav": "wav",
30
+ "audio/x-wav": "wav",
31
+ "audio/wave": "wav",
32
+ "audio/mpeg": "mp3",
33
+ "audio/mp3": "mp3",
34
+ "audio/flac": "flac",
35
+ }
36
+
37
+ const MIME: Record<string, string> = {
38
+ webm: "audio/webm",
39
+ m4a: "audio/mp4",
40
+ aac: "audio/aac",
41
+ ogg: "audio/ogg",
42
+ wav: "audio/wav",
43
+ mp3: "audio/mpeg",
44
+ flac: "audio/flac",
45
+ }
46
+
47
+ /** Maps a browser recording's Content-Type to an audio format name (webm, m4a, wav, ...). */
48
+ export function audioFormat(contentType: string | null | undefined): string | undefined {
49
+ const mime = contentType?.split(";")[0]?.trim().toLowerCase()
50
+ return mime ? FORMATS[mime] : undefined
51
+ }
52
+
53
+ /** Content type for an audio format name. */
54
+ export const audioMime = (format: string): string => MIME[format] ?? "application/octet-stream"
55
+
56
+ /** Upstream error message: the JSON `error.message` when present, else the body, capped. */
57
+ export async function upstreamDetail(response: Response): Promise<string> {
58
+ const raw = await response.text().catch(() => "")
59
+ try {
60
+ const parsed = JSON.parse(raw) as { error?: { message?: unknown } | string; message?: unknown }
61
+ if (typeof parsed.error === "string") return parsed.error.slice(0, 300)
62
+ if (parsed.error?.message) return String(parsed.error.message).slice(0, 300)
63
+ if (typeof parsed.message === "string") return parsed.message.slice(0, 300)
64
+ } catch {}
65
+ return raw.slice(0, 300)
66
+ }
67
+
68
+ /** Sends with `speed`; when that is refused with 400, sends once more without it. */
69
+ export async function withSpeedRetry(send: (speed?: number) => Promise<Response>, speed?: number): Promise<Response> {
70
+ const response = await send(speed)
71
+ if (response.status !== 400 || speed === undefined) return response
72
+ await response.body?.cancel().catch(() => {})
73
+ return send(undefined)
74
+ }
@@ -0,0 +1,168 @@
1
+ // Speech for the hub: builds the configured provider for each request from the stored settings (so
2
+ // changes apply at once), proxies model lists for the settings page, and caches them for 10 minutes.
3
+ import { OpenAICompatible } from "./openai"
4
+ import { OpenRouter } from "./openrouter"
5
+ import { speechModelList, withCurrent, type SpeechModelInfo, type SpeechModelList } from "./models"
6
+ import { SpeechError, upstreamDetail, type SpeechProvider } from "./provider"
7
+ import {
8
+ lowerLayers,
9
+ resolveStt,
10
+ resolveTts,
11
+ speechView,
12
+ updateSpeech,
13
+ userSpeechView,
14
+ type Resolved,
15
+ type SpeechDefaults,
16
+ type SpeechView,
17
+ type StoredSpeech,
18
+ type UserSpeechView,
19
+ } from "./settings"
20
+
21
+ export const MODELS_CACHE_MS = 10 * 60_000
22
+ const MODELS_TIMEOUT_MS = 10_000
23
+
24
+ export interface SpeechSettingsStore {
25
+ /** The server default. */
26
+ speechSettings(): StoredSpeech
27
+ setSpeechSettings(settings: StoredSpeech): void
28
+ /** A user's own settings (multi-user hubs). */
29
+ userSpeech?(userID: string): StoredSpeech
30
+ setUserSpeech?(userID: string, settings: StoredSpeech): void
31
+ }
32
+
33
+ /** Speech for one scope: a user (their settings over the default) or, without a user, the server default. */
34
+ export interface ScopedSpeech {
35
+ transcribe(audio: Uint8Array, format: string): Promise<string>
36
+ speak(text: string): Promise<Uint8Array<ArrayBuffer>>
37
+ }
38
+
39
+ export interface SpeechServiceOptions {
40
+ readonly store: SpeechSettingsStore
41
+ readonly defaults: SpeechDefaults
42
+ readonly fetch?: typeof globalThis.fetch
43
+ readonly now?: () => number
44
+ }
45
+
46
+ export type SpeechKind = "stt" | "tts"
47
+
48
+ export type ModelInfo = SpeechModelInfo
49
+ export type ModelList = SpeechModelList
50
+
51
+ export class SpeechService {
52
+ private readonly fetch: typeof globalThis.fetch
53
+ private readonly now: () => number
54
+ private readonly cache = new Map<string, { at: number; body: unknown }>()
55
+
56
+ constructor(private readonly options: SpeechServiceOptions) {
57
+ this.fetch = options.fetch ?? globalThis.fetch
58
+ this.now = options.now ?? Date.now
59
+ }
60
+
61
+ /** The server default's view, or a user's view (own settings over the default). */
62
+ view(): SpeechView
63
+ view(userID: string | undefined): SpeechView | UserSpeechView
64
+ view(userID?: string): SpeechView | UserSpeechView {
65
+ const stored = this.options.store.speechSettings()
66
+ if (userID === undefined) return speechView(stored, this.options.defaults)
67
+ return userSpeechView(stored, this.options.defaults, this.userSettings(userID) ?? {})
68
+ }
69
+
70
+ /** Partial update of the server default, or of a user's own settings; throws ValidationError without saving anything. */
71
+ update(input: unknown, userID?: string): SpeechView | UserSpeechView {
72
+ const { store, defaults } = this.options
73
+ if (userID === undefined) store.setSpeechSettings(updateSpeech(store.speechSettings(), input, defaults.file))
74
+ else {
75
+ if (!store.setUserSpeech) throw new Error("per-user speech settings are not supported by this store")
76
+ // Validated against (and keys dropped relative to) the layers below: server default and hub.json.
77
+ store.setUserSpeech(userID, updateSpeech(this.userSettings(userID) ?? {}, input, lowerLayers(store.speechSettings(), defaults)))
78
+ }
79
+ this.cache.clear()
80
+ return this.view(userID)
81
+ }
82
+
83
+ /** Speech with a user's effective settings (undefined: the server default). */
84
+ forUser(userID: string | undefined): ScopedSpeech {
85
+ return {
86
+ transcribe: (audio, format) => this.transcribe(audio, format, undefined, userID),
87
+ speak: (text) => this.speak(text, undefined, userID),
88
+ }
89
+ }
90
+
91
+ transcribe(audio: Uint8Array, format: string, signal?: AbortSignal, userID?: string): Promise<string> {
92
+ return this.provider("stt", userID).transcribe(audio, format, signal)
93
+ }
94
+
95
+ speak(text: string, signal?: AbortSignal, userID?: string): Promise<Uint8Array<ArrayBuffer>> {
96
+ const tts = resolveTts(this.options.store.speechSettings(), this.options.defaults, this.userSettings(userID))
97
+ return this.provider("tts", userID).speak(text, tts.speed, signal)
98
+ }
99
+
100
+ /**
101
+ * Models offered by the configured provider for `kind` (a user's effective provider when given), the
102
+ * configured model first and marked `current`. OpenRouter filters by output modality; other providers list
103
+ * everything, so their models are classified here (the full list with a `note` when nothing is labeled).
104
+ */
105
+ async models(kind: SpeechKind, userID?: string): Promise<ModelList> {
106
+ const settings = this.options.store.speechSettings()
107
+ const user = this.userSettings(userID)
108
+ const resolved: Resolved = kind === "stt" ? resolveStt(settings, this.options.defaults, user) : resolveTts(settings, this.options.defaults, user)
109
+ const openrouter = resolved.provider === "openrouter"
110
+ const url = openrouter
111
+ ? `${resolved.baseURL}/models?output_modalities=${kind === "stt" ? "transcription" : "speech"}`
112
+ : `${resolved.baseURL}/models`
113
+ // Per scope: a list fetched with one user's key is never served to another.
114
+ const cacheKey = `${userID ?? ""} ${url}`
115
+ const cached = this.cache.get(cacheKey)
116
+ let body: unknown
117
+ if (cached && this.now() - cached.at < MODELS_CACHE_MS) body = cached.body
118
+ else {
119
+ try {
120
+ // OpenRouter's list is public: no key is sent. Other providers usually need theirs.
121
+ const headers: Record<string, string> = !openrouter && resolved.apiKey ? { authorization: `Bearer ${resolved.apiKey}` } : {}
122
+ const response = await this.fetch(url, { headers, signal: AbortSignal.timeout(MODELS_TIMEOUT_MS) })
123
+ if (!response.ok) throw new SpeechError(`Model list failed (${response.status}): ${await upstreamDetail(response)}`, response.status)
124
+ body = await response.json()
125
+ } catch (cause) {
126
+ if (!openrouter) return withCurrent([], resolved.model)
127
+ throw cause instanceof SpeechError ? cause : new SpeechError(`Model list failed: ${cause instanceof Error ? cause.message : cause}`)
128
+ }
129
+ this.cache.set(cacheKey, { at: this.now(), body })
130
+ }
131
+ return openrouter ? withCurrent(parseModels(body), resolved.model) : speechModelList(kind, body, resolved.model)
132
+ }
133
+
134
+ private userSettings(userID: string | undefined): StoredSpeech | undefined {
135
+ return userID === undefined ? undefined : (this.options.store.userSpeech?.(userID) ?? {})
136
+ }
137
+
138
+ private provider(kind: SpeechKind, userID?: string): SpeechProvider {
139
+ const settings = this.options.store.speechSettings()
140
+ const user = this.userSettings(userID)
141
+ const stt = resolveStt(settings, this.options.defaults, user)
142
+ const tts = resolveTts(settings, this.options.defaults, user)
143
+ const active = kind === "stt" ? stt : tts
144
+ const options = {
145
+ apiKey: async () => active.apiKey,
146
+ sttModel: stt.model,
147
+ ttsModel: tts.model,
148
+ voice: tts.voice,
149
+ ...(stt.language ? { language: stt.language } : {}),
150
+ baseURL: active.baseURL,
151
+ fetch: this.fetch,
152
+ }
153
+ return active.provider === "openrouter" ? new OpenRouter(options) : new OpenAICompatible(options)
154
+ }
155
+ }
156
+
157
+ function parseModels(body: unknown): ModelInfo[] {
158
+ const data = (body as { data?: unknown } | null)?.data
159
+ if (!Array.isArray(data)) return []
160
+ const models: ModelInfo[] = []
161
+ for (const entry of data) {
162
+ const id = (entry as { id?: unknown } | null)?.id
163
+ if (typeof id !== "string" || !id) continue
164
+ const name = (entry as { name?: unknown }).name
165
+ models.push({ id, name: typeof name === "string" && name ? name : id })
166
+ }
167
+ return models
168
+ }