pi-devin-auth 0.1.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.
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Per-account model catalog from Cognition's `GetCascadeModelConfigs`.
3
+ *
4
+ * Why this exists — issue #14:
5
+ * The cloud's `GetChatMessage` returns a single Connect-streaming EOS frame
6
+ * containing `{"error":{"code":"permission_denied","message":"an internal
7
+ * error occurred (trace ID: <hex>)"}}` whenever the caller's account tier
8
+ * does not include the requested `model_uid`. Reproduced byte-identical on a
9
+ * `TEAMS_TIER_DEVIN_FREE` account for every Anthropic/Gemini/Premium UID
10
+ * (only `swe-1-6-slow` streamed a real reply). The user-facing message is
11
+ * indistinguishable from a transient server fault — issue #14's reporter
12
+ * spent multiple sessions guessing.
13
+ *
14
+ * The pre-flight here checks the per-account catalog (`disabled` flag on
15
+ * `ClientModelConfig` field #4) BEFORE we spend a roundtrip on a request
16
+ * the cloud will refuse. When the lookup fails (network, auth, schema
17
+ * drift) we silently fall back to the chat path so a transient catalog
18
+ * outage can't take chat down with it.
19
+ *
20
+ * Schema (verified against the bundled `extension.js`,
21
+ * `exa.codeium_common_pb.ClientModelConfig`):
22
+ *
23
+ * GetCascadeModelConfigsResponse {
24
+ * #1 client_model_configs: repeated ClientModelConfig
25
+ * }
26
+ * ClientModelConfig {
27
+ * #1 label string
28
+ * #4 disabled bool ← the gate this module reads
29
+ * #22 model_uid string ← what `GetChatMessage` accepts
30
+ * }
31
+ *
32
+ * Disabled semantics: TRUE means "this UID exists in the catalog but the
33
+ * caller's account/tier cannot run inference against it." BYOK models
34
+ * surface as `disabled: false` so users with their own provider keys still
35
+ * pass through — the only way they fail at chat time is a missing key,
36
+ * which surfaces with a different message.
37
+ *
38
+ * Cache: per (apiServerUrl, apiKey) for {@link CATALOG_TTL_MS}. Cognition
39
+ * doesn't bump catalog entries mid-session in normal operation, so a 10-min
40
+ * TTL trades one extra roundtrip per ~10 min for clear errors on every chat.
41
+ */
42
+
43
+ import * as crypto from 'crypto';
44
+ import { buildMetadata } from './metadata.js';
45
+ import { getCachedUserJwt } from './auth.js';
46
+ import { encodeMessage, iterFields } from './wire.js';
47
+
48
+ /** 10 minutes — see header. */
49
+ const CATALOG_TTL_MS = 10 * 60 * 1000;
50
+
51
+ /** Catalog endpoint inactivity timeout. Cognition responds in <500ms steady-state. */
52
+ const CATALOG_FETCH_TIMEOUT_MS = 10_000;
53
+
54
+ export interface ModelCatalogEntry {
55
+ /** Cloud-side `model_uid` (e.g. `claude-opus-4-7-medium`). */
56
+ modelUid: string;
57
+ /** Human label (e.g. `Claude Opus 4.7 Medium`) — used in error messages. */
58
+ label: string;
59
+ /** True when the caller's account tier cannot use this UID for chat. */
60
+ disabled: boolean;
61
+ }
62
+
63
+ interface CacheEntry {
64
+ /** Lookup keyed by `model_uid`. */
65
+ byUid: Map<string, ModelCatalogEntry>;
66
+ fetchedAt: number;
67
+ /** Cache key components, captured for invalidation/log purposes. */
68
+ apiKey: string;
69
+ host: string;
70
+ }
71
+
72
+ let cached: CacheEntry | null = null;
73
+ let inFlight: Promise<CacheEntry> | null = null;
74
+ let inFlightKey: string | null = null;
75
+
76
+ function flightKey(apiKey: string, host: string): string {
77
+ return `${host}\x1f${apiKey}`;
78
+ }
79
+
80
+ /**
81
+ * Fetch the cascade model catalog for `(apiKey, host)` and parse the
82
+ * subset of `ClientModelConfig` we care about into a UID-keyed map.
83
+ *
84
+ * Throws on transport/auth failure so the caller can decide whether to fall
85
+ * back to "skip pre-flight". Does NOT throw on an unexpected response body —
86
+ * a malformed catalog returns an empty map, treated the same as "model not
87
+ * listed" by the chat pre-flight.
88
+ */
89
+ async function fetchCatalog(apiKey: string, host: string, signal?: AbortSignal): Promise<CacheEntry> {
90
+ const userJwt = await getCachedUserJwt(apiKey, host, signal);
91
+
92
+ const metadata = buildMetadata({
93
+ apiKey,
94
+ userJwt,
95
+ sessionId: crypto.randomUUID(),
96
+ requestId: BigInt(Date.now()),
97
+ triggerId: crypto.randomUUID(),
98
+ });
99
+ // GetCascadeModelConfigsRequest { metadata: Metadata } — Metadata is #1.
100
+ const reqBody = encodeMessage(1, metadata);
101
+
102
+ // Internal 10s timeout so a stalled catalog endpoint can't deadlock chat.
103
+ // The caller's signal still takes precedence — when they cancel, we cancel.
104
+ const ac = new AbortController();
105
+ const timer = setTimeout(
106
+ () => ac.abort(new Error(`catalog: fetch timeout (${CATALOG_FETCH_TIMEOUT_MS}ms)`)),
107
+ CATALOG_FETCH_TIMEOUT_MS,
108
+ );
109
+ const cleanupOnAbort = signal
110
+ ? (() => {
111
+ if (signal.aborted) ac.abort(signal.reason);
112
+ const fwd = (): void => ac.abort(signal.reason);
113
+ signal.addEventListener('abort', fwd, { once: true });
114
+ return () => signal.removeEventListener('abort', fwd);
115
+ })()
116
+ : (): void => { /* no caller signal */ };
117
+
118
+ let resp: Response;
119
+ try {
120
+ resp = await fetch(`${host}/exa.api_server_pb.ApiServerService/GetCascadeModelConfigs`, {
121
+ method: 'POST',
122
+ headers: { 'Content-Type': 'application/proto', 'Connect-Protocol-Version': '1' },
123
+ body: reqBody,
124
+ signal: ac.signal,
125
+ });
126
+ } finally {
127
+ clearTimeout(timer);
128
+ cleanupOnAbort();
129
+ }
130
+
131
+ if (!resp.ok) {
132
+ const text = await resp.text();
133
+ throw new Error(`GetCascadeModelConfigs HTTP ${resp.status}: ${text.slice(0, 200)}`);
134
+ }
135
+ const buf = Buffer.from(await resp.arrayBuffer());
136
+
137
+ // GetCascadeModelConfigsResponse #1 (repeated ClientModelConfig)
138
+ const byUid = new Map<string, ModelCatalogEntry>();
139
+ for (const f of iterFields(buf)) {
140
+ if (f.num !== 1 || f.wire !== 2 || !Buffer.isBuffer(f.value)) continue;
141
+ let label = '';
142
+ let modelUid = '';
143
+ let disabled = false;
144
+ for (const sf of iterFields(f.value as Buffer)) {
145
+ if (sf.num === 1 && sf.wire === 2 && Buffer.isBuffer(sf.value)) {
146
+ label = (sf.value as Buffer).toString('utf8');
147
+ } else if (sf.num === 4 && sf.wire === 0) {
148
+ // #4 = disabled (bool, varint 0/1)
149
+ disabled = sf.value === 1n;
150
+ } else if (sf.num === 22 && sf.wire === 2 && Buffer.isBuffer(sf.value)) {
151
+ modelUid = (sf.value as Buffer).toString('utf8');
152
+ }
153
+ }
154
+ if (modelUid.length > 0) {
155
+ byUid.set(modelUid, { modelUid, label: label || modelUid, disabled });
156
+ }
157
+ }
158
+
159
+ return { byUid, fetchedAt: Date.now(), apiKey, host };
160
+ }
161
+
162
+ /**
163
+ * Get the cached catalog for `(apiKey, host)`, fetching when missing or stale.
164
+ *
165
+ * Concurrent callers for the SAME (apiKey, host) share one in-flight fetch
166
+ * (no thundering herd on startup). Concurrent callers for DIFFERENT keys
167
+ * serialise the in-flight slot but only one of them holds it at a time —
168
+ * good enough for opencode's single-account-at-a-time usage pattern.
169
+ *
170
+ * Returns `null` on fetch failure (network, transient 5xx, auth issue). The
171
+ * caller treats `null` as "skip pre-flight and let the chat path surface the
172
+ * server-side error itself."
173
+ */
174
+ export async function getCachedCatalog(
175
+ apiKey: string,
176
+ host: string,
177
+ signal?: AbortSignal,
178
+ ): Promise<CacheEntry | null> {
179
+ if (cached && cached.apiKey === apiKey && cached.host === host) {
180
+ if (Date.now() - cached.fetchedAt < CATALOG_TTL_MS) {
181
+ return cached;
182
+ }
183
+ }
184
+
185
+ const key = flightKey(apiKey, host);
186
+ if (inFlight && inFlightKey === key) {
187
+ try {
188
+ return await inFlight;
189
+ } catch {
190
+ return null;
191
+ }
192
+ }
193
+
194
+ const promise = fetchCatalog(apiKey, host, signal);
195
+ inFlight = promise;
196
+ inFlightKey = key;
197
+ try {
198
+ const result = await promise;
199
+ cached = result;
200
+ return result;
201
+ } catch {
202
+ return null;
203
+ } finally {
204
+ if (inFlight === promise) {
205
+ inFlight = null;
206
+ inFlightKey = null;
207
+ }
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Drop the cached catalog. Call after logout/account switch so a fresh
213
+ * sign-in doesn't see a previous account's allow-list.
214
+ */
215
+ export function clearCachedCatalog(): void {
216
+ cached = null;
217
+ inFlight = null;
218
+ inFlightKey = null;
219
+ }
220
+
221
+ /**
222
+ * Tier-disabled error — thrown by the chat pre-flight when the catalog lists
223
+ * a model as `disabled: true` for this account. The message names the model
224
+ * and points at the plan page, replacing Cognition's opaque
225
+ * "an internal error occurred" trailer.
226
+ */
227
+ export class ModelNotAvailableError extends Error {
228
+ constructor(
229
+ public readonly modelUid: string,
230
+ public readonly label: string,
231
+ public readonly reason: 'disabled' | 'not_listed',
232
+ ) {
233
+ super(
234
+ reason === 'disabled'
235
+ ? `Model "${label}" (uid=${modelUid}) is not enabled for your Cognition account. ` +
236
+ `The Cognition catalog returned it with disabled=true — meaning your current plan/tier ` +
237
+ `does not include this model. ` +
238
+ `Check the model picker on https://codeium.com/account, or pick a different model. ` +
239
+ `(This message replaces Cognition's "an internal error occurred" — same root cause.)`
240
+ : `Model uid "${modelUid}" is not listed in the Cognition catalog for your account. ` +
241
+ `Either the UID has been retired upstream or your account/region doesn't serve it. ` +
242
+ `Run \`curl http://127.0.0.1:42100/v1/models\` to see the canonical names your plan accepts.`,
243
+ );
244
+ this.name = 'ModelNotAvailableError';
245
+ }
246
+ }