@nmzpy/pi-ember-stack 0.1.1 → 0.1.3
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/ATTRIBUTION.md +7 -2
- package/README.md +90 -69
- package/package.json +49 -50
- package/plugins/devin-auth/AGENTS.md +63 -0
- package/plugins/devin-auth/LICENSE +21 -0
- package/plugins/devin-auth/README.md +56 -0
- package/plugins/devin-auth/extensions/index.ts +141 -0
- package/plugins/devin-auth/src/cloud-direct/auth.ts +246 -0
- package/plugins/devin-auth/src/cloud-direct/catalog.ts +246 -0
- package/plugins/devin-auth/src/cloud-direct/chat.ts +1091 -0
- package/plugins/devin-auth/src/cloud-direct/index.ts +41 -0
- package/plugins/devin-auth/src/cloud-direct/metadata.ts +78 -0
- package/plugins/devin-auth/src/cloud-direct/wire.ts +202 -0
- package/plugins/devin-auth/src/context-map.ts +170 -0
- package/plugins/devin-auth/src/models.ts +236 -0
- package/plugins/devin-auth/src/oauth/login.ts +95 -0
- package/plugins/devin-auth/src/oauth/register-user.ts +174 -0
- package/plugins/devin-auth/src/oauth/types.ts +71 -0
- package/plugins/devin-auth/src/stream.ts +341 -0
- package/plugins/index.ts +138 -0
- package/{src/pi-ember-stack.ts → plugins/pi-compact-tools/index.ts} +2 -2
- package/{src → plugins}/subagent/extensions/model.ts +96 -96
- package/tsconfig.json +2 -1
- /package/{src → plugins/pi-compact-tools}/questionnaire-tool.ts +0 -0
- /package/{src → plugins}/subagent/LICENSE +0 -0
- /package/{src → plugins}/subagent/README.md +0 -0
- /package/{src → plugins}/subagent/agent-format.md +0 -0
- /package/{src → plugins}/subagent/agents/architect.md +0 -0
- /package/{src → plugins}/subagent/agents/coder.md +0 -0
- /package/{src → plugins}/subagent/agents/general-purpose.md +0 -0
- /package/{src → plugins}/subagent/agents/reviewer.md +0 -0
- /package/{src → plugins}/subagent/agents/scout.md +0 -0
- /package/{src → plugins}/subagent/agents/worker.md +0 -0
- /package/{src → plugins}/subagent/extensions/agents.ts +0 -0
- /package/{src → plugins}/subagent/extensions/index.ts +0 -0
- /package/{src → plugins}/subagent/extensions/package.json +0 -0
- /package/{src → plugins}/subagent/extensions/render.ts +0 -0
- /package/{src → plugins}/subagent/extensions/runner.ts +0 -0
- /package/{src → plugins}/subagent/extensions/service.ts +0 -0
- /package/{src → plugins}/subagent/extensions/thread-viewer.ts +0 -0
- /package/{src → plugins}/subagent/extensions/threads.ts +0 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mint the short-lived `user_jwt` that every chat RPC needs alongside the
|
|
3
|
+
* persistent OAuth-issued `api_key`.
|
|
4
|
+
*
|
|
5
|
+
* POST https://server.codeium.com/exa.auth_pb.AuthService/GetUserJwt
|
|
6
|
+
* Content-Type: application/proto ← unary, NOT streaming
|
|
7
|
+
* Body: GetUserJwtRequest { metadata: Metadata }
|
|
8
|
+
* Response: GetUserJwtResponse { user_jwt: string } (field 1)
|
|
9
|
+
*
|
|
10
|
+
* The returned JWT has a payload like:
|
|
11
|
+
* {
|
|
12
|
+
* "api_key": "devin-synthetic-apikey$account-…$user-…",
|
|
13
|
+
* "auth_uid": "devin-auth-uid$…",
|
|
14
|
+
* "email": "user@example.com",
|
|
15
|
+
* "exp": <unix-seconds>, ← ~24 minute TTL
|
|
16
|
+
* "pro": true,
|
|
17
|
+
* "teams_tier": "TEAMS_TIER_DEVIN_PRO",
|
|
18
|
+
* ...
|
|
19
|
+
* }
|
|
20
|
+
*
|
|
21
|
+
* The JWT is signed HS256 by the server — can't be forged client-side. We
|
|
22
|
+
* cache it and refresh shortly before `exp`.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import * as crypto from 'crypto';
|
|
26
|
+
import { encodeMessage, iterFields } from './wire.js';
|
|
27
|
+
import { buildMetadata } from './metadata.js';
|
|
28
|
+
|
|
29
|
+
const DEFAULT_HOST = 'https://server.codeium.com';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Polyfill for `AbortSignal.any` — composes multiple signals so the result
|
|
33
|
+
* aborts when ANY input aborts. Built-in in Node ≥20.3 / Bun ≥1.0. Our
|
|
34
|
+
* `engines.node` is `>=18.0.0`, so we ship the fallback ourselves; without
|
|
35
|
+
* it the caller's cancel signal silently disappears on older runtimes
|
|
36
|
+
* (chat-cancel during a `GetUserJwt` mint would keep the network request
|
|
37
|
+
* alive for up to the full 30s timeout).
|
|
38
|
+
*/
|
|
39
|
+
function anySignal(signals: AbortSignal[]): AbortSignal {
|
|
40
|
+
const builtin = (AbortSignal as unknown as { any?: (s: AbortSignal[]) => AbortSignal }).any;
|
|
41
|
+
if (typeof builtin === 'function') return builtin(signals);
|
|
42
|
+
const controller = new AbortController();
|
|
43
|
+
const onAbort = (reason: unknown): void => {
|
|
44
|
+
if (!controller.signal.aborted) controller.abort(reason);
|
|
45
|
+
};
|
|
46
|
+
for (const s of signals) {
|
|
47
|
+
if (s.aborted) {
|
|
48
|
+
onAbort(s.reason);
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
s.addEventListener('abort', () => onAbort(s.reason), { once: true });
|
|
52
|
+
}
|
|
53
|
+
return controller.signal;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface MintedUserJwt {
|
|
57
|
+
jwt: string;
|
|
58
|
+
/** Unix epoch seconds when the JWT expires. */
|
|
59
|
+
expiresAt: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class CloudAuthError extends Error {
|
|
63
|
+
constructor(message: string, public readonly status?: number) {
|
|
64
|
+
super(message);
|
|
65
|
+
this.name = 'CloudAuthError';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Default mint timeout — 30s is generous (the endpoint responds in ~200ms
|
|
71
|
+
* in steady state) but enough headroom for slow networks. Callers can pass
|
|
72
|
+
* a tighter `signal` to override.
|
|
73
|
+
*/
|
|
74
|
+
const MINT_TIMEOUT_MS = 30_000;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Mint a fresh user_jwt by calling exa.auth_pb.AuthService/GetUserJwt.
|
|
78
|
+
* `host` defaults to https://server.codeium.com — pass your tenant URL if your
|
|
79
|
+
* RegisterUser response gave a different host.
|
|
80
|
+
*
|
|
81
|
+
* Always applies an internal 30s timeout so a network stall here can't
|
|
82
|
+
* deadlock every concurrent chat request. If the caller passes a `signal`,
|
|
83
|
+
* we honor whichever fires first via AbortSignal.any.
|
|
84
|
+
*/
|
|
85
|
+
export async function mintUserJwt(
|
|
86
|
+
apiKey: string,
|
|
87
|
+
host: string = DEFAULT_HOST,
|
|
88
|
+
signal?: AbortSignal,
|
|
89
|
+
): Promise<MintedUserJwt> {
|
|
90
|
+
const metadata = buildMetadata({
|
|
91
|
+
apiKey,
|
|
92
|
+
sessionId: crypto.randomUUID(),
|
|
93
|
+
requestId: BigInt(Date.now()),
|
|
94
|
+
triggerId: crypto.randomUUID(),
|
|
95
|
+
});
|
|
96
|
+
// GetUserJwtRequest { metadata: Metadata } — Metadata is field 1
|
|
97
|
+
const req = encodeMessage(1, metadata);
|
|
98
|
+
|
|
99
|
+
// Compose caller signal with our internal timeout via `anySignal` — a
|
|
100
|
+
// small polyfill of `AbortSignal.any` for runtimes (Node 18 / older
|
|
101
|
+
// Bun) that lack the built-in. The previous fallback silently dropped
|
|
102
|
+
// the CALLER's signal on those runtimes, so a chat-cancel during a
|
|
103
|
+
// GetUserJwt mint would keep the network request alive for up to the
|
|
104
|
+
// full 30s timeout.
|
|
105
|
+
const timeoutSignal = AbortSignal.timeout(MINT_TIMEOUT_MS);
|
|
106
|
+
const combinedSignal: AbortSignal = signal
|
|
107
|
+
? anySignal([signal, timeoutSignal])
|
|
108
|
+
: timeoutSignal;
|
|
109
|
+
|
|
110
|
+
const resp = await fetch(`${host.replace(/\/$/, '')}/exa.auth_pb.AuthService/GetUserJwt`, {
|
|
111
|
+
method: 'POST',
|
|
112
|
+
headers: {
|
|
113
|
+
'Content-Type': 'application/proto',
|
|
114
|
+
'Connect-Protocol-Version': '1',
|
|
115
|
+
},
|
|
116
|
+
body: req,
|
|
117
|
+
signal: combinedSignal,
|
|
118
|
+
});
|
|
119
|
+
const buf = Buffer.from(await resp.arrayBuffer());
|
|
120
|
+
|
|
121
|
+
if (!resp.ok) {
|
|
122
|
+
const text = buf.toString('utf8');
|
|
123
|
+
throw new CloudAuthError(`GetUserJwt HTTP ${resp.status}: ${text.slice(0, 400)}`, resp.status);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Response is GetUserJwtResponse { user_jwt: string } where user_jwt is
|
|
127
|
+
// field 1, length-delimited. Decode the field properly instead of
|
|
128
|
+
// regex-scanning the whole buffer — the previous regex would pick up
|
|
129
|
+
// any JWT-shaped substring in the response (trace IDs, signature
|
|
130
|
+
// headers, any cached token inadvertently logged) and could even land
|
|
131
|
+
// on a non-user_jwt if Cognition ever embeds another JWT in a sibling
|
|
132
|
+
// field.
|
|
133
|
+
let jwt: string | null = null;
|
|
134
|
+
for (const f of iterFields(buf)) {
|
|
135
|
+
if (f.num === 1 && f.wire === 2 && Buffer.isBuffer(f.value)) {
|
|
136
|
+
const s = (f.value as Buffer).toString('utf8');
|
|
137
|
+
// Sanity-check the shape — defensive: if the cloud ever moves user_jwt
|
|
138
|
+
// out from field 1 we want a clean error, not silently wrong creds.
|
|
139
|
+
// base64url with OPTIONAL `=` padding on each segment. Most modern
|
|
140
|
+
// JWTs omit the `=`, but the spec allows it and a future server-side
|
|
141
|
+
// change could re-introduce it; either way it's still a valid token.
|
|
142
|
+
if (/^eyJ[A-Za-z0-9_-]{10,}={0,2}\.[A-Za-z0-9_-]+={0,2}\.[A-Za-z0-9_-]+={0,2}$/.test(s)) {
|
|
143
|
+
jwt = s;
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (!jwt) {
|
|
149
|
+
throw new CloudAuthError(
|
|
150
|
+
`GetUserJwt 200 but no field-1 JWT found (${buf.length} bytes): ${buf.toString('utf8').slice(0, 200)}`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Decode the payload to get the expiry.
|
|
155
|
+
let expiresAt = Math.floor(Date.now() / 1000) + 600; // fallback: 10 min
|
|
156
|
+
try {
|
|
157
|
+
const parts = jwt.split('.');
|
|
158
|
+
const pad = (s: string) => s + '='.repeat((4 - (s.length % 4)) % 4);
|
|
159
|
+
const payload = JSON.parse(
|
|
160
|
+
Buffer.from(pad(parts[1]).replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'),
|
|
161
|
+
);
|
|
162
|
+
if (typeof payload.exp === 'number') expiresAt = payload.exp;
|
|
163
|
+
} catch { /* fall back to default */ }
|
|
164
|
+
|
|
165
|
+
return { jwt, expiresAt };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ----------------------------------------------------------------------------
|
|
169
|
+
// In-memory cache — refresh ~60s before expiry
|
|
170
|
+
// ----------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
interface CacheEntry {
|
|
173
|
+
jwt: string;
|
|
174
|
+
expiresAt: number;
|
|
175
|
+
apiKey: string;
|
|
176
|
+
host: string;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Cache is keyed by (apiKey, host). A single shared `cache` slot only holds
|
|
181
|
+
* the MOST RECENTLY USED entry — common case is one account at a time, so
|
|
182
|
+
* a single slot is enough. inFlight is a per-key map so a JWT mint for
|
|
183
|
+
* account A doesn't get returned to a concurrent request for account B.
|
|
184
|
+
*
|
|
185
|
+
* Previously `inFlight` was a singleton — if account A's mint was in flight
|
|
186
|
+
* and a request for account B arrived, B got A's JWT. That's the M1
|
|
187
|
+
* "concurrent requests after account switch get wrong JWT" bug.
|
|
188
|
+
*/
|
|
189
|
+
let cache: CacheEntry | null = null;
|
|
190
|
+
const inFlight = new Map<string, Promise<MintedUserJwt>>();
|
|
191
|
+
/**
|
|
192
|
+
* Monotonic epoch counter. Incremented on every `clearCachedUserJwt()`
|
|
193
|
+
* call so an in-flight mint that started BEFORE the clear can't
|
|
194
|
+
* repopulate the cache after-the-fact. Without this, a logout that
|
|
195
|
+
* happened concurrently with a mint would silently get its just-
|
|
196
|
+
* invalidated JWT cached and served for the next ~24 minutes.
|
|
197
|
+
*/
|
|
198
|
+
let cacheEpoch = 0;
|
|
199
|
+
|
|
200
|
+
function flightKey(apiKey: string, host: string): string {
|
|
201
|
+
return `${host}\x1f${apiKey}`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Get a cached user_jwt or mint a new one. Refreshes when the cached JWT is
|
|
206
|
+
* within 60s of expiry. Multiple concurrent callers for the SAME (apiKey, host)
|
|
207
|
+
* share the same in-flight mint; concurrent callers for DIFFERENT keys each
|
|
208
|
+
* get their own mint.
|
|
209
|
+
*/
|
|
210
|
+
export async function getCachedUserJwt(apiKey: string, host: string = DEFAULT_HOST, signal?: AbortSignal): Promise<string> {
|
|
211
|
+
const now = Math.floor(Date.now() / 1000);
|
|
212
|
+
if (cache && cache.apiKey === apiKey && cache.host === host && cache.expiresAt > now + 60) {
|
|
213
|
+
return cache.jwt;
|
|
214
|
+
}
|
|
215
|
+
const key = flightKey(apiKey, host);
|
|
216
|
+
const existing = inFlight.get(key);
|
|
217
|
+
if (existing) return (await existing).jwt;
|
|
218
|
+
const promise = mintUserJwt(apiKey, host, signal);
|
|
219
|
+
inFlight.set(key, promise);
|
|
220
|
+
// Snapshot the epoch BEFORE awaiting the mint. If clearCachedUserJwt()
|
|
221
|
+
// fires while we're awaiting (logout-during-mint), the epoch changes
|
|
222
|
+
// and we won't repopulate the cache with the just-invalidated JWT.
|
|
223
|
+
const epochAtStart = cacheEpoch;
|
|
224
|
+
try {
|
|
225
|
+
const minted = await promise;
|
|
226
|
+
if (cacheEpoch === epochAtStart) {
|
|
227
|
+
cache = { jwt: minted.jwt, expiresAt: minted.expiresAt, apiKey, host };
|
|
228
|
+
}
|
|
229
|
+
return minted.jwt;
|
|
230
|
+
} finally {
|
|
231
|
+
inFlight.delete(key);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Drop the in-memory JWT cache. Call after credential changes (logout,
|
|
237
|
+
* account switch) so long-running opencode processes don't keep using a
|
|
238
|
+
* JWT minted from a now-invalid api_key. Also bumps the cache epoch so
|
|
239
|
+
* any in-flight mint racing with this clear can't repopulate cache
|
|
240
|
+
* with the stale JWT after-the-fact.
|
|
241
|
+
*/
|
|
242
|
+
export function clearCachedUserJwt(): void {
|
|
243
|
+
cache = null;
|
|
244
|
+
inFlight.clear();
|
|
245
|
+
cacheEpoch++;
|
|
246
|
+
}
|
|
@@ -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
|
+
export 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
|
+
}
|