@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.
- package/README.md +110 -0
- package/package.json +77 -0
- package/src/attachments/classify.ts +114 -0
- package/src/attachments/content-blocks.ts +51 -0
- package/src/attachments/download.ts +235 -0
- package/src/attachments/extract.ts +241 -0
- package/src/attachments/index.ts +84 -0
- package/src/attachments/magic.ts +133 -0
- package/src/attachments/pipeline.ts +409 -0
- package/src/attachments/retention.ts +205 -0
- package/src/attachments/route.ts +41 -0
- package/src/attachments/sandbox-archive.ts +90 -0
- package/src/attachments/sanitize.ts +35 -0
- package/src/attachments/types.ts +86 -0
- package/src/attachments/view.ts +107 -0
- package/src/core/env.ts +200 -0
- package/src/core/index.ts +513 -0
- package/src/core/llm.ts +842 -0
- package/src/core/main-agent-types.ts +123 -0
- package/src/core/main-agent.ts +399 -0
- package/src/core/manifest.ts +365 -0
- package/src/core/meta-tools.ts +232 -0
- package/src/core/middlewares/byo-history-sanitizer.ts +165 -0
- package/src/core/middlewares/capability-gate.ts +76 -0
- package/src/core/middlewares/index.ts +18 -0
- package/src/core/middlewares/page-context.ts +98 -0
- package/src/core/middlewares/safety-guardrail.ts +117 -0
- package/src/core/middlewares/summarization.ts +99 -0
- package/src/core/middlewares/tool-repetition-guard.ts +125 -0
- package/src/core/middlewares/tool-validation.ts +83 -0
- package/src/core/middlewares/work-status.ts +57 -0
- package/src/core/openrouter-pricing.ts +124 -0
- package/src/core/plugin-loader.ts +192 -0
- package/src/core/plugins/index.ts +6 -0
- package/src/core/plugins/skills/index.ts +8 -0
- package/src/core/plugins/skills/skills-tools.ts +356 -0
- package/src/core/plugins/skills/skills.plugin.ts +113 -0
- package/src/core/plugins/weather/index.ts +8 -0
- package/src/core/plugins/weather/weather-client.ts +153 -0
- package/src/core/plugins/weather/weather-middleware.ts +27 -0
- package/src/core/plugins/weather/weather-sub-agent.ts +65 -0
- package/src/core/plugins/weather/weather-tools.ts +103 -0
- package/src/core/plugins/weather/weather.plugin.ts +227 -0
- package/src/core/prompt-composer.ts +469 -0
- package/src/core/registries.ts +642 -0
- package/src/core/runtime-context.ts +619 -0
- package/src/core/state.ts +108 -0
- package/src/core/sub-agent-fallback.ts +187 -0
- package/src/core/subagent-as-tool.ts +322 -0
- package/src/core/template.ts +245 -0
- package/src/core/utils.ts +246 -0
- package/src/core/wrap-plugin-tool.ts +100 -0
- package/src/do/ambient.ts +266 -0
- package/src/do/contracts.ts +562 -0
- package/src/do/debug-timers.ts +120 -0
- package/src/do/fresh-stub.ts +32 -0
- package/src/do/gateway-retry.ts +73 -0
- package/src/do/secrets-adapter.ts +32 -0
- package/src/do/size-alerts.ts +76 -0
- package/src/do/sse-stream.ts +472 -0
- package/src/do/transcript.ts +192 -0
- package/src/do/turn-body.ts +84 -0
- package/src/do/turn-metadata.ts +146 -0
- package/src/do/ucan-service.ts +339 -0
- package/src/do/user-oracle-do.ts +3035 -0
- package/src/gateway-worker.ts +82 -0
- package/src/index.ts +237 -0
- package/src/llm/byo-adapter.ts +59 -0
- package/src/llm/byo-catalog.ts +520 -0
- package/src/llm/byo-client.ts +280 -0
- package/src/llm/byo-routes.ts +255 -0
- package/src/llm/byo-service.ts +836 -0
- package/src/llm/chatgpt-oauth.ts +430 -0
- package/src/llm/provider-error.ts +283 -0
- package/src/matrix/gateway-do.ts +918 -0
- package/src/matrix/ingest.ts +209 -0
- package/src/matrix/replay-format.ts +34 -0
- package/src/matrix/reply-chain.ts +109 -0
- package/src/matrix/room-state-codec.ts +115 -0
- package/src/matrix/user-homeserver.ts +106 -0
- package/src/matrix/work-status.ts +294 -0
- package/src/memory/session-history-indexer.ts +328 -0
- package/src/owner-store/ixo-vfs-store.ts +570 -0
- package/src/owner-store/matrix-media-store.ts +136 -0
- package/src/owner-store/migrating-store.ts +185 -0
- package/src/owner-store/owner-copy-errors.ts +175 -0
- package/src/owner-store/retry.ts +45 -0
- package/src/owner-store/tus-upload.ts +333 -0
- package/src/owner-store/types.ts +0 -0
- package/src/plugin-api/define-plugin.ts +45 -0
- package/src/plugin-api/index.ts +56 -0
- package/src/plugin-api/oracle-plugin.ts +145 -0
- package/src/plugin-api/tool-helper.ts +82 -0
- package/src/plugin-api/types.ts +816 -0
- package/src/plugin-api/ucan-errors.ts +16 -0
- package/src/plugins/agui/agui-agent.ts +115 -0
- package/src/plugins/agui/agui.plugin.ts +148 -0
- package/src/plugins/agui/index.ts +2 -0
- package/src/plugins/attachments/attachments.plugin.ts +54 -0
- package/src/plugins/attachments/index.ts +6 -0
- package/src/plugins/attachments/view-attachment-tool.ts +91 -0
- package/src/plugins/base64.ts +26 -0
- package/src/plugins/composio/composio-tools.ts +365 -0
- package/src/plugins/composio/composio-ucan.ts +43 -0
- package/src/plugins/composio/composio.plugin.ts +214 -0
- package/src/plugins/composio/index.ts +15 -0
- package/src/plugins/domain-indexer/domain-indexer-agent.ts +57 -0
- package/src/plugins/domain-indexer/domain-indexer-tools.ts +253 -0
- package/src/plugins/domain-indexer/domain-indexer.plugin.ts +100 -0
- package/src/plugins/domain-indexer/index.ts +3 -0
- package/src/plugins/editor/blocknote-bridge.ts +120 -0
- package/src/plugins/editor/content-session.ts +331 -0
- package/src/plugins/editor/content-tools.ts +686 -0
- package/src/plugins/editor/document-model.ts +0 -0
- package/src/plugins/editor/editor-access-denied-tool.ts +58 -0
- package/src/plugins/editor/editor-agent.ts +34 -0
- package/src/plugins/editor/editor-config.ts +64 -0
- package/src/plugins/editor/editor-mx.ts +128 -0
- package/src/plugins/editor/editor.plugin.ts +143 -0
- package/src/plugins/editor/failures.ts +154 -0
- package/src/plugins/editor/index.ts +31 -0
- package/src/plugins/editor/jsdom-shim.ts +55 -0
- package/src/plugins/editor/prompts.ts +59 -0
- package/src/plugins/editor/prop-policy.ts +147 -0
- package/src/plugins/editor/provider.ts +386 -0
- package/src/plugins/editor/room-membership.ts +95 -0
- package/src/plugins/editor/standalone-editor-tool.ts +200 -0
- package/src/plugins/firecrawl/firecrawl-agent.ts +93 -0
- package/src/plugins/firecrawl/firecrawl-tools.ts +220 -0
- package/src/plugins/firecrawl/firecrawl.plugin.ts +105 -0
- package/src/plugins/firecrawl/index.ts +15 -0
- package/src/plugins/flows/action-metadata.ts +3505 -0
- package/src/plugins/flows/actions.ts +190 -0
- package/src/plugins/flows/edit.ts +314 -0
- package/src/plugins/flows/errors.ts +38 -0
- package/src/plugins/flows/explain.ts +43 -0
- package/src/plugins/flows/flow-doc.ts +111 -0
- package/src/plugins/flows/flows.plugin.ts +129 -0
- package/src/plugins/flows/forms.ts +330 -0
- package/src/plugins/flows/index.ts +2 -0
- package/src/plugins/flows/linkage.ts +147 -0
- package/src/plugins/flows/port-types.ts +53 -0
- package/src/plugins/flows/prompts.ts +65 -0
- package/src/plugins/flows/read.ts +392 -0
- package/src/plugins/flows/references.ts +66 -0
- package/src/plugins/flows/test-support.ts +165 -0
- package/src/plugins/flows/tools/authoring.ts +516 -0
- package/src/plugins/flows/tools/discovery.ts +119 -0
- package/src/plugins/flows/tools/forms.ts +142 -0
- package/src/plugins/flows/tools/inspect.ts +165 -0
- package/src/plugins/flows/tools/linkage.ts +117 -0
- package/src/plugins/flows/tools/settings.ts +184 -0
- package/src/plugins/flows/translator.ts +328 -0
- package/src/plugins/flows/types.ts +210 -0
- package/src/plugins/flows/ydoc-helpers.ts +543 -0
- package/src/plugins/index.ts +70 -0
- package/src/plugins/mcp-call-timeout.ts +38 -0
- package/src/plugins/mcp-tool-adapter.ts +87 -0
- package/src/plugins/memory/index.ts +17 -0
- package/src/plugins/memory/memory-tools.ts +463 -0
- package/src/plugins/memory/memory-ucan.ts +51 -0
- package/src/plugins/memory/memory.plugin.ts +122 -0
- package/src/plugins/memory/types.ts +147 -0
- package/src/plugins/portal/action-log.ts +34 -0
- package/src/plugins/portal/index.ts +2 -0
- package/src/plugins/portal/portal.plugin.ts +203 -0
- package/src/plugins/sandbox/index.ts +34 -0
- package/src/plugins/sandbox/sandbox-bridge.ts +295 -0
- package/src/plugins/sandbox/sandbox-mcp.ts +127 -0
- package/src/plugins/sandbox/sandbox-write-blob.ts +103 -0
- package/src/plugins/sandbox/sandbox.plugin.ts +599 -0
- package/src/plugins/tasks/index.ts +17 -0
- package/src/plugins/tasks/manifest.ts +81 -0
- package/src/plugins/tasks/middleware.ts +219 -0
- package/src/plugins/tasks/tasks-tools.ts +414 -0
- package/src/plugins/tasks/tasks.plugin.ts +74 -0
- package/src/plugins/ucan-failure.ts +133 -0
- package/src/plugins/user-preferences/index.ts +22 -0
- package/src/plugins/user-preferences/schema.ts +31 -0
- package/src/plugins/user-preferences/user-preferences-store.ts +172 -0
- package/src/plugins/user-preferences/user-preferences-tool.ts +131 -0
- package/src/plugins/user-preferences/user-preferences.plugin.ts +137 -0
- package/src/plugins/vfs/index.ts +40 -0
- package/src/plugins/vfs/vfs-auth.ts +56 -0
- package/src/plugins/vfs/vfs-client.ts +655 -0
- package/src/plugins/vfs/vfs-content.ts +181 -0
- package/src/plugins/vfs/vfs-errors.ts +163 -0
- package/src/plugins/vfs/vfs-sandbox-tools.ts +272 -0
- package/src/plugins/vfs/vfs-tools.ts +635 -0
- package/src/plugins/vfs/vfs.plugin.ts +201 -0
- package/src/realtime/frontend-call-registry.ts +163 -0
- package/src/realtime/realtime-endpoint.ts +619 -0
- package/src/realtime/session-socket-hub.ts +249 -0
- package/src/realtime/socket-io-codec.ts +187 -0
- package/src/secrets/jwe.ts +74 -0
- package/src/secrets/pin-cipher.ts +43 -0
- package/src/secrets/secrets-service.ts +319 -0
- package/src/shell/app.ts +564 -0
- package/src/shell/auth.ts +290 -0
- package/src/sqlite/blob-codec.ts +100 -0
- package/src/sqlite/blob-compactor.ts +146 -0
- package/src/sqlite/cache-config.ts +45 -0
- package/src/sqlite/database.ts +643 -0
- package/src/sqlite/do-vfs.ts +1485 -0
- package/src/sqlite/index.ts +61 -0
- package/src/sqlite/serialization.ts +370 -0
- package/src/sqlite/sessions-store.ts +311 -0
- package/src/sqlite/sqlite-saver.ts +919 -0
- package/src/sqlite/vacuum-policy.ts +77 -0
- package/src/sqlite/wa-sqlite-loader.ts +123 -0
- package/src/sqlite/wasm.d.ts +10 -0
- package/src/tasks/schedule.ts +174 -0
- package/src/tasks/scheduler.ts +753 -0
- package/src/tasks/spec.ts +125 -0
- 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
|
+
}
|