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.
- package/AGENTS.md +63 -0
- package/LICENSE +21 -0
- package/README.md +64 -0
- package/extensions/index.ts +69 -0
- package/package.json +58 -0
- package/src/cloud-direct/auth.ts +246 -0
- package/src/cloud-direct/catalog.ts +246 -0
- package/src/cloud-direct/chat.ts +1091 -0
- package/src/cloud-direct/index.ts +40 -0
- package/src/cloud-direct/metadata.ts +78 -0
- package/src/cloud-direct/wire.ts +202 -0
- package/src/context-map.ts +170 -0
- package/src/models.ts +135 -0
- package/src/oauth/login.ts +95 -0
- package/src/oauth/register-user.ts +174 -0
- package/src/oauth/types.ts +71 -0
- package/src/stream.ts +341 -0
|
@@ -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
|
+
}
|