@ixo/oracle-runtime-workers 0.2.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 (215) hide show
  1. package/README.md +110 -0
  2. package/package.json +77 -0
  3. package/src/attachments/classify.ts +114 -0
  4. package/src/attachments/content-blocks.ts +51 -0
  5. package/src/attachments/download.ts +235 -0
  6. package/src/attachments/extract.ts +241 -0
  7. package/src/attachments/index.ts +84 -0
  8. package/src/attachments/magic.ts +133 -0
  9. package/src/attachments/pipeline.ts +409 -0
  10. package/src/attachments/retention.ts +205 -0
  11. package/src/attachments/route.ts +41 -0
  12. package/src/attachments/sandbox-archive.ts +90 -0
  13. package/src/attachments/sanitize.ts +35 -0
  14. package/src/attachments/types.ts +86 -0
  15. package/src/attachments/view.ts +107 -0
  16. package/src/core/env.ts +200 -0
  17. package/src/core/index.ts +513 -0
  18. package/src/core/llm.ts +842 -0
  19. package/src/core/main-agent-types.ts +123 -0
  20. package/src/core/main-agent.ts +399 -0
  21. package/src/core/manifest.ts +365 -0
  22. package/src/core/meta-tools.ts +232 -0
  23. package/src/core/middlewares/byo-history-sanitizer.ts +165 -0
  24. package/src/core/middlewares/capability-gate.ts +76 -0
  25. package/src/core/middlewares/index.ts +18 -0
  26. package/src/core/middlewares/page-context.ts +98 -0
  27. package/src/core/middlewares/safety-guardrail.ts +117 -0
  28. package/src/core/middlewares/summarization.ts +99 -0
  29. package/src/core/middlewares/tool-repetition-guard.ts +125 -0
  30. package/src/core/middlewares/tool-validation.ts +83 -0
  31. package/src/core/middlewares/work-status.ts +57 -0
  32. package/src/core/openrouter-pricing.ts +124 -0
  33. package/src/core/plugin-loader.ts +192 -0
  34. package/src/core/plugins/index.ts +6 -0
  35. package/src/core/plugins/skills/index.ts +8 -0
  36. package/src/core/plugins/skills/skills-tools.ts +356 -0
  37. package/src/core/plugins/skills/skills.plugin.ts +113 -0
  38. package/src/core/plugins/weather/index.ts +8 -0
  39. package/src/core/plugins/weather/weather-client.ts +153 -0
  40. package/src/core/plugins/weather/weather-middleware.ts +27 -0
  41. package/src/core/plugins/weather/weather-sub-agent.ts +65 -0
  42. package/src/core/plugins/weather/weather-tools.ts +103 -0
  43. package/src/core/plugins/weather/weather.plugin.ts +227 -0
  44. package/src/core/prompt-composer.ts +469 -0
  45. package/src/core/registries.ts +642 -0
  46. package/src/core/runtime-context.ts +619 -0
  47. package/src/core/state.ts +108 -0
  48. package/src/core/sub-agent-fallback.ts +187 -0
  49. package/src/core/subagent-as-tool.ts +322 -0
  50. package/src/core/template.ts +245 -0
  51. package/src/core/utils.ts +246 -0
  52. package/src/core/wrap-plugin-tool.ts +100 -0
  53. package/src/do/ambient.ts +266 -0
  54. package/src/do/contracts.ts +562 -0
  55. package/src/do/debug-timers.ts +120 -0
  56. package/src/do/fresh-stub.ts +32 -0
  57. package/src/do/gateway-retry.ts +73 -0
  58. package/src/do/secrets-adapter.ts +32 -0
  59. package/src/do/size-alerts.ts +76 -0
  60. package/src/do/sse-stream.ts +472 -0
  61. package/src/do/transcript.ts +192 -0
  62. package/src/do/turn-body.ts +84 -0
  63. package/src/do/turn-metadata.ts +146 -0
  64. package/src/do/ucan-service.ts +339 -0
  65. package/src/do/user-oracle-do.ts +3035 -0
  66. package/src/gateway-worker.ts +82 -0
  67. package/src/index.ts +237 -0
  68. package/src/llm/byo-adapter.ts +59 -0
  69. package/src/llm/byo-catalog.ts +520 -0
  70. package/src/llm/byo-client.ts +280 -0
  71. package/src/llm/byo-routes.ts +255 -0
  72. package/src/llm/byo-service.ts +836 -0
  73. package/src/llm/chatgpt-oauth.ts +430 -0
  74. package/src/llm/provider-error.ts +283 -0
  75. package/src/matrix/gateway-do.ts +918 -0
  76. package/src/matrix/ingest.ts +209 -0
  77. package/src/matrix/replay-format.ts +34 -0
  78. package/src/matrix/reply-chain.ts +109 -0
  79. package/src/matrix/room-state-codec.ts +115 -0
  80. package/src/matrix/user-homeserver.ts +106 -0
  81. package/src/matrix/work-status.ts +294 -0
  82. package/src/memory/session-history-indexer.ts +328 -0
  83. package/src/owner-store/ixo-vfs-store.ts +570 -0
  84. package/src/owner-store/matrix-media-store.ts +136 -0
  85. package/src/owner-store/migrating-store.ts +185 -0
  86. package/src/owner-store/owner-copy-errors.ts +175 -0
  87. package/src/owner-store/retry.ts +45 -0
  88. package/src/owner-store/tus-upload.ts +333 -0
  89. package/src/owner-store/types.ts +0 -0
  90. package/src/plugin-api/define-plugin.ts +45 -0
  91. package/src/plugin-api/index.ts +56 -0
  92. package/src/plugin-api/oracle-plugin.ts +145 -0
  93. package/src/plugin-api/tool-helper.ts +82 -0
  94. package/src/plugin-api/types.ts +816 -0
  95. package/src/plugin-api/ucan-errors.ts +16 -0
  96. package/src/plugins/agui/agui-agent.ts +115 -0
  97. package/src/plugins/agui/agui.plugin.ts +148 -0
  98. package/src/plugins/agui/index.ts +2 -0
  99. package/src/plugins/attachments/attachments.plugin.ts +54 -0
  100. package/src/plugins/attachments/index.ts +6 -0
  101. package/src/plugins/attachments/view-attachment-tool.ts +91 -0
  102. package/src/plugins/base64.ts +26 -0
  103. package/src/plugins/composio/composio-tools.ts +365 -0
  104. package/src/plugins/composio/composio-ucan.ts +43 -0
  105. package/src/plugins/composio/composio.plugin.ts +214 -0
  106. package/src/plugins/composio/index.ts +15 -0
  107. package/src/plugins/domain-indexer/domain-indexer-agent.ts +57 -0
  108. package/src/plugins/domain-indexer/domain-indexer-tools.ts +253 -0
  109. package/src/plugins/domain-indexer/domain-indexer.plugin.ts +100 -0
  110. package/src/plugins/domain-indexer/index.ts +3 -0
  111. package/src/plugins/editor/blocknote-bridge.ts +120 -0
  112. package/src/plugins/editor/content-session.ts +331 -0
  113. package/src/plugins/editor/content-tools.ts +686 -0
  114. package/src/plugins/editor/document-model.ts +0 -0
  115. package/src/plugins/editor/editor-access-denied-tool.ts +58 -0
  116. package/src/plugins/editor/editor-agent.ts +34 -0
  117. package/src/plugins/editor/editor-config.ts +64 -0
  118. package/src/plugins/editor/editor-mx.ts +128 -0
  119. package/src/plugins/editor/editor.plugin.ts +143 -0
  120. package/src/plugins/editor/failures.ts +154 -0
  121. package/src/plugins/editor/index.ts +31 -0
  122. package/src/plugins/editor/jsdom-shim.ts +55 -0
  123. package/src/plugins/editor/prompts.ts +59 -0
  124. package/src/plugins/editor/prop-policy.ts +147 -0
  125. package/src/plugins/editor/provider.ts +386 -0
  126. package/src/plugins/editor/room-membership.ts +95 -0
  127. package/src/plugins/editor/standalone-editor-tool.ts +200 -0
  128. package/src/plugins/firecrawl/firecrawl-agent.ts +93 -0
  129. package/src/plugins/firecrawl/firecrawl-tools.ts +220 -0
  130. package/src/plugins/firecrawl/firecrawl.plugin.ts +105 -0
  131. package/src/plugins/firecrawl/index.ts +15 -0
  132. package/src/plugins/flows/action-metadata.ts +3505 -0
  133. package/src/plugins/flows/actions.ts +190 -0
  134. package/src/plugins/flows/edit.ts +314 -0
  135. package/src/plugins/flows/errors.ts +38 -0
  136. package/src/plugins/flows/explain.ts +43 -0
  137. package/src/plugins/flows/flow-doc.ts +111 -0
  138. package/src/plugins/flows/flows.plugin.ts +129 -0
  139. package/src/plugins/flows/forms.ts +330 -0
  140. package/src/plugins/flows/index.ts +2 -0
  141. package/src/plugins/flows/linkage.ts +147 -0
  142. package/src/plugins/flows/port-types.ts +53 -0
  143. package/src/plugins/flows/prompts.ts +65 -0
  144. package/src/plugins/flows/read.ts +392 -0
  145. package/src/plugins/flows/references.ts +66 -0
  146. package/src/plugins/flows/test-support.ts +165 -0
  147. package/src/plugins/flows/tools/authoring.ts +516 -0
  148. package/src/plugins/flows/tools/discovery.ts +119 -0
  149. package/src/plugins/flows/tools/forms.ts +142 -0
  150. package/src/plugins/flows/tools/inspect.ts +165 -0
  151. package/src/plugins/flows/tools/linkage.ts +117 -0
  152. package/src/plugins/flows/tools/settings.ts +184 -0
  153. package/src/plugins/flows/translator.ts +328 -0
  154. package/src/plugins/flows/types.ts +210 -0
  155. package/src/plugins/flows/ydoc-helpers.ts +543 -0
  156. package/src/plugins/index.ts +70 -0
  157. package/src/plugins/mcp-call-timeout.ts +38 -0
  158. package/src/plugins/mcp-tool-adapter.ts +87 -0
  159. package/src/plugins/memory/index.ts +17 -0
  160. package/src/plugins/memory/memory-tools.ts +463 -0
  161. package/src/plugins/memory/memory-ucan.ts +51 -0
  162. package/src/plugins/memory/memory.plugin.ts +122 -0
  163. package/src/plugins/memory/types.ts +147 -0
  164. package/src/plugins/portal/action-log.ts +34 -0
  165. package/src/plugins/portal/index.ts +2 -0
  166. package/src/plugins/portal/portal.plugin.ts +203 -0
  167. package/src/plugins/sandbox/index.ts +34 -0
  168. package/src/plugins/sandbox/sandbox-bridge.ts +295 -0
  169. package/src/plugins/sandbox/sandbox-mcp.ts +127 -0
  170. package/src/plugins/sandbox/sandbox-write-blob.ts +103 -0
  171. package/src/plugins/sandbox/sandbox.plugin.ts +599 -0
  172. package/src/plugins/tasks/index.ts +17 -0
  173. package/src/plugins/tasks/manifest.ts +81 -0
  174. package/src/plugins/tasks/middleware.ts +219 -0
  175. package/src/plugins/tasks/tasks-tools.ts +414 -0
  176. package/src/plugins/tasks/tasks.plugin.ts +74 -0
  177. package/src/plugins/ucan-failure.ts +133 -0
  178. package/src/plugins/user-preferences/index.ts +22 -0
  179. package/src/plugins/user-preferences/schema.ts +31 -0
  180. package/src/plugins/user-preferences/user-preferences-store.ts +172 -0
  181. package/src/plugins/user-preferences/user-preferences-tool.ts +131 -0
  182. package/src/plugins/user-preferences/user-preferences.plugin.ts +137 -0
  183. package/src/plugins/vfs/index.ts +40 -0
  184. package/src/plugins/vfs/vfs-auth.ts +56 -0
  185. package/src/plugins/vfs/vfs-client.ts +655 -0
  186. package/src/plugins/vfs/vfs-content.ts +181 -0
  187. package/src/plugins/vfs/vfs-errors.ts +163 -0
  188. package/src/plugins/vfs/vfs-sandbox-tools.ts +272 -0
  189. package/src/plugins/vfs/vfs-tools.ts +635 -0
  190. package/src/plugins/vfs/vfs.plugin.ts +201 -0
  191. package/src/realtime/frontend-call-registry.ts +163 -0
  192. package/src/realtime/realtime-endpoint.ts +619 -0
  193. package/src/realtime/session-socket-hub.ts +249 -0
  194. package/src/realtime/socket-io-codec.ts +187 -0
  195. package/src/secrets/jwe.ts +74 -0
  196. package/src/secrets/pin-cipher.ts +43 -0
  197. package/src/secrets/secrets-service.ts +319 -0
  198. package/src/shell/app.ts +564 -0
  199. package/src/shell/auth.ts +290 -0
  200. package/src/sqlite/blob-codec.ts +100 -0
  201. package/src/sqlite/blob-compactor.ts +146 -0
  202. package/src/sqlite/cache-config.ts +45 -0
  203. package/src/sqlite/database.ts +643 -0
  204. package/src/sqlite/do-vfs.ts +1485 -0
  205. package/src/sqlite/index.ts +61 -0
  206. package/src/sqlite/serialization.ts +370 -0
  207. package/src/sqlite/sessions-store.ts +311 -0
  208. package/src/sqlite/sqlite-saver.ts +919 -0
  209. package/src/sqlite/vacuum-policy.ts +77 -0
  210. package/src/sqlite/wa-sqlite-loader.ts +123 -0
  211. package/src/sqlite/wasm.d.ts +10 -0
  212. package/src/tasks/schedule.ts +174 -0
  213. package/src/tasks/scheduler.ts +753 -0
  214. package/src/tasks/spec.ts +125 -0
  215. package/src/tasks/store.ts +351 -0
@@ -0,0 +1,836 @@
1
+ /**
2
+ * Bring-your-own-credential LLM service — the Workers port of the Node
3
+ * runtime's `modules/byo-llm/byo-llm.service.ts`.
4
+ *
5
+ * Credentials live as room secrets in the canonical user↔oracle room
6
+ * (`BYO_SECRET_NAMES`), read and written through `WorkersSecretsService`
7
+ * (JWE to the oracle's own P-256 key) — wire-identical to the Node runtime
8
+ * and the portal, so connected credentials survive the runtime swap.
9
+ *
10
+ * The service is constructed per `UserOracleDO`, which is single-threaded
11
+ * per user — that replaces the Node service's in-process synchronization
12
+ * outright while keeping the same safety properties:
13
+ *
14
+ * - per-user single-flight so concurrent turns share one token refresh;
15
+ * - a credential epoch bumped on every write/delete, so a read that
16
+ * overlapped a write refuses to cache its (possibly stale) result and a
17
+ * refresh that lost a race with a disconnect refuses to write back;
18
+ * - refreshed-but-unpersisted ChatGPT tokens are held (refresh tokens
19
+ * ROTATE on use — the in-memory result is the only valid copy) and, on
20
+ * Workers, additionally persisted through the host `ByoStateStore`
21
+ * (Durable Object storage) so an eviction cannot strand them.
22
+ */
23
+
24
+ import type { Logger } from '../plugin-api/types';
25
+ import {
26
+ BYO_DEFAULT_MODEL,
27
+ BYO_PROVIDER_INFO,
28
+ BYO_PROVIDERS,
29
+ BYO_SECRET_NAMES,
30
+ buildByoModelListing,
31
+ isByoModelId,
32
+ parseByoModelId,
33
+ parseChatGptOAuthTokens,
34
+ providerForSecretName,
35
+ toByoModelId,
36
+ type ByoCredential,
37
+ type ByoProvider,
38
+ type ChatGptOAuthTokens,
39
+ } from './byo-catalog';
40
+ import {
41
+ DEEPSEEK_BASE_URL,
42
+ GEMINI_OPENAI_COMPAT_BASE_URL,
43
+ type ChatGptBackendConfig,
44
+ chatGptBackendHeaders,
45
+ DEFAULT_CHATGPT_BACKEND,
46
+ } from './byo-client';
47
+ import type { ModelListItem } from '../core/llm';
48
+ import {
49
+ buildByoFallbackNotice,
50
+ type ByoFallbackNoticePayload,
51
+ } from './provider-error';
52
+ import {
53
+ ChatGptOAuthError,
54
+ DEFAULT_CHATGPT_CLIENT_ID,
55
+ refreshChatGptTokens,
56
+ TOKEN_REFRESH_SKEW_MS,
57
+ } from './chatgpt-oauth';
58
+
59
+ /**
60
+ * Parsed credentials are cached briefly so the per-turn resolution costs one
61
+ * cache read instead of a Matrix room-state fetch. Short on purpose: a key
62
+ * added from the portal becomes usable within this window without any
63
+ * cross-service invalidation (the connect UI can also force `refresh`).
64
+ */
65
+ const CREDS_CACHE_TTL_MS = 60_000;
66
+
67
+ /** Device-auth bindings live as long as the device code's 15-minute window. */
68
+ const DEVICE_BIND_TTL_MS = 15 * 60 * 1000;
69
+
70
+ /**
71
+ * Cooldowns after a failed ChatGPT token refresh so a dead credential doesn't
72
+ * retry the token endpoint on every message. Permanent failures (rotated /
73
+ * expired refresh token) back off longer than transient ones; a successful
74
+ * refresh or reconnect clears the cooldown.
75
+ */
76
+ const REFRESH_COOLDOWN_TRANSIENT_MS = 2 * 60 * 1000;
77
+ const REFRESH_COOLDOWN_RECONNECT_MS = 30 * 60 * 1000;
78
+
79
+ export type ByoCredentialMap = Partial<Record<ByoProvider, ByoCredential>>;
80
+
81
+ /** Everything a BYO turn needs; built once per request by the host. */
82
+ export interface ByoTurnState {
83
+ provider: ByoProvider;
84
+ credential: ByoCredential;
85
+ /** Provider-native id serving the `main` role. */
86
+ mainModelId: string;
87
+ /** Namespaced id the turn is recorded as (goes on `requestCtx.model`). */
88
+ byoModelId: string;
89
+ /** ChatGPT lane only: backend/proxy the turn's requests go to. */
90
+ chatGptBackend?: ChatGptBackendConfig;
91
+ }
92
+
93
+ export interface ByoProviderStatus {
94
+ provider: ByoProvider;
95
+ label: string;
96
+ authType: 'oauth' | 'api-key';
97
+ badge: string;
98
+ connected: boolean;
99
+ /** Present when connected — picker entries for this provider. */
100
+ models: ModelListItem[];
101
+ /** Namespaced default model id (picker preselect after connect). */
102
+ defaultModelId: string;
103
+ }
104
+
105
+ /** The slice of `WorkersSecretsService` the BYO service consumes. */
106
+ export interface ByoSecretsBackend {
107
+ getIndex(roomId: string): Promise<Array<{ name: string; eventId: string }>>;
108
+ getValues(roomId: string, names: string[]): Promise<Record<string, string>>;
109
+ putSecret(roomId: string, name: string, value: string): Promise<void>;
110
+ deleteSecret(roomId: string, name: string): Promise<void>;
111
+ }
112
+
113
+ /**
114
+ * Small durable KV the host supplies (Durable Object storage in production,
115
+ * a Map in tests) for the state that must survive object eviction: the
116
+ * device-auth bindings and unpersisted rotated ChatGPT tokens.
117
+ */
118
+ export interface ByoStateStore {
119
+ get(key: string): Promise<string | undefined>;
120
+ put(key: string, value: string, ttlMs?: number): Promise<void>;
121
+ delete(key: string): Promise<void>;
122
+ }
123
+
124
+ export interface WorkersByoServiceOptions {
125
+ enabled: boolean;
126
+ /** Override of the public ChatGPT OAuth client id. */
127
+ chatGptClientId?: string;
128
+ secrets: ByoSecretsBackend;
129
+ /** Canonical user↔oracle room lookup (gateway alias resolution). */
130
+ resolveRoomId: (userDid: string) => Promise<string | null>;
131
+ store: ByoStateStore;
132
+ logger?: Logger;
133
+ /** Fetch used for the ChatGPT-backend reachability probe (tests). */
134
+ probeFetch?: typeof fetch;
135
+ /** Where ChatGPT-subscription requests go (default: the real backend). */
136
+ chatGptBackend?: ChatGptBackendConfig;
137
+ }
138
+
139
+ /** How long a ChatGPT-backend reachability verdict is trusted. */
140
+ const CHATGPT_PROBE_TTL_MS = 10 * 60 * 1000;
141
+
142
+ const NOOP: Logger = {
143
+ log: () => undefined,
144
+ warn: () => undefined,
145
+ error: () => undefined,
146
+ };
147
+
148
+ interface CachedCreds {
149
+ creds: ByoCredentialMap;
150
+ expiresAt: number;
151
+ }
152
+
153
+ export class WorkersByoService {
154
+ private readonly enabled: boolean;
155
+ private readonly secrets: ByoSecretsBackend;
156
+ private readonly resolveRoomIdFn: (userDid: string) => Promise<string | null>;
157
+ private readonly store: ByoStateStore;
158
+ private readonly logger: Logger;
159
+ readonly chatGptClientId: string;
160
+
161
+ private readonly credsCache = new Map<string, CachedCreds>();
162
+ /** Per-user single-flight so concurrent turns share one token refresh. */
163
+ private readonly refreshInFlight = new Map<
164
+ string,
165
+ Promise<ChatGptOAuthTokens | null>
166
+ >();
167
+ /** Per-user credential epoch — bumped on every credential write/delete. */
168
+ private readonly credsEpoch = new Map<string, number>();
169
+ /** In-memory mirror of the store-backed pending (unpersisted) tokens. */
170
+ private readonly pendingChatGptTokens = new Map<string, ChatGptOAuthTokens>();
171
+ /** Dedup for background retries of a pending token write-back. */
172
+ private readonly pendingPersistInFlight = new Set<string>();
173
+ /** Per-user refresh-failure cooldown (epoch-ms deadline). */
174
+ private readonly refreshCooldownUntil = new Map<string, number>();
175
+ private readonly probeFetch: typeof fetch;
176
+ readonly chatGptBackend: ChatGptBackendConfig;
177
+ /** Last ChatGPT-backend reachability verdict (deployment-wide, not per user). */
178
+ private chatGptProbe: { blocked: boolean; at: number } | null = null;
179
+
180
+ constructor(opts: WorkersByoServiceOptions) {
181
+ this.enabled = opts.enabled;
182
+ this.secrets = opts.secrets;
183
+ this.resolveRoomIdFn = opts.resolveRoomId;
184
+ this.store = opts.store;
185
+ this.logger = opts.logger ?? NOOP;
186
+ this.chatGptClientId = opts.chatGptClientId ?? DEFAULT_CHATGPT_CLIENT_ID;
187
+ // Never store the bare global: workerd rejects `fetch` invoked with a
188
+ // foreign `this` ("Illegal invocation"), and this field is called as a
189
+ // method. Resolve the global at call time so the probe really runs.
190
+ this.probeFetch =
191
+ opts.probeFetch ?? ((input, init) => globalThis.fetch(input, init));
192
+ this.chatGptBackend = opts.chatGptBackend ?? DEFAULT_CHATGPT_BACKEND;
193
+ }
194
+
195
+ /**
196
+ * Is the ChatGPT subscription backend reachable from THIS deployment?
197
+ *
198
+ * `chatgpt.com/backend-api/codex` sits behind a WAF that answers some
199
+ * egress networks (Cloudflare Workers among them) with a 403 HTML block
200
+ * page before any authentication happens. Discovering that mid-turn means
201
+ * a failed turn with no reply, so probe once (a cheap GET the backend
202
+ * answers with a JSON 404/405 when reachable) and trust the verdict for
203
+ * `CHATGPT_PROBE_TTL_MS`. Only an HTML 403 counts as blocked — a network
204
+ * error or any JSON status is "reachable", so a probe hiccup never hides
205
+ * the user's own provider errors.
206
+ */
207
+ private async chatGptBackendBlocked(accessToken: string): Promise<boolean> {
208
+ if (
209
+ this.chatGptProbe &&
210
+ Date.now() - this.chatGptProbe.at < CHATGPT_PROBE_TTL_MS
211
+ ) {
212
+ return this.chatGptProbe.blocked;
213
+ }
214
+ let blocked = false;
215
+ try {
216
+ const res = await this.probeFetch(
217
+ `${this.chatGptBackend.baseUrl}/responses`,
218
+ {
219
+ method: 'GET',
220
+ headers: {
221
+ ...chatGptBackendHeaders(this.chatGptBackend),
222
+ Authorization: `Bearer ${accessToken}`,
223
+ originator: 'codex_cli_rs',
224
+ },
225
+ },
226
+ );
227
+ const type = res.headers.get('content-type') ?? '';
228
+ blocked = res.status === 403 && /text\/html/i.test(type);
229
+ if (blocked) {
230
+ this.logger.warn(
231
+ `[byo] ChatGPT backend is WAF-blocked from this deployment (HTTP 403 HTML) — ChatGPT turns fall back to the platform model for ${Math.round(CHATGPT_PROBE_TTL_MS / 60_000)} min`,
232
+ );
233
+ }
234
+ } catch (err) {
235
+ this.logger.warn(
236
+ `[byo] ChatGPT backend probe failed (treated as reachable): ${err instanceof Error ? err.message : String(err)}`,
237
+ );
238
+ }
239
+ this.chatGptProbe = { blocked, at: Date.now() };
240
+ return blocked;
241
+ }
242
+
243
+ isEnabled(): boolean {
244
+ return this.enabled;
245
+ }
246
+
247
+ private epochOf(userDid: string): number {
248
+ return this.credsEpoch.get(userDid) ?? 0;
249
+ }
250
+
251
+ private bumpEpoch(userDid: string): void {
252
+ this.credsEpoch.set(userDid, this.epochOf(userDid) + 1);
253
+ }
254
+
255
+ private pendingKey(userDid: string): string {
256
+ return `pending_chatgpt:${userDid}`;
257
+ }
258
+
259
+ private deviceBindKey(deviceAuthId: string): string {
260
+ return `device:${deviceAuthId}`;
261
+ }
262
+
263
+ private cooldownActive(userDid: string): boolean {
264
+ const until = this.refreshCooldownUntil.get(userDid);
265
+ if (until === undefined) return false;
266
+ if (until <= Date.now()) {
267
+ this.refreshCooldownUntil.delete(userDid);
268
+ return false;
269
+ }
270
+ return true;
271
+ }
272
+
273
+ private clearCooldown(userDid: string): void {
274
+ this.refreshCooldownUntil.delete(userDid);
275
+ }
276
+
277
+ /**
278
+ * Bind a started device-auth flow to the account that started it, so a
279
+ * poll from any other account (or for a flow this oracle never issued)
280
+ * cannot complete the connect and capture the resulting tokens.
281
+ */
282
+ async bindDeviceAuth(userDid: string, deviceAuthId: string): Promise<void> {
283
+ await this.store.put(
284
+ this.deviceBindKey(deviceAuthId),
285
+ userDid,
286
+ DEVICE_BIND_TTL_MS,
287
+ );
288
+ }
289
+
290
+ async isDeviceAuthOwner(
291
+ userDid: string,
292
+ deviceAuthId: string,
293
+ ): Promise<boolean> {
294
+ const owner = await this.store.get(this.deviceBindKey(deviceAuthId));
295
+ return owner === userDid;
296
+ }
297
+
298
+ private async pendingTokensOf(
299
+ userDid: string,
300
+ ): Promise<ChatGptOAuthTokens | null> {
301
+ const inMemory = this.pendingChatGptTokens.get(userDid);
302
+ if (inMemory) return inMemory;
303
+ const stored = await this.store.get(this.pendingKey(userDid));
304
+ if (!stored) return null;
305
+ const parsed = parseChatGptOAuthTokens(stored);
306
+ if (parsed) this.pendingChatGptTokens.set(userDid, parsed);
307
+ return parsed;
308
+ }
309
+
310
+ private async setPendingTokens(
311
+ userDid: string,
312
+ tokens: ChatGptOAuthTokens,
313
+ ): Promise<void> {
314
+ this.pendingChatGptTokens.set(userDid, tokens);
315
+ await this.store.put(this.pendingKey(userDid), JSON.stringify(tokens));
316
+ }
317
+
318
+ private async clearPendingTokens(userDid: string): Promise<void> {
319
+ this.pendingChatGptTokens.delete(userDid);
320
+ await this.store.delete(this.pendingKey(userDid));
321
+ }
322
+
323
+ /**
324
+ * Read + parse the user's BYO credentials from the canonical room's
325
+ * secrets. Cached for {@link CREDS_CACHE_TTL_MS}; `refresh` bypasses the
326
+ * cache (used by the connect UI right after saving a key).
327
+ */
328
+ async getCredentials(
329
+ userDid: string,
330
+ opts?: { refresh?: boolean },
331
+ ): Promise<ByoCredentialMap> {
332
+ if (!this.enabled) return {};
333
+
334
+ if (!opts?.refresh) {
335
+ const cached = this.credsCache.get(userDid);
336
+ if (cached && cached.expiresAt > Date.now()) return cached.creds;
337
+ }
338
+
339
+ const epochAtRead = this.epochOf(userDid);
340
+ const roomId = await this.resolveRoomIdFn(userDid);
341
+ if (!roomId) return {};
342
+
343
+ const index = await this.secrets.getIndex(roomId);
344
+ const byoNames = index
345
+ .map((entry) => entry.name)
346
+ .filter((name) => providerForSecretName(name) !== undefined);
347
+ const values =
348
+ byoNames.length > 0 ? await this.secrets.getValues(roomId, byoNames) : {};
349
+
350
+ const creds: ByoCredentialMap = {};
351
+ for (const [name, value] of Object.entries(values)) {
352
+ const provider = providerForSecretName(name);
353
+ if (!provider || !value) continue;
354
+ if (provider === 'chatgpt') {
355
+ const oauth = parseChatGptOAuthTokens(value);
356
+ if (oauth) {
357
+ creds.chatgpt = { provider: 'chatgpt', oauth };
358
+ } else {
359
+ this.logger.warn(
360
+ `[byo] stored ChatGPT OAuth blob for ${userDid} is malformed — treating as disconnected`,
361
+ );
362
+ }
363
+ } else {
364
+ creds[provider] = { provider, apiKey: value.trim() };
365
+ }
366
+ }
367
+
368
+ // Unpersisted (rotated) tokens shadow the room-stored ones — the stored
369
+ // refresh token is already consumed upstream. Once the room state carries
370
+ // an equally-fresh or fresher credential, the shadow copy is dropped.
371
+ const pending = await this.pendingTokensOf(userDid);
372
+ if (pending) {
373
+ const stored = creds.chatgpt;
374
+ const storedOauth =
375
+ stored?.provider === 'chatgpt' ? stored.oauth : undefined;
376
+ if (!storedOauth || storedOauth.expiresAt < pending.expiresAt) {
377
+ creds.chatgpt = { provider: 'chatgpt', oauth: pending };
378
+ } else {
379
+ await this.clearPendingTokens(userDid);
380
+ }
381
+ }
382
+
383
+ // Cache only when no write raced this read — a store/delete that landed
384
+ // mid-read supersedes what was read, and caching it would poison the next
385
+ // minute of turns (worst case: resurrecting a consumed refresh token).
386
+ if (this.epochOf(userDid) === epochAtRead) {
387
+ this.credsCache.set(userDid, {
388
+ creds,
389
+ expiresAt: Date.now() + CREDS_CACHE_TTL_MS,
390
+ });
391
+ }
392
+ return creds;
393
+ }
394
+
395
+ /** Presence check. Not cached beyond `getCredentials`' own 60s window. */
396
+ async hasCredentials(userDid: string): Promise<boolean> {
397
+ if (!this.enabled) return false;
398
+ const creds = await this.getCredentials(userDid);
399
+ return Object.keys(creds).length > 0;
400
+ }
401
+
402
+ /**
403
+ * Per-turn resolution, called by the host before the agent build.
404
+ *
405
+ * - A `byo:` model on the request activates that provider (credential
406
+ * required — otherwise the turn falls back to the platform default).
407
+ * - A platform model on the request keeps the turn platform-paid (the
408
+ * caller skips this method entirely in that case).
409
+ * - No model on the request (Matrix ingress) prefers the connected
410
+ * subscription, then the first connected API key.
411
+ *
412
+ * Returns `null` for "platform turn" — the caller changes nothing.
413
+ * Degradations the user should know about are reported via `onNotice`
414
+ * (the host forwards them onto the turn's SSE stream).
415
+ */
416
+ async resolveForTurn(params: {
417
+ userDid: string;
418
+ requestedModel?: string;
419
+ onNotice?: (notice: ByoFallbackNoticePayload) => void;
420
+ }): Promise<ByoTurnState | null> {
421
+ if (!this.enabled) return null;
422
+ const { userDid, requestedModel, onNotice } = params;
423
+
424
+ let requested: { provider: ByoProvider; modelId: string } | null = null;
425
+ if (requestedModel !== undefined) {
426
+ if (!isByoModelId(requestedModel)) return null;
427
+ requested = parseByoModelId(requestedModel);
428
+ if (!requested) {
429
+ this.logger.warn(
430
+ `[byo] ignoring unknown BYO model "${requestedModel}" — falling back to the platform default.`,
431
+ );
432
+ return null;
433
+ }
434
+ }
435
+
436
+ const creds = await this.getCredentials(userDid);
437
+
438
+ if (requested) {
439
+ const credential = creds[requested.provider];
440
+ if (!credential) {
441
+ this.logger.warn(
442
+ `[byo] model "${requestedModel}" requested but no ${requested.provider} credential is connected — falling back to the platform default.`,
443
+ );
444
+ // The picker still shows their BYO model — without a signal the user
445
+ // has no way to know this reply ran on the platform model instead.
446
+ onNotice?.(buildByoFallbackNotice('not_connected', requested.provider));
447
+ return null;
448
+ }
449
+ return this.finalizeTurn(
450
+ userDid,
451
+ credential,
452
+ requested.modelId,
453
+ onNotice,
454
+ );
455
+ }
456
+
457
+ for (const provider of BYO_PROVIDERS) {
458
+ const credential = creds[provider];
459
+ if (credential) {
460
+ return this.finalizeTurn(
461
+ userDid,
462
+ credential,
463
+ BYO_DEFAULT_MODEL[provider],
464
+ onNotice,
465
+ );
466
+ }
467
+ }
468
+ return null;
469
+ }
470
+
471
+ private async finalizeTurn(
472
+ userDid: string,
473
+ credential: ByoCredential,
474
+ mainModelId: string,
475
+ onNotice?: (notice: ByoFallbackNoticePayload) => void,
476
+ ): Promise<ByoTurnState | null> {
477
+ let effective = credential;
478
+ if (credential.provider === 'chatgpt') {
479
+ const fresh = await this.ensureFreshChatGptTokens(
480
+ userDid,
481
+ credential.oauth,
482
+ );
483
+ if (!fresh) {
484
+ this.logger.warn(
485
+ `[byo] ChatGPT token refresh failed for ${userDid} — turn falls back to the platform default (user must reconnect).`,
486
+ );
487
+ onNotice?.(buildByoFallbackNotice('reconnect_required', 'chatgpt'));
488
+ return null;
489
+ }
490
+ if (await this.chatGptBackendBlocked(fresh.accessToken)) {
491
+ onNotice?.(buildByoFallbackNotice('unreachable', 'chatgpt'));
492
+ return null;
493
+ }
494
+ effective = { provider: 'chatgpt', oauth: fresh };
495
+ }
496
+ return {
497
+ provider: effective.provider,
498
+ credential: effective,
499
+ mainModelId,
500
+ byoModelId: toByoModelId(effective.provider, mainModelId),
501
+ ...(effective.provider === 'chatgpt'
502
+ ? { chatGptBackend: this.chatGptBackend }
503
+ : {}),
504
+ };
505
+ }
506
+
507
+ /**
508
+ * Return tokens valid for at least {@link TOKEN_REFRESH_SKEW_MS} more ms,
509
+ * refreshing (single-flight per user) and writing back when needed.
510
+ */
511
+ private async ensureFreshChatGptTokens(
512
+ userDid: string,
513
+ oauth: ChatGptOAuthTokens,
514
+ ): Promise<ChatGptOAuthTokens | null> {
515
+ if (oauth.expiresAt - TOKEN_REFRESH_SKEW_MS > Date.now()) {
516
+ // Fresh enough — but if these tokens are an unpersisted shadow copy,
517
+ // retry the write-back in the background so an eviction can't strand
518
+ // the only valid copy of the rotated refresh token.
519
+ if (this.pendingChatGptTokens.get(userDid)) {
520
+ void this.retryPendingPersist(userDid);
521
+ }
522
+ return oauth;
523
+ }
524
+
525
+ // A recent refresh failure short-circuits the retry so a dead credential
526
+ // costs one map read per turn, not a token-endpoint round-trip.
527
+ if (this.cooldownActive(userDid)) return null;
528
+
529
+ const inFlight = this.refreshInFlight.get(userDid);
530
+ if (inFlight) return inFlight;
531
+
532
+ const refresh = (async (): Promise<ChatGptOAuthTokens | null> => {
533
+ const epochAtStart = this.epochOf(userDid);
534
+ let tokens: ChatGptOAuthTokens;
535
+ try {
536
+ tokens = await refreshChatGptTokens({
537
+ clientId: this.chatGptClientId,
538
+ previous: oauth,
539
+ });
540
+ } catch (error) {
541
+ const reconnect =
542
+ error instanceof ChatGptOAuthError &&
543
+ error.code === 'reconnect_required';
544
+ this.logger.error(
545
+ `[byo] ChatGPT token refresh failed for ${userDid}${reconnect ? ' (refresh token expired/rotated — reconnect required)' : ''}: ${error instanceof Error ? error.message : String(error)}`,
546
+ );
547
+ this.refreshCooldownUntil.set(
548
+ userDid,
549
+ Date.now() +
550
+ (reconnect
551
+ ? REFRESH_COOLDOWN_RECONNECT_MS
552
+ : REFRESH_COOLDOWN_TRANSIENT_MS),
553
+ );
554
+ this.refreshInFlight.delete(userDid);
555
+ return null;
556
+ }
557
+
558
+ try {
559
+ if (this.epochOf(userDid) !== epochAtStart) {
560
+ // A disconnect (or another write) superseded this refresh while it
561
+ // was in flight — do not resurrect the credential.
562
+ this.logger.log(
563
+ `[byo] discarding refreshed ChatGPT tokens for ${userDid} — credential changed mid-refresh`,
564
+ );
565
+ return null;
566
+ }
567
+ await this.storeChatGptTokens(userDid, tokens);
568
+ return tokens;
569
+ } catch (error) {
570
+ // The refresh SUCCEEDED — only persisting it failed. The old refresh
571
+ // token is already consumed upstream, so these tokens are the only
572
+ // valid copy: hold them (no cooldown — nothing is wrong with the
573
+ // credential) and let reads/retries pick them up.
574
+ this.logger.error(
575
+ `[byo] ChatGPT tokens refreshed but write-back failed for ${userDid} — holding and retrying: ${error instanceof Error ? error.message : String(error)}`,
576
+ );
577
+ await this.holdUnpersistedChatGptTokens(userDid, tokens);
578
+ return tokens;
579
+ } finally {
580
+ this.refreshInFlight.delete(userDid);
581
+ }
582
+ })();
583
+ this.refreshInFlight.set(userDid, refresh);
584
+ return refresh;
585
+ }
586
+
587
+ /**
588
+ * Keep freshly-obtained tokens usable when persisting them failed: shadow
589
+ * them (memory + durable store), drop the (stale) cached credential map so
590
+ * the next read substitutes them, and clear any cooldown — the credential
591
+ * itself is fine.
592
+ */
593
+ async holdUnpersistedChatGptTokens(
594
+ userDid: string,
595
+ tokens: ChatGptOAuthTokens,
596
+ ): Promise<void> {
597
+ this.bumpEpoch(userDid);
598
+ await this.setPendingTokens(userDid, tokens);
599
+ this.credsCache.delete(userDid);
600
+ this.clearCooldown(userDid);
601
+ }
602
+
603
+ /** Background retry of a pending token write-back, deduped per user. */
604
+ private async retryPendingPersist(userDid: string): Promise<void> {
605
+ if (this.pendingPersistInFlight.has(userDid)) return;
606
+ this.pendingPersistInFlight.add(userDid);
607
+ try {
608
+ const pending = await this.pendingTokensOf(userDid);
609
+ if (!pending) return;
610
+ await this.storeChatGptTokens(userDid, pending);
611
+ this.logger.log(
612
+ `[byo] persisted previously-held ChatGPT tokens for ${userDid}`,
613
+ );
614
+ } catch (error) {
615
+ this.logger.warn(
616
+ `[byo] retry of ChatGPT token write-back failed for ${userDid}: ${error instanceof Error ? error.message : String(error)}`,
617
+ );
618
+ } finally {
619
+ this.pendingPersistInFlight.delete(userDid);
620
+ }
621
+ }
622
+
623
+ /**
624
+ * Persist ChatGPT OAuth tokens into the canonical room (JWE to the oracle's
625
+ * own key) and patch the credential cache in place.
626
+ */
627
+ async storeChatGptTokens(
628
+ userDid: string,
629
+ tokens: ChatGptOAuthTokens,
630
+ ): Promise<void> {
631
+ const roomId = await this.resolveRoomIdFn(userDid);
632
+ if (!roomId) {
633
+ throw new Error(
634
+ `[byo] cannot store ChatGPT tokens — no oracle room for ${userDid}`,
635
+ );
636
+ }
637
+ // Bump on both sides of the Matrix write so a credential read overlapping
638
+ // ANY part of it (started before, or started mid-write against the old
639
+ // room state) fails the epoch check and refuses to cache a stale result.
640
+ this.bumpEpoch(userDid);
641
+ await this.secrets.putSecret(
642
+ roomId,
643
+ BYO_SECRET_NAMES.chatgpt,
644
+ JSON.stringify(tokens),
645
+ );
646
+ this.bumpEpoch(userDid);
647
+ // Durably persisted — any shadow copy from an earlier failed write-back
648
+ // is superseded (unless it is strictly fresher than what was just written).
649
+ const pending = await this.pendingTokensOf(userDid);
650
+ if (pending && pending.expiresAt <= tokens.expiresAt) {
651
+ await this.clearPendingTokens(userDid);
652
+ }
653
+
654
+ const cached = this.credsCache.get(userDid);
655
+ const next: ByoCredentialMap = {
656
+ ...(cached && cached.expiresAt > Date.now() ? cached.creds : {}),
657
+ chatgpt: { provider: 'chatgpt', oauth: tokens },
658
+ };
659
+ this.credsCache.set(userDid, {
660
+ creds: next,
661
+ expiresAt: Date.now() + CREDS_CACHE_TTL_MS,
662
+ });
663
+ this.clearCooldown(userDid);
664
+ }
665
+
666
+ /**
667
+ * Store a provider API key server-side, same as the OAuth path: encrypted
668
+ * by the oracle to its own key and written into the canonical room. A
669
+ * client-side write through the agent-secrets flow depends on cross-device
670
+ * Matrix key sharing — when the room-key to-device message goes missing the
671
+ * oracle stores ciphertext it can never read. Server-side writes are always
672
+ * readable by construction, and the oracle decrypts the key on every BYO
673
+ * turn anyway, so the exposure is identical.
674
+ */
675
+ async storeApiKey(
676
+ userDid: string,
677
+ provider: Exclude<ByoProvider, 'chatgpt'>,
678
+ apiKey: string,
679
+ ): Promise<void> {
680
+ const roomId = await this.resolveRoomIdFn(userDid);
681
+ if (!roomId) {
682
+ throw new Error(
683
+ `[byo] cannot store API key — no oracle room for ${userDid}`,
684
+ );
685
+ }
686
+ // Same both-sides epoch bump as the token store: an overlapping
687
+ // credential read must refuse to cache around this write.
688
+ this.bumpEpoch(userDid);
689
+ await this.secrets.putSecret(roomId, BYO_SECRET_NAMES[provider], apiKey);
690
+ this.bumpEpoch(userDid);
691
+
692
+ const cached = this.credsCache.get(userDid);
693
+ const next: ByoCredentialMap = {
694
+ ...(cached && cached.expiresAt > Date.now() ? cached.creds : {}),
695
+ [provider]: { provider, apiKey },
696
+ };
697
+ this.credsCache.set(userDid, {
698
+ creds: next,
699
+ expiresAt: Date.now() + CREDS_CACHE_TTL_MS,
700
+ });
701
+ }
702
+
703
+ /** Remove a stored credential (index cleared, value redacted, caches dropped). */
704
+ async deleteCredential(
705
+ userDid: string,
706
+ provider: ByoProvider,
707
+ ): Promise<void> {
708
+ // Let any in-flight token refresh land first, then bump the epoch on
709
+ // both sides of the Matrix delete — together these guarantee a refresh
710
+ // can neither write after the delete nor start a late write-back, and a
711
+ // credential read overlapping any part of the delete refuses to cache
712
+ // the not-yet-deleted state.
713
+ const inFlight = this.refreshInFlight.get(userDid);
714
+ if (inFlight) {
715
+ await inFlight;
716
+ }
717
+ this.bumpEpoch(userDid);
718
+
719
+ const roomId = await this.resolveRoomIdFn(userDid);
720
+ if (!roomId) {
721
+ throw new Error(
722
+ `[byo] cannot delete credential — no oracle room for ${userDid}`,
723
+ );
724
+ }
725
+ await this.secrets.deleteSecret(roomId, BYO_SECRET_NAMES[provider]);
726
+ this.bumpEpoch(userDid);
727
+ if (provider === 'chatgpt') {
728
+ await this.clearPendingTokens(userDid);
729
+ }
730
+ this.credsCache.delete(userDid);
731
+ this.clearCooldown(userDid);
732
+ }
733
+
734
+ /** Per-provider connection status + picker entries for the connect UI. */
735
+ async status(
736
+ userDid: string,
737
+ opts?: { refresh?: boolean },
738
+ ): Promise<{ enabled: boolean; providers: ByoProviderStatus[] }> {
739
+ if (!this.enabled) {
740
+ return { enabled: false, providers: [] };
741
+ }
742
+ const creds = await this.getCredentials(userDid, opts);
743
+ const providers = BYO_PROVIDERS.map((provider): ByoProviderStatus => {
744
+ const info = BYO_PROVIDER_INFO[provider];
745
+ const connected = creds[provider] !== undefined;
746
+ return {
747
+ provider,
748
+ label: info.label,
749
+ authType: info.authType,
750
+ badge: info.badge,
751
+ connected,
752
+ models: connected ? buildByoModelListing([provider]) : [],
753
+ defaultModelId: toByoModelId(provider, BYO_DEFAULT_MODEL[provider]),
754
+ };
755
+ });
756
+ return { enabled: true, providers };
757
+ }
758
+
759
+ /**
760
+ * Live-check a stored credential: API keys make a cheap `GET /models`
761
+ * against their provider; the subscription checks token freshness
762
+ * (refreshing when stale). Never throws — the result is UI feedback.
763
+ */
764
+ async validate(
765
+ userDid: string,
766
+ provider: ByoProvider,
767
+ ): Promise<{ valid: boolean; error?: string }> {
768
+ const creds = await this.getCredentials(userDid, { refresh: true });
769
+ const credential = creds[provider];
770
+ if (!credential) {
771
+ return { valid: false, error: 'Not connected' };
772
+ }
773
+
774
+ if (credential.provider === 'chatgpt') {
775
+ // A user-initiated check is an explicit retry — lift any cooldown so
776
+ // the refresh really runs instead of reporting the cached failure.
777
+ this.clearCooldown(userDid);
778
+ const fresh = await this.ensureFreshChatGptTokens(
779
+ userDid,
780
+ credential.oauth,
781
+ );
782
+ return fresh
783
+ ? { valid: true }
784
+ : { valid: false, error: 'Token refresh failed — please reconnect' };
785
+ }
786
+
787
+ try {
788
+ const { url, headers } = validationRequestFor(
789
+ credential.provider,
790
+ credential.apiKey,
791
+ );
792
+ const res = await fetch(url, { headers });
793
+ if (res.ok) return { valid: true };
794
+ return {
795
+ valid: false,
796
+ error:
797
+ res.status === 401 || res.status === 403
798
+ ? 'The provider rejected this key'
799
+ : `Provider check failed (HTTP ${res.status})`,
800
+ };
801
+ } catch (error) {
802
+ return {
803
+ valid: false,
804
+ error: `Could not reach the provider: ${error instanceof Error ? error.message : String(error)}`,
805
+ };
806
+ }
807
+ }
808
+ }
809
+
810
+ function validationRequestFor(
811
+ provider: Exclude<ByoProvider, 'chatgpt'>,
812
+ apiKey: string,
813
+ ): { url: string; headers: Record<string, string> } {
814
+ switch (provider) {
815
+ case 'openai':
816
+ return {
817
+ url: 'https://api.openai.com/v1/models',
818
+ headers: { Authorization: `Bearer ${apiKey}` },
819
+ };
820
+ case 'deepseek':
821
+ return {
822
+ url: `${DEEPSEEK_BASE_URL}/models`,
823
+ headers: { Authorization: `Bearer ${apiKey}` },
824
+ };
825
+ case 'gemini':
826
+ return {
827
+ url: `${GEMINI_OPENAI_COMPAT_BASE_URL}models`,
828
+ headers: { Authorization: `Bearer ${apiKey}` },
829
+ };
830
+ case 'anthropic':
831
+ return {
832
+ url: 'https://api.anthropic.com/v1/models',
833
+ headers: { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
834
+ };
835
+ }
836
+ }