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,1091 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloud-direct streaming chat. Talks to
|
|
3
|
+
* `server.codeium.com/exa.api_server_pb.ApiServerService/GetChatMessage`
|
|
4
|
+
* with no local language_server in the path. Returns an async iterable of
|
|
5
|
+
* text deltas so the caller can stream straight into opencode's SSE.
|
|
6
|
+
*
|
|
7
|
+
* What this DOES support today:
|
|
8
|
+
* - Single- or multi-turn chat using the prompt-and-history pattern the LS
|
|
9
|
+
* uses (flatten history into one ChatMessagePrompt list)
|
|
10
|
+
* - All free Windsurf models (swe-1.6, kimi-k2.6) and any model the user's
|
|
11
|
+
* api_key is entitled to
|
|
12
|
+
* - Streaming (uses Connect-streaming envelope, emits deltas as they arrive)
|
|
13
|
+
*
|
|
14
|
+
* What this DOES NOT yet support (future work):
|
|
15
|
+
* - Tools (the GetChatMessage proto has a `tools` field; the opencode plugin
|
|
16
|
+
* currently runs tool-planning in `src/plugin.ts:planToolCall` against the
|
|
17
|
+
* local LS — porting that to cloud-direct requires also encoding the tool
|
|
18
|
+
* definitions in the request and decoding tool_calls from the response)
|
|
19
|
+
* - Workspace context (open files, cursor position) — chat-only mode
|
|
20
|
+
*
|
|
21
|
+
* Wire-protocol reference: docs/CLOUD_DIRECT.md.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import * as crypto from 'crypto';
|
|
25
|
+
import * as zlib from 'zlib';
|
|
26
|
+
import {
|
|
27
|
+
encodeMessage,
|
|
28
|
+
encodeString,
|
|
29
|
+
encodeVarintField,
|
|
30
|
+
frameConnectStream,
|
|
31
|
+
iterFields,
|
|
32
|
+
parseConnectFrames,
|
|
33
|
+
} from './wire.js';
|
|
34
|
+
import { buildMetadata } from './metadata.js';
|
|
35
|
+
import { getCachedUserJwt } from './auth.js';
|
|
36
|
+
import { getCachedCatalog, ModelNotAvailableError } from './catalog.js';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Connect-RPC streaming inactivity timeout. If the cloud sends zero bytes
|
|
40
|
+
* for this long after the last chunk, we abort the fetch. The cloud's own
|
|
41
|
+
* idle limit is around 90s on most models; we set ours a little above so
|
|
42
|
+
* we only trigger when the server has genuinely stopped responding.
|
|
43
|
+
*/
|
|
44
|
+
const CLOUD_STREAM_IDLE_MS = 120_000;
|
|
45
|
+
/** Time-to-first-byte timeout. */
|
|
46
|
+
const CLOUD_STREAM_TTFB_MS = 60_000;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Compose multiple AbortSignals into a single signal that aborts when ANY
|
|
50
|
+
* input aborts. Uses `AbortSignal.any` when available (Node ≥20.3 / Bun
|
|
51
|
+
* ≥1.0); falls back to a manual implementation for older runtimes that
|
|
52
|
+
* are still in our `engines` range (Node 18.x and early 20.x). The
|
|
53
|
+
* previous `req.signal ?? ttfbSignal` fallback silently picked one signal
|
|
54
|
+
* and dropped the other, defeating either the caller's cancel or the
|
|
55
|
+
* internal timeout.
|
|
56
|
+
*/
|
|
57
|
+
function anySignal(signals: AbortSignal[]): AbortSignal {
|
|
58
|
+
const builtin = (AbortSignal as unknown as { any?: (s: AbortSignal[]) => AbortSignal }).any;
|
|
59
|
+
if (typeof builtin === 'function') return builtin(signals);
|
|
60
|
+
const controller = new AbortController();
|
|
61
|
+
const onAbort = (reason: unknown): void => {
|
|
62
|
+
if (!controller.signal.aborted) controller.abort(reason);
|
|
63
|
+
};
|
|
64
|
+
for (const s of signals) {
|
|
65
|
+
if (s.aborted) {
|
|
66
|
+
onAbort(s.reason);
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
s.addEventListener('abort', () => onAbort(s.reason), { once: true });
|
|
70
|
+
}
|
|
71
|
+
return controller.signal;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Per-(apiKey, host) session/cascade ID cache. Cloud uses these for
|
|
76
|
+
* server-side context caching across turns of the same conversation; if we
|
|
77
|
+
* mint a fresh sessionId on every call (which we used to), every turn looks
|
|
78
|
+
* like a brand-new session and the prompt-cache hit ratio is zero.
|
|
79
|
+
* Single-process scope is enough: opencode lives in one runtime for a TUI
|
|
80
|
+
* session, and CLI one-shots don't benefit from caching anyway.
|
|
81
|
+
*/
|
|
82
|
+
interface SessionIds {
|
|
83
|
+
sessionId: string;
|
|
84
|
+
cascadeId: string;
|
|
85
|
+
}
|
|
86
|
+
const sessionCache = new Map<string, SessionIds>();
|
|
87
|
+
function getOrAllocateSessionIds(apiKey: string, host: string, cascadeIdOverride?: string): SessionIds {
|
|
88
|
+
const key = `${host}\x1f${apiKey}`;
|
|
89
|
+
let ids = sessionCache.get(key);
|
|
90
|
+
if (!ids) {
|
|
91
|
+
ids = {
|
|
92
|
+
sessionId: crypto.randomUUID(),
|
|
93
|
+
cascadeId: cascadeIdOverride ?? allocateCascadeId(),
|
|
94
|
+
};
|
|
95
|
+
sessionCache.set(key, ids);
|
|
96
|
+
} else if (cascadeIdOverride && ids.cascadeId !== cascadeIdOverride) {
|
|
97
|
+
// Caller explicitly requested a different cascadeId — honor it.
|
|
98
|
+
ids = { sessionId: ids.sessionId, cascadeId: cascadeIdOverride };
|
|
99
|
+
sessionCache.set(key, ids);
|
|
100
|
+
}
|
|
101
|
+
return ids;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Drop the cached session IDs — call after logout so a new sign-in starts fresh. */
|
|
105
|
+
export function clearSessionIds(): void {
|
|
106
|
+
sessionCache.clear();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ----------------------------------------------------------------------------
|
|
110
|
+
// Per-conversation cascade state — generated client-side; cloud lazy-registers
|
|
111
|
+
// ----------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Allocate a fresh cascade UUID. The cloud lazy-registers cascade_id on first
|
|
115
|
+
* use — confirmed empirically (random UUID accepted, model responded). One
|
|
116
|
+
* cascade_id per opencode-CLI conversation is fine; reuse across turns to
|
|
117
|
+
* preserve server-side context.
|
|
118
|
+
*/
|
|
119
|
+
export function allocateCascadeId(): string {
|
|
120
|
+
return crypto.randomUUID();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ----------------------------------------------------------------------------
|
|
124
|
+
// Request encoders
|
|
125
|
+
// ----------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* ChatMessagePrompt {
|
|
129
|
+
* #2 source: enum CHAT_MESSAGE_SOURCE_USER=1 / ASSISTANT=2 / SYSTEM=3 / TOOL=4
|
|
130
|
+
* #3 prompt: string (text content)
|
|
131
|
+
* #4 num_tokens: int (rough estimate)
|
|
132
|
+
* #5 safe_for_code_telemetry: bool (1 = ok to log)
|
|
133
|
+
* #10 images: repeated ImageData (multimodal)
|
|
134
|
+
* }
|
|
135
|
+
*
|
|
136
|
+
* ImageData (exa.codeium_common_pb.ImageData) {
|
|
137
|
+
* #1 base64_data: string
|
|
138
|
+
* #2 mime_type: string
|
|
139
|
+
* #3 caption: string (optional)
|
|
140
|
+
* }
|
|
141
|
+
*/
|
|
142
|
+
function encodeImageData(img: { mimeType: string; base64Data: string; caption?: string }): Buffer {
|
|
143
|
+
const parts: Buffer[] = [
|
|
144
|
+
encodeString(1, img.base64Data),
|
|
145
|
+
encodeString(2, img.mimeType),
|
|
146
|
+
];
|
|
147
|
+
if (img.caption) parts.push(encodeString(3, img.caption));
|
|
148
|
+
return Buffer.concat(parts);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Encode one ChatToolCall sub-message:
|
|
153
|
+
* {#1 id, #2 name, #3 arguments_json}
|
|
154
|
+
* Verified against `exa.codeium_common_pb.ChatToolCall` from extension.js.
|
|
155
|
+
*/
|
|
156
|
+
function encodeChatToolCall(tc: { id: string; name: string; arguments: string }): Buffer {
|
|
157
|
+
return Buffer.concat([
|
|
158
|
+
encodeString(1, tc.id),
|
|
159
|
+
encodeString(2, tc.name),
|
|
160
|
+
encodeString(3, tc.arguments),
|
|
161
|
+
]);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function encodeChatMessagePrompt(
|
|
165
|
+
content: ContentPart[],
|
|
166
|
+
source: number,
|
|
167
|
+
opts?: { toolCallId?: string; toolCalls?: Array<{ id: string; name: string; arguments: string }> },
|
|
168
|
+
): Buffer {
|
|
169
|
+
const textParts = content.filter((p): p is { type: 'text'; text: string } => p.type === 'text');
|
|
170
|
+
const imageParts = content.filter((p): p is { type: 'image'; mimeType: string; base64Data: string; caption?: string } => p.type === 'image');
|
|
171
|
+
const joined = textParts.map((p) => p.text).join('\n');
|
|
172
|
+
const parts: Buffer[] = [
|
|
173
|
+
encodeVarintField(2, source),
|
|
174
|
+
encodeString(3, joined),
|
|
175
|
+
encodeVarintField(4, Math.max(1, Math.floor(joined.length / 4))),
|
|
176
|
+
encodeVarintField(5, 1),
|
|
177
|
+
];
|
|
178
|
+
// Tool-result message: attach the id of the call this result answers.
|
|
179
|
+
// Without it, the model can't pair multi-tool conversations.
|
|
180
|
+
if (opts?.toolCallId) {
|
|
181
|
+
parts.push(encodeString(7, opts.toolCallId));
|
|
182
|
+
}
|
|
183
|
+
// Assistant message with tool_calls: encode each as a ChatToolCall.
|
|
184
|
+
if (opts?.toolCalls && opts.toolCalls.length > 0) {
|
|
185
|
+
for (const tc of opts.toolCalls) {
|
|
186
|
+
parts.push(encodeMessage(6, encodeChatToolCall(tc)));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
for (const img of imageParts) {
|
|
190
|
+
parts.push(encodeMessage(10, encodeImageData(img)));
|
|
191
|
+
}
|
|
192
|
+
return Buffer.concat(parts);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const SOURCE_BY_ROLE: Record<string, number> = {
|
|
196
|
+
user: 1,
|
|
197
|
+
assistant: 2,
|
|
198
|
+
// NOTE: do not send source=3 (SYSTEM) directly — the Codeium chat backend
|
|
199
|
+
// returns "third-party model provider is experiencing issues" when any
|
|
200
|
+
// ChatMessagePrompt has source=SYSTEM. The captured LS upstream traffic
|
|
201
|
+
// shows the IDE inlines system context into the *user* prompt (source=1)
|
|
202
|
+
// wrapped in <additional_metadata>...</additional_metadata>. We collapse
|
|
203
|
+
// role:'system' messages into the next user turn before building the
|
|
204
|
+
// proto — see `collapseSystemIntoUser` below.
|
|
205
|
+
system: 1,
|
|
206
|
+
tool: 4,
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Collapse OpenAI-style messages so all `role:'system'` entries are inlined
|
|
211
|
+
* into the immediately-following user message, matching the wire format the
|
|
212
|
+
* IDE uses. Cognition's chat backend rejects raw role=system entries.
|
|
213
|
+
*
|
|
214
|
+
* [{system: "S1"}, {system: "S2"}, {user: "U1"}, {assistant: "A1"}, {user: "U2"}]
|
|
215
|
+
*
|
|
216
|
+
* becomes
|
|
217
|
+
*
|
|
218
|
+
* [{user: "<system>\nS1\nS2\n</system>\nU1"}, {assistant: "A1"}, {user: "U2"}]
|
|
219
|
+
*
|
|
220
|
+
* If there's no following user message, the trailing system messages get
|
|
221
|
+
* appended as a synthesized user turn.
|
|
222
|
+
*/
|
|
223
|
+
function collapseSystemIntoUser(messages: ChatHistoryItem[]): ChatHistoryItem[] {
|
|
224
|
+
const out: ChatHistoryItem[] = [];
|
|
225
|
+
let pendingSystem: string[] = [];
|
|
226
|
+
|
|
227
|
+
const flushTextOf = (content: ContentPart[]): string =>
|
|
228
|
+
content.filter((p): p is { type: 'text'; text: string } => p.type === 'text')
|
|
229
|
+
.map((p) => p.text).join('\n');
|
|
230
|
+
|
|
231
|
+
for (const m of messages) {
|
|
232
|
+
if (m.role === 'system') {
|
|
233
|
+
const parts = normalizeContent(m.content);
|
|
234
|
+
const text = flushTextOf(parts);
|
|
235
|
+
if (text) pendingSystem.push(text);
|
|
236
|
+
} else if (m.role === 'user' && pendingSystem.length > 0) {
|
|
237
|
+
const userParts = normalizeContent(m.content);
|
|
238
|
+
const userText = flushTextOf(userParts);
|
|
239
|
+
const userImages = userParts.filter((p) => p.type === 'image');
|
|
240
|
+
const wrapped = `<system>\n${pendingSystem.join('\n\n')}\n</system>\n${userText}`;
|
|
241
|
+
const newContent: ContentPart[] = [{ type: 'text', text: wrapped }, ...userImages];
|
|
242
|
+
out.push({ role: 'user', content: newContent });
|
|
243
|
+
pendingSystem = [];
|
|
244
|
+
} else {
|
|
245
|
+
out.push(m);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (pendingSystem.length > 0) {
|
|
249
|
+
// Trailing system messages with no following user turn — convert to a
|
|
250
|
+
// standalone user message so they still reach the model.
|
|
251
|
+
out.push({
|
|
252
|
+
role: 'user',
|
|
253
|
+
content: [{ type: 'text', text: `<system>\n${pendingSystem.join('\n\n')}\n</system>` }],
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
return out;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* CompletionConfiguration — mirrors the LS-shipped defaults, lets the caller
|
|
261
|
+
* override the obvious knobs.
|
|
262
|
+
*/
|
|
263
|
+
function encodeCompletionConfiguration(opts: {
|
|
264
|
+
maxOutputTokens?: number;
|
|
265
|
+
maxInputTokens?: number;
|
|
266
|
+
temperature?: number;
|
|
267
|
+
topK?: number;
|
|
268
|
+
topP?: number;
|
|
269
|
+
}): Buffer {
|
|
270
|
+
const enc64 = (fieldNum: number, n: number): Buffer => {
|
|
271
|
+
const b = Buffer.alloc(8);
|
|
272
|
+
b.writeDoubleLE(n, 0);
|
|
273
|
+
return Buffer.concat([Buffer.from([(fieldNum << 3) | 1]), b]);
|
|
274
|
+
};
|
|
275
|
+
return Buffer.concat([
|
|
276
|
+
encodeVarintField(1, 1),
|
|
277
|
+
encodeVarintField(2, opts.maxInputTokens ?? 64000),
|
|
278
|
+
// Default to the catalog's most permissive `maxOutputTokens` (128K).
|
|
279
|
+
// The cloud clamps to the per-model limit anyway. The old 4096 default
|
|
280
|
+
// would silently truncate any callers (tests, CLI users of
|
|
281
|
+
// streamChatEvents directly) who didn't override.
|
|
282
|
+
encodeVarintField(3, opts.maxOutputTokens ?? 128_000),
|
|
283
|
+
enc64(5, opts.temperature ?? 0.7),
|
|
284
|
+
enc64(6, opts.topP ?? 0.95),
|
|
285
|
+
encodeVarintField(7, opts.topK ?? 50),
|
|
286
|
+
enc64(8, 1.0),
|
|
287
|
+
enc64(11, 1.0),
|
|
288
|
+
]);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Multimodal content part — text or image.
|
|
293
|
+
*
|
|
294
|
+
* Text: `{ type: 'text', text: '...' }`
|
|
295
|
+
* Image: `{ type: 'image', mimeType: 'image/png', base64Data: '...' [, caption: '...'] }`
|
|
296
|
+
*
|
|
297
|
+
* Matches the OpenAI/@ai-sdk multimodal message shape — we accept their
|
|
298
|
+
* `image_url: { url: 'data:image/png;base64,...' }` form via {@link parseContent}.
|
|
299
|
+
*/
|
|
300
|
+
export type ContentPart =
|
|
301
|
+
| { type: 'text'; text: string }
|
|
302
|
+
| { type: 'image'; mimeType: string; base64Data: string; caption?: string };
|
|
303
|
+
|
|
304
|
+
export interface ChatHistoryItem {
|
|
305
|
+
role: 'user' | 'assistant' | 'system' | 'tool';
|
|
306
|
+
/**
|
|
307
|
+
* Either a plain string or an array of {@link ContentPart}. Plain strings are
|
|
308
|
+
* shorthand for `[{ type: 'text', text: '...' }]`.
|
|
309
|
+
*/
|
|
310
|
+
content: string | ContentPart[];
|
|
311
|
+
/**
|
|
312
|
+
* For `role: 'tool'` only — the id of the assistant's preceding tool_call
|
|
313
|
+
* this message answers. Required by the cloud's chat backend to pair
|
|
314
|
+
* tool results with calls; without it, multi-tool conversations can't
|
|
315
|
+
* tell the model which call produced which result. Encoded as
|
|
316
|
+
* ChatMessagePrompt field #7 (verified against the Windsurf bundled
|
|
317
|
+
* extension.js proto schema `exa.chat_pb.ChatMessagePrompt`).
|
|
318
|
+
*/
|
|
319
|
+
tool_call_id?: string;
|
|
320
|
+
/**
|
|
321
|
+
* For `role: 'assistant'` only — the tool calls the assistant emitted.
|
|
322
|
+
* Encoded as ChatMessagePrompt field #6 (repeated ChatToolCall, where
|
|
323
|
+
* each ChatToolCall has #1 id, #2 name, #3 arguments_json).
|
|
324
|
+
*/
|
|
325
|
+
tool_calls?: Array<{ id: string; name: string; arguments: string }>;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Normalize ChatHistoryItem content into structured parts. Accepts strings,
|
|
330
|
+
* OpenAI multimodal `[{type:'text',text}, {type:'image_url',image_url}]`, and
|
|
331
|
+
* our own `[{type:'image', mimeType, base64Data}]`.
|
|
332
|
+
*/
|
|
333
|
+
function normalizeContent(content: string | ContentPart[] | unknown): ContentPart[] {
|
|
334
|
+
if (typeof content === 'string') return [{ type: 'text', text: content }];
|
|
335
|
+
if (!Array.isArray(content)) return [];
|
|
336
|
+
const out: ContentPart[] = [];
|
|
337
|
+
// Each element may follow our own ContentPart shape, the OpenAI multimodal
|
|
338
|
+
// `image_url` shape, or be malformed — narrow defensively per branch.
|
|
339
|
+
const parts = content as Array<Record<string, unknown>>;
|
|
340
|
+
for (const p of parts) {
|
|
341
|
+
if (!p || typeof p !== 'object') continue;
|
|
342
|
+
if (p.type === 'text' && typeof p.text === 'string') {
|
|
343
|
+
out.push({ type: 'text', text: p.text });
|
|
344
|
+
} else if (p.type === 'image' && typeof p.base64Data === 'string') {
|
|
345
|
+
const mimeType = typeof p.mimeType === 'string' ? p.mimeType : 'image/png';
|
|
346
|
+
const caption = typeof p.caption === 'string' ? p.caption : undefined;
|
|
347
|
+
out.push({ type: 'image', mimeType, base64Data: p.base64Data, caption });
|
|
348
|
+
} else if (p.type === 'image_url' && p.image_url) {
|
|
349
|
+
// OpenAI/@ai-sdk shape — parse data: URL into base64 + mime.
|
|
350
|
+
const imgRef = p.image_url as string | { url?: string };
|
|
351
|
+
const url: string = typeof imgRef === 'string' ? imgRef : (imgRef.url ?? '');
|
|
352
|
+
const m = url.match(/^data:([^;]+);base64,(.+)$/);
|
|
353
|
+
if (m) out.push({ type: 'image', mimeType: m[1], base64Data: m[2] });
|
|
354
|
+
else if (url) out.push({ type: 'text', text: `[image url: ${url}]` });
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return out;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export interface ToolDef {
|
|
361
|
+
/** Function name. */
|
|
362
|
+
name: string;
|
|
363
|
+
/** Plain-English description. */
|
|
364
|
+
description: string;
|
|
365
|
+
/** JSON Schema for the function's arguments. */
|
|
366
|
+
parameters: unknown;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Streaming event emitted by the cloud-direct chat loop.
|
|
371
|
+
*
|
|
372
|
+
* - `text` : incremental visible content from the assistant
|
|
373
|
+
* - `reasoning` : incremental internal thinking (Anthropic-style, kept
|
|
374
|
+
* separate from visible content; @ai-sdk consumers can
|
|
375
|
+
* render in a collapsed/grey region)
|
|
376
|
+
* - `tool_call_*` : function-calling deltas (id+name once, args streamed)
|
|
377
|
+
* - `finish` : stream terminated cleanly with a reason
|
|
378
|
+
* - `usage` : final token-accounting block (input/output/total counts)
|
|
379
|
+
*/
|
|
380
|
+
export type CloudChatEvent =
|
|
381
|
+
| { kind: 'text'; text: string }
|
|
382
|
+
| { kind: 'reasoning'; text: string }
|
|
383
|
+
| { kind: 'tool_call_start'; id: string; name: string }
|
|
384
|
+
| {
|
|
385
|
+
kind: 'tool_call_args';
|
|
386
|
+
argsDelta: string;
|
|
387
|
+
/**
|
|
388
|
+
* Tool-call id this delta belongs to, when the cloud surfaced one in
|
|
389
|
+
* this frame. Cognition's wire format only carries id on the START
|
|
390
|
+
* frame today, so most argsDelta events arrive without one — callers
|
|
391
|
+
* route those to the most-recent-start by convention. If Cognition
|
|
392
|
+
* ever interleaves args across calls, the consumer should prefer
|
|
393
|
+
* `id` over the rolling lastToolCallId.
|
|
394
|
+
*/
|
|
395
|
+
id?: string;
|
|
396
|
+
}
|
|
397
|
+
// Note: there is no `tool_call_end` event. Cognition's wire format
|
|
398
|
+
// signals the end of a tool call implicitly — args just stop arriving
|
|
399
|
+
// for the current id and either a new `tool_call_start` fires or the
|
|
400
|
+
// stream finishes. Consumers should treat each `tool_call_start` as
|
|
401
|
+
// ending the previous call.
|
|
402
|
+
| { kind: 'finish'; reason: 'stop' | 'tool_calls' | 'length' | 'content_filter' }
|
|
403
|
+
| {
|
|
404
|
+
kind: 'usage';
|
|
405
|
+
promptTokens?: number;
|
|
406
|
+
completionTokens?: number;
|
|
407
|
+
totalTokens?: number;
|
|
408
|
+
/**
|
|
409
|
+
* Tokens served from the cache. Surfaced separately so callers tracking
|
|
410
|
+
* cost can distinguish them from fresh input tokens (Anthropic / OpenAI
|
|
411
|
+
* both bill cache reads cheaper than fresh prompts).
|
|
412
|
+
*/
|
|
413
|
+
cachedInputTokens?: number;
|
|
414
|
+
/** Tokens written to the cache on this request (Anthropic-style). */
|
|
415
|
+
cacheCreationInputTokens?: number;
|
|
416
|
+
/** Reasoning tokens (gpt-5-x reasoning models, Claude thinking variants). */
|
|
417
|
+
reasoningTokens?: number;
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
interface BuildArgs {
|
|
421
|
+
apiKey: string;
|
|
422
|
+
userJwt: string;
|
|
423
|
+
modelUid: string;
|
|
424
|
+
messages: ChatHistoryItem[];
|
|
425
|
+
cascadeId: string;
|
|
426
|
+
promptId: string;
|
|
427
|
+
sessionId: string;
|
|
428
|
+
requestId: bigint;
|
|
429
|
+
triggerId: string;
|
|
430
|
+
tools?: ToolDef[];
|
|
431
|
+
/** Default 5 = CHAT_MESSAGE_REQUEST_TYPE_CASCADE (matches captured LS body). */
|
|
432
|
+
requestType?: number;
|
|
433
|
+
completionOpts?: {
|
|
434
|
+
maxOutputTokens?: number;
|
|
435
|
+
maxInputTokens?: number;
|
|
436
|
+
temperature?: number;
|
|
437
|
+
topK?: number;
|
|
438
|
+
topP?: number;
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* ChatToolDefinition proto, observed in the LS upstream traffic:
|
|
444
|
+
* { #1 name (string), #2 description (string), #3 parameters_schema (JSON string) }
|
|
445
|
+
*
|
|
446
|
+
* Truncation note: Codeium's tool validator rejects very long descriptions
|
|
447
|
+
* with a generic `failed_precondition: "Unable to process request due to an
|
|
448
|
+
* MCP configuration issue."` error. opencode ships some tools (notably `bash`)
|
|
449
|
+
* with ~9.6 KB descriptions packed with examples and rules. We truncate to a
|
|
450
|
+
* conservative `MAX_DESC_LEN` and append an ellipsis so the cloud accepts
|
|
451
|
+
* them. The model still gets the first chunk of the description (where the
|
|
452
|
+
* essential signature lives); detailed examples are sacrificed for
|
|
453
|
+
* compatibility.
|
|
454
|
+
*/
|
|
455
|
+
/**
|
|
456
|
+
* The Codeium tool validator rejects any tool whose description hits exactly
|
|
457
|
+
* 7,000 chars (or more) with a misleading `failed_precondition: "Unable to
|
|
458
|
+
* process request due to an MCP configuration issue."` error. Binary-search
|
|
459
|
+
* verified to char-precision:
|
|
460
|
+
* - 6,999 chars → server accepts
|
|
461
|
+
* - 7,000 chars → server returns MCP error
|
|
462
|
+
*
|
|
463
|
+
* The limit is per-description, content-sensitive (plain `a`-repeats up to
|
|
464
|
+
* 20K work fine; the bash description's exact byte at position 6999 trips
|
|
465
|
+
* it). We truncate to the maximum-1 (6,998) for a one-char safety margin.
|
|
466
|
+
*
|
|
467
|
+
* We do NOT need to aggregate-cap — 200K total tool descriptions across 200
|
|
468
|
+
* tools was confirmed to pass server-side. Only per-string length is gated.
|
|
469
|
+
*/
|
|
470
|
+
const MAX_TOOL_DESC_LEN = 6998;
|
|
471
|
+
function encodeToolDef(tool: ToolDef): Buffer {
|
|
472
|
+
const rawDesc = tool.description ?? '';
|
|
473
|
+
const desc =
|
|
474
|
+
rawDesc.length > MAX_TOOL_DESC_LEN
|
|
475
|
+
? rawDesc.slice(0, MAX_TOOL_DESC_LEN - 24) + '\n…(truncated for cloud)'
|
|
476
|
+
: rawDesc;
|
|
477
|
+
return Buffer.concat([
|
|
478
|
+
encodeString(1, tool.name),
|
|
479
|
+
encodeString(2, desc),
|
|
480
|
+
encodeString(3, JSON.stringify(tool.parameters ?? {})),
|
|
481
|
+
]);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function buildGetChatMessageRequest(args: BuildArgs): Buffer {
|
|
485
|
+
const metadata = buildMetadata({
|
|
486
|
+
apiKey: args.apiKey,
|
|
487
|
+
userJwt: args.userJwt,
|
|
488
|
+
sessionId: args.sessionId,
|
|
489
|
+
requestId: args.requestId,
|
|
490
|
+
triggerId: args.triggerId,
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
// System messages must be inlined into the user turn (Cognition cloud
|
|
494
|
+
// rejects source=3). See `collapseSystemIntoUser` for the format.
|
|
495
|
+
const collapsed = collapseSystemIntoUser(args.messages);
|
|
496
|
+
const promptParts = collapsed.map((m) =>
|
|
497
|
+
encodeMessage(
|
|
498
|
+
3,
|
|
499
|
+
encodeChatMessagePrompt(
|
|
500
|
+
normalizeContent(m.content),
|
|
501
|
+
SOURCE_BY_ROLE[m.role] ?? 1,
|
|
502
|
+
// Thread tool_call_id (for tool results) + tool_calls (for assistant
|
|
503
|
+
// turns that fired tools) into the proto. Cloud rejects multi-tool
|
|
504
|
+
// conversations otherwise — it can't pair a tool result with the
|
|
505
|
+
// assistant call that produced it.
|
|
506
|
+
{
|
|
507
|
+
toolCallId: m.role === 'tool' ? m.tool_call_id : undefined,
|
|
508
|
+
toolCalls: m.role === 'assistant' ? m.tool_calls : undefined,
|
|
509
|
+
},
|
|
510
|
+
),
|
|
511
|
+
),
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
const completion = encodeCompletionConfiguration(args.completionOpts ?? {});
|
|
515
|
+
|
|
516
|
+
const toolParts: Buffer[] = (args.tools ?? []).map((t) =>
|
|
517
|
+
encodeMessage(10, encodeToolDef(t)),
|
|
518
|
+
);
|
|
519
|
+
|
|
520
|
+
// Field layout from mitm capture of the LS:
|
|
521
|
+
// #1 metadata
|
|
522
|
+
// #3 chat_message_prompts (repeated — one element per history turn)
|
|
523
|
+
// #7 request_type (varint enum)
|
|
524
|
+
// #8 completion_configuration
|
|
525
|
+
// #10 tools (repeated ChatToolDefinition)
|
|
526
|
+
// #16 cascade_id (string)
|
|
527
|
+
// #21 chat_model_uid (string)
|
|
528
|
+
// #22 prompt_id (string)
|
|
529
|
+
return Buffer.concat([
|
|
530
|
+
encodeMessage(1, metadata),
|
|
531
|
+
...promptParts,
|
|
532
|
+
encodeVarintField(7, args.requestType ?? 5),
|
|
533
|
+
encodeMessage(8, completion),
|
|
534
|
+
...toolParts,
|
|
535
|
+
encodeString(16, args.cascadeId),
|
|
536
|
+
encodeString(21, args.modelUid),
|
|
537
|
+
encodeString(22, args.promptId),
|
|
538
|
+
]);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// ----------------------------------------------------------------------------
|
|
542
|
+
// Response parsing — pull `delta_text` (top-level field #9) out of each frame
|
|
543
|
+
// ----------------------------------------------------------------------------
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Decode a single streaming ChatMessage proto frame into one or more
|
|
547
|
+
* CloudChatEvents. Captured shape (from a tool-using swe-1.6 chat):
|
|
548
|
+
*
|
|
549
|
+
* ChatMessage {
|
|
550
|
+
* #1 bot_id (string)
|
|
551
|
+
* #2 timestamp { seconds, nanos }
|
|
552
|
+
* #5 finish_reason (varint — 10 = "tool_calls" observed, others unknown)
|
|
553
|
+
* #6 ToolCallDelta {
|
|
554
|
+
* #1 id (string, only on first tool-call frame)
|
|
555
|
+
* #2 name (string, only on first tool-call frame)
|
|
556
|
+
* #3 arguments_delta (string, JSON fragment, streamed)
|
|
557
|
+
* }
|
|
558
|
+
* #7 ChatStatus { #6 status_code, #9 model_name }
|
|
559
|
+
* #9 delta_text (string)
|
|
560
|
+
* #12 (fixed64) some_hash
|
|
561
|
+
* #17 (string) message_uuid
|
|
562
|
+
* #28 UsageStats { #1 label, ... }
|
|
563
|
+
* }
|
|
564
|
+
*
|
|
565
|
+
* #9 appears both at top-level (text delta) AND inside #7 (model_name).
|
|
566
|
+
* iterFields walks top-level only, so we don't confuse the two.
|
|
567
|
+
*
|
|
568
|
+
* #5 is the finish_reason. Observed value `10` = tool_calls finish. We map
|
|
569
|
+
* any non-zero to 'tool_calls' for now (and let the caller fall back to
|
|
570
|
+
* 'stop' if no tool_call deltas were emitted).
|
|
571
|
+
*/
|
|
572
|
+
function* decodeChatFrame(proto: Buffer): Generator<CloudChatEvent> {
|
|
573
|
+
for (const f of iterFields(proto)) {
|
|
574
|
+
if (f.num === 3 && f.wire === 2 && Buffer.isBuffer(f.value)) {
|
|
575
|
+
// Visible delta_text — what the user should SEE in the chat.
|
|
576
|
+
//
|
|
577
|
+
// We previously had this mapping inverted (#3 = thinking, #9 = visible),
|
|
578
|
+
// which produced two compounding bugs in the TUI:
|
|
579
|
+
// 1. The model's CoT was rendered as plain content, so the user saw
|
|
580
|
+
// "The user wants me to X..." instead of the answer.
|
|
581
|
+
// 2. The actual answer (which lives in #3) was silently dropped — so
|
|
582
|
+
// the assistant turn appeared to end after the CoT with nothing
|
|
583
|
+
// after, matching the "model wrote reasoning then went silent"
|
|
584
|
+
// symptom the user reported.
|
|
585
|
+
// Verified live: prompted swe-1.6 with "explain then answer 2+2"; #3
|
|
586
|
+
// streamed "2+2=4 because... 4" while #9 streamed the meta-narration
|
|
587
|
+
// "The user wants me to perform a reasoning task...".
|
|
588
|
+
const s = (f.value as Buffer).toString('utf8');
|
|
589
|
+
if (s) yield { kind: 'text', text: s };
|
|
590
|
+
} else if (f.num === 9 && f.wire === 2 && Buffer.isBuffer(f.value)) {
|
|
591
|
+
// Internal thinking / chain-of-thought. Surface as `reasoning` so
|
|
592
|
+
// @ai-sdk consumers (opencode TUI) render it in a collapsed grey
|
|
593
|
+
// block instead of inline with the answer.
|
|
594
|
+
const s = (f.value as Buffer).toString('utf8');
|
|
595
|
+
if (s) yield { kind: 'reasoning', text: s };
|
|
596
|
+
} else if (f.num === 6 && f.wire === 2 && Buffer.isBuffer(f.value)) {
|
|
597
|
+
let id: string | undefined;
|
|
598
|
+
let name: string | undefined;
|
|
599
|
+
let argsDelta: string | undefined;
|
|
600
|
+
for (const sf of iterFields(f.value as Buffer)) {
|
|
601
|
+
if (sf.wire === 2 && Buffer.isBuffer(sf.value)) {
|
|
602
|
+
const s = (sf.value as Buffer).toString('utf8');
|
|
603
|
+
if (sf.num === 1) id = s;
|
|
604
|
+
else if (sf.num === 2) name = s;
|
|
605
|
+
else if (sf.num === 3) argsDelta = s;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
if (id !== undefined && name !== undefined) {
|
|
609
|
+
yield { kind: 'tool_call_start', id, name };
|
|
610
|
+
}
|
|
611
|
+
if (argsDelta !== undefined) {
|
|
612
|
+
// Pass through `id` when this frame carries one (Cognition only
|
|
613
|
+
// sets it on the start frame today, but defending against future
|
|
614
|
+
// interleaving). Callers should prefer `id` over their rolling
|
|
615
|
+
// lastToolCallId when both are available.
|
|
616
|
+
yield { kind: 'tool_call_args', argsDelta, ...(id !== undefined ? { id } : {}) };
|
|
617
|
+
}
|
|
618
|
+
} else if (f.num === 5 && f.wire === 0) {
|
|
619
|
+
const v = Number(f.value);
|
|
620
|
+
// exa.codeium_common_pb.StopReason → OpenAI finish_reason.
|
|
621
|
+
// Source of truth: Windsurf extension.js sets `setEnumType("StopReason", [...])`
|
|
622
|
+
// 0 UNSPECIFIED → "stop" (no signal — treat as natural end)
|
|
623
|
+
// 1 INCOMPLETE → "length" (request cut short, model wanted more)
|
|
624
|
+
// 2 STOP_PATTERN → "stop" (model emitted its stop sequence — NORMAL)
|
|
625
|
+
// 3 MAX_TOKENS → "length"
|
|
626
|
+
// 4-9 internal → "stop"
|
|
627
|
+
// 10 FUNCTION_CALL → "tool_calls"
|
|
628
|
+
// 11 CONTENT_FILTER → "content_filter"
|
|
629
|
+
// 12 NON_INSERTION → "stop"
|
|
630
|
+
// 13 ERROR → "stop" (errors come as Connect trailer, not via this)
|
|
631
|
+
//
|
|
632
|
+
// We had 2 and 3 swapped previously, which made the model's normal
|
|
633
|
+
// STOP_PATTERN look like "length" → @ai-sdk treated complete responses
|
|
634
|
+
// as truncated. That was the "model wrote reasoning then went silent"
|
|
635
|
+
// symptom the user kept hitting.
|
|
636
|
+
let reason: 'stop' | 'tool_calls' | 'length' | 'content_filter' = 'stop';
|
|
637
|
+
if (v === 10) reason = 'tool_calls';
|
|
638
|
+
else if (v === 11) reason = 'content_filter';
|
|
639
|
+
else if (v === 1 || v === 3) reason = 'length';
|
|
640
|
+
// else stays 'stop' for 0/2/4-9/12/13
|
|
641
|
+
yield { kind: 'finish', reason };
|
|
642
|
+
} else if (f.num === 28 && f.wire === 2 && Buffer.isBuffer(f.value)) {
|
|
643
|
+
const usage = decodeUsageBlock(f.value as Buffer);
|
|
644
|
+
if (usage) yield usage;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* UsageStats block at proto field #28. Captured shape (mitm of a real call):
|
|
651
|
+
*
|
|
652
|
+
* UsageStats {
|
|
653
|
+
* #1 label = "Token Usage"
|
|
654
|
+
* #2 entries [
|
|
655
|
+
* UsageEntry {
|
|
656
|
+
* #1 label = "Input tokens" / "Output tokens" / "Cached tokens" / ...
|
|
657
|
+
* #2 value (fixed32 — IEEE 754 float, OpenAI-style count cast)
|
|
658
|
+
* #3 unit = " tokens"
|
|
659
|
+
* #5 metric_id = "input_tokens" / "output_tokens" / ...
|
|
660
|
+
* },
|
|
661
|
+
* ...
|
|
662
|
+
* ]
|
|
663
|
+
* }
|
|
664
|
+
*
|
|
665
|
+
* We extract the standard input/output counts and synthesize a `total`.
|
|
666
|
+
* Anything else (cached, reasoning_tokens, …) is dropped for v1.
|
|
667
|
+
*/
|
|
668
|
+
function decodeUsageBlock(buf: Buffer): CloudChatEvent | null {
|
|
669
|
+
let promptTokens: number | undefined;
|
|
670
|
+
let completionTokens: number | undefined;
|
|
671
|
+
let cachedInputTokens: number | undefined;
|
|
672
|
+
let cacheCreationInputTokens: number | undefined;
|
|
673
|
+
let reasoningTokens: number | undefined;
|
|
674
|
+
|
|
675
|
+
for (const f of iterFields(buf)) {
|
|
676
|
+
// Each UsageEntry lives at field 2 (repeated). Field 1 is the block label
|
|
677
|
+
// ("Token Usage"); skip.
|
|
678
|
+
if (f.num !== 2 || f.wire !== 2 || !Buffer.isBuffer(f.value)) continue;
|
|
679
|
+
|
|
680
|
+
// Observed entry shape:
|
|
681
|
+
// UsageEntry {
|
|
682
|
+
// #4 (sub-message) {
|
|
683
|
+
// #1 label = "Input tokens" / "Output tokens"
|
|
684
|
+
// #2 (fixed32) value (IEEE 754 LE float — count as float)
|
|
685
|
+
// #3 unit = " token"
|
|
686
|
+
// #4 unit_plural = " tokens"
|
|
687
|
+
// }
|
|
688
|
+
// #5 metric_id = "input_tokens" / "output_tokens" / "cached_input_tokens" / ...
|
|
689
|
+
// }
|
|
690
|
+
let entryMetric: string | undefined;
|
|
691
|
+
let entryValue: number | undefined;
|
|
692
|
+
for (const sf of iterFields(f.value as Buffer)) {
|
|
693
|
+
if (sf.num === 5 && sf.wire === 2 && Buffer.isBuffer(sf.value)) {
|
|
694
|
+
entryMetric = (sf.value as Buffer).toString('utf8');
|
|
695
|
+
} else if (sf.num === 4 && sf.wire === 2 && Buffer.isBuffer(sf.value)) {
|
|
696
|
+
// Recurse into the displayed-dimension submessage to pull the fixed32
|
|
697
|
+
// value at its field 2.
|
|
698
|
+
for (const ssf of iterFields(sf.value as Buffer)) {
|
|
699
|
+
if (ssf.num === 2 && ssf.wire === 5 && Buffer.isBuffer(ssf.value)) {
|
|
700
|
+
entryValue = (ssf.value as Buffer).readFloatLE(0);
|
|
701
|
+
break;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
if (entryMetric && entryValue !== undefined && Number.isFinite(entryValue)) {
|
|
707
|
+
const n = Math.round(entryValue);
|
|
708
|
+
if (entryMetric === 'input_tokens') promptTokens = n;
|
|
709
|
+
else if (entryMetric === 'output_tokens') completionTokens = n;
|
|
710
|
+
else if (entryMetric === 'cached_input_tokens' || entryMetric === 'cache_read_input_tokens') {
|
|
711
|
+
cachedInputTokens = (cachedInputTokens ?? 0) + n;
|
|
712
|
+
} else if (entryMetric === 'cache_creation_input_tokens') {
|
|
713
|
+
cacheCreationInputTokens = (cacheCreationInputTokens ?? 0) + n;
|
|
714
|
+
} else if (entryMetric === 'reasoning_tokens' || entryMetric === 'output_reasoning_tokens') {
|
|
715
|
+
reasoningTokens = (reasoningTokens ?? 0) + n;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
if (promptTokens === undefined && completionTokens === undefined) return null;
|
|
720
|
+
// totalTokens reflects what OpenAI's API counts as billable: input +
|
|
721
|
+
// output. Cached / cache-creation / reasoning subtotals are surfaced as
|
|
722
|
+
// additional fields so callers that want a fuller picture (e.g. cost
|
|
723
|
+
// breakdown for reasoning models) can read them, but they're NOT
|
|
724
|
+
// double-counted into total.
|
|
725
|
+
const total = (promptTokens ?? 0) + (completionTokens ?? 0);
|
|
726
|
+
return {
|
|
727
|
+
kind: 'usage',
|
|
728
|
+
promptTokens,
|
|
729
|
+
completionTokens,
|
|
730
|
+
totalTokens: total > 0 ? total : undefined,
|
|
731
|
+
cachedInputTokens,
|
|
732
|
+
cacheCreationInputTokens,
|
|
733
|
+
reasoningTokens,
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// ----------------------------------------------------------------------------
|
|
738
|
+
// Public API: streamChat
|
|
739
|
+
// ----------------------------------------------------------------------------
|
|
740
|
+
|
|
741
|
+
export interface CloudChatRequest {
|
|
742
|
+
/** Persistent OAuth-issued api_key (`devin-session-token$<JWT>`). */
|
|
743
|
+
apiKey: string;
|
|
744
|
+
/** Pre-resolved API server URL from RegisterUser (falls back to default). */
|
|
745
|
+
apiServerUrl?: string;
|
|
746
|
+
/** Model UID — e.g. `swe-1-6`, `kimi-k2-6`, `claude-opus-4-7-medium`. */
|
|
747
|
+
modelUid: string;
|
|
748
|
+
/** Chat history. */
|
|
749
|
+
messages: ChatHistoryItem[];
|
|
750
|
+
/**
|
|
751
|
+
* Tool definitions available to the model. Cloud encodes these in the
|
|
752
|
+
* GetChatMessage request's `tools` field (proto #10). When set, the model
|
|
753
|
+
* may emit `tool_call_start`/`_args`/`_end` events instead of plain text.
|
|
754
|
+
*/
|
|
755
|
+
tools?: ToolDef[];
|
|
756
|
+
/** Cascade ID — reuse across turns of the same conversation. */
|
|
757
|
+
cascadeId?: string;
|
|
758
|
+
/** Optional sampling overrides. */
|
|
759
|
+
completionOpts?: BuildArgs['completionOpts'];
|
|
760
|
+
/** Override request_type (default = 5, CASCADE). */
|
|
761
|
+
requestType?: number;
|
|
762
|
+
/** Abort signal — closes the fetch stream. */
|
|
763
|
+
signal?: AbortSignal;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
export class CloudChatError extends Error {
|
|
767
|
+
constructor(message: string, public readonly code?: string, public readonly traceId?: string) {
|
|
768
|
+
super(message);
|
|
769
|
+
this.name = 'CloudChatError';
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const TRACE_ID_RE = /\(trace ID: ([0-9a-f]+)\)/i;
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Stream chat events from the cloud. Yields CloudChatEvent (text deltas, tool
|
|
777
|
+
* call deltas, finish reason). Use `streamChatText` for legacy text-only iteration.
|
|
778
|
+
*
|
|
779
|
+
* On error (auth fail, quota exhausted, malformed request) throws a
|
|
780
|
+
* CloudChatError with the cloud's `code` + `traceId` for diagnostics.
|
|
781
|
+
*/
|
|
782
|
+
export async function* streamChatEvents(req: CloudChatRequest): AsyncGenerator<CloudChatEvent> {
|
|
783
|
+
const host = (req.apiServerUrl ?? 'https://server.codeium.com').replace(/\/$/, '');
|
|
784
|
+
const userJwt = await getCachedUserJwt(req.apiKey, host, req.signal);
|
|
785
|
+
|
|
786
|
+
// Pre-flight: consult the per-account model catalog. Cognition's cloud
|
|
787
|
+
// returns an opaque `permission_denied: "an internal error occurred (trace
|
|
788
|
+
// ID: ...)"` for every chat call that targets a model not enabled on the
|
|
789
|
+
// caller's tier — issue #14. The catalog's `disabled` flag is the
|
|
790
|
+
// authoritative source for "can this account run this UID"; we surface a
|
|
791
|
+
// named error here so the user knows why instead of guessing.
|
|
792
|
+
//
|
|
793
|
+
// Best-effort: if the catalog fetch fails (network, auth, schema drift) we
|
|
794
|
+
// pass through to the chat call. The cloud will still surface its own
|
|
795
|
+
// error and the trailer-error path below enriches the message in-place.
|
|
796
|
+
const catalog = await getCachedCatalog(req.apiKey, host, req.signal).catch(() => null);
|
|
797
|
+
if (catalog) {
|
|
798
|
+
const entry = catalog.byUid.get(req.modelUid);
|
|
799
|
+
if (!entry) {
|
|
800
|
+
throw new ModelNotAvailableError(req.modelUid, req.modelUid, 'not_listed');
|
|
801
|
+
}
|
|
802
|
+
if (entry.disabled) {
|
|
803
|
+
throw new ModelNotAvailableError(req.modelUid, entry.label, 'disabled');
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// Reuse session + cascade ids across calls for the same (apiKey, host).
|
|
808
|
+
// Without this, every turn looks like a brand-new server-side session
|
|
809
|
+
// and the cloud's prompt cache never hits — significant cost regression
|
|
810
|
+
// for long conversations.
|
|
811
|
+
const sessionIds = getOrAllocateSessionIds(req.apiKey, host, req.cascadeId);
|
|
812
|
+
|
|
813
|
+
const proto = buildGetChatMessageRequest({
|
|
814
|
+
apiKey: req.apiKey,
|
|
815
|
+
userJwt,
|
|
816
|
+
modelUid: req.modelUid,
|
|
817
|
+
messages: req.messages,
|
|
818
|
+
tools: req.tools,
|
|
819
|
+
cascadeId: sessionIds.cascadeId,
|
|
820
|
+
promptId: crypto.randomUUID(),
|
|
821
|
+
sessionId: sessionIds.sessionId,
|
|
822
|
+
requestId: BigInt(Date.now()),
|
|
823
|
+
triggerId: crypto.randomUUID(),
|
|
824
|
+
requestType: req.requestType,
|
|
825
|
+
completionOpts: req.completionOpts,
|
|
826
|
+
});
|
|
827
|
+
const body = frameConnectStream(proto, true);
|
|
828
|
+
|
|
829
|
+
// Compose caller signal with a TTFB timeout. If the cloud takes longer
|
|
830
|
+
// than CLOUD_STREAM_TTFB_MS to start the response, abort. Once any byte
|
|
831
|
+
// arrives we cancel the TTFB timer and start the per-chunk idle timer
|
|
832
|
+
// inside the read loop instead.
|
|
833
|
+
const ttfbController = new AbortController();
|
|
834
|
+
const ttfbTimer = setTimeout(() => ttfbController.abort(new Error(`cloud-direct: time-to-first-byte timeout (${CLOUD_STREAM_TTFB_MS}ms)`)), CLOUD_STREAM_TTFB_MS);
|
|
835
|
+
const ttfbSignal = ttfbController.signal;
|
|
836
|
+
// Compose req.signal + ttfbSignal. AbortSignal.any was added in Node
|
|
837
|
+
// 20.3 / Bun 1.0; our `engines` allows Node ≥18, so on Node 18-20.2 the
|
|
838
|
+
// built-in is missing. The previous fallback `req.signal ?? ttfbSignal`
|
|
839
|
+
// silently discarded one of the two signals (TTFB if caller passed
|
|
840
|
+
// one), defeating the timeout guard. anySignal() is a real polyfill.
|
|
841
|
+
const initialSignal: AbortSignal = req.signal ? anySignal([req.signal, ttfbSignal]) : ttfbSignal;
|
|
842
|
+
|
|
843
|
+
let resp: Response;
|
|
844
|
+
try {
|
|
845
|
+
resp = await fetch(`${host}/exa.api_server_pb.ApiServerService/GetChatMessage`, {
|
|
846
|
+
method: 'POST',
|
|
847
|
+
headers: {
|
|
848
|
+
'Content-Type': 'application/connect+proto',
|
|
849
|
+
'Connect-Protocol-Version': '1',
|
|
850
|
+
'Connect-Content-Encoding': 'gzip',
|
|
851
|
+
'Connect-Accept-Encoding': 'gzip',
|
|
852
|
+
},
|
|
853
|
+
body,
|
|
854
|
+
signal: initialSignal,
|
|
855
|
+
});
|
|
856
|
+
} finally {
|
|
857
|
+
clearTimeout(ttfbTimer);
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
if (!resp.ok) {
|
|
861
|
+
const text = await resp.text();
|
|
862
|
+
throw new CloudChatError(`GetChatMessage HTTP ${resp.status}: ${text.slice(0, 300)}`, undefined);
|
|
863
|
+
}
|
|
864
|
+
if (!resp.body) {
|
|
865
|
+
throw new CloudChatError('GetChatMessage response had no body stream');
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// Incremental parsing. We previously did `pending = Buffer.concat([pending,
|
|
869
|
+
// chunk])` per chunk — O(n²) over a long stream because every chunk copies
|
|
870
|
+
// every buffered byte again. Now we keep a queue of arriving chunks with a
|
|
871
|
+
// running offset; we only `Buffer.concat` when a frame straddles a chunk
|
|
872
|
+
// boundary, and we slice/drop fully-consumed chunks immediately. For
|
|
873
|
+
// typical 50-200KB responses this is ~5x faster and produces zero waste.
|
|
874
|
+
const chunkQueue: Buffer[] = [];
|
|
875
|
+
let queuedBytes = 0;
|
|
876
|
+
// Bun + Node ReadableStream readers diverge on the type-level shape
|
|
877
|
+
// (Bun's includes a `readMany` method); both work the same at runtime.
|
|
878
|
+
const reader = resp.body.getReader() as ReadableStreamDefaultReader<Uint8Array>;
|
|
879
|
+
let trailerError: { code?: string; message: string; traceId?: string } | null = null;
|
|
880
|
+
let sawEos = false;
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* Try to read the next `n` bytes from the chunk queue WITHOUT consuming
|
|
884
|
+
* them. Returns null if not enough buffered.
|
|
885
|
+
*/
|
|
886
|
+
function peek(n: number): Buffer | null {
|
|
887
|
+
if (queuedBytes < n) return null;
|
|
888
|
+
if (chunkQueue.length === 1 && chunkQueue[0].length >= n) {
|
|
889
|
+
return chunkQueue[0].slice(0, n);
|
|
890
|
+
}
|
|
891
|
+
// Cross-chunk peek — concat just the prefix we need.
|
|
892
|
+
const parts: Buffer[] = [];
|
|
893
|
+
let remaining = n;
|
|
894
|
+
for (const c of chunkQueue) {
|
|
895
|
+
if (remaining <= 0) break;
|
|
896
|
+
if (c.length <= remaining) {
|
|
897
|
+
parts.push(c);
|
|
898
|
+
remaining -= c.length;
|
|
899
|
+
} else {
|
|
900
|
+
parts.push(c.slice(0, remaining));
|
|
901
|
+
remaining = 0;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
return Buffer.concat(parts, n);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/** Drop the first `n` bytes from the chunk queue. */
|
|
908
|
+
function drop(n: number): void {
|
|
909
|
+
queuedBytes -= n;
|
|
910
|
+
let remaining = n;
|
|
911
|
+
while (remaining > 0 && chunkQueue.length > 0) {
|
|
912
|
+
const head = chunkQueue[0];
|
|
913
|
+
if (head.length <= remaining) {
|
|
914
|
+
chunkQueue.shift();
|
|
915
|
+
remaining -= head.length;
|
|
916
|
+
} else {
|
|
917
|
+
chunkQueue[0] = head.slice(remaining);
|
|
918
|
+
remaining = 0;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// Track the idle timer at outer scope so the finally block can clear it
|
|
924
|
+
// regardless of how we exit the read loop (clean done, throw, etc).
|
|
925
|
+
// Previously this lived inside `try { ... }` and was only cleared on
|
|
926
|
+
// normal exit — an error path left a 120s timer in the event loop and
|
|
927
|
+
// the process refused to exit promptly.
|
|
928
|
+
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
929
|
+
try {
|
|
930
|
+
const resetIdle = (): Promise<{ value?: Uint8Array; done: boolean }> => {
|
|
931
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
932
|
+
const idleController = new AbortController();
|
|
933
|
+
idleTimer = setTimeout(
|
|
934
|
+
() => idleController.abort(new Error(`cloud-direct: idle timeout (${CLOUD_STREAM_IDLE_MS}ms with no bytes)`)),
|
|
935
|
+
CLOUD_STREAM_IDLE_MS,
|
|
936
|
+
);
|
|
937
|
+
// Race the reader.read() against idle abort. When abort wins, we
|
|
938
|
+
// also actively `cancel()` the underlying body stream so the
|
|
939
|
+
// pending read() resolves promptly with done=true instead of
|
|
940
|
+
// hanging on the now-dead TCP socket until the OS notices.
|
|
941
|
+
//
|
|
942
|
+
// Promise-handling carefully: the reader.read() promise can settle
|
|
943
|
+
// AFTER the outer race rejects (we cancelled, the read eventually
|
|
944
|
+
// sees the cancellation and either resolves with done=true or
|
|
945
|
+
// rejects with an abort error). We attach an explicit `.catch(()=>{})`
|
|
946
|
+
// on the read promise so any post-race rejection doesn't surface as
|
|
947
|
+
// an unhandled-rejection warning in the host runtime.
|
|
948
|
+
return new Promise((resolve, reject) => {
|
|
949
|
+
let settled = false;
|
|
950
|
+
const settle = (fn: () => void): void => {
|
|
951
|
+
if (settled) return;
|
|
952
|
+
settled = true;
|
|
953
|
+
fn();
|
|
954
|
+
};
|
|
955
|
+
const readP = reader.read();
|
|
956
|
+
// Defensive: swallow any post-race rejection. If the outer promise
|
|
957
|
+
// already settled via the abort listener, we still need a handler
|
|
958
|
+
// attached to readP or Node logs an unhandledRejection.
|
|
959
|
+
readP.catch(() => { /* swallowed; outer promise already rejected */ });
|
|
960
|
+
|
|
961
|
+
idleController.signal.addEventListener('abort', () => {
|
|
962
|
+
try { void resp.body?.cancel(idleController.signal.reason ?? new Error('idle abort')); } catch { /* */ }
|
|
963
|
+
settle(() => reject(idleController.signal.reason ?? new Error('idle abort')));
|
|
964
|
+
}, { once: true });
|
|
965
|
+
|
|
966
|
+
readP.then(
|
|
967
|
+
(v) => settle(() => resolve(v)),
|
|
968
|
+
(e) => settle(() => reject(e)),
|
|
969
|
+
);
|
|
970
|
+
});
|
|
971
|
+
};
|
|
972
|
+
|
|
973
|
+
while (true) {
|
|
974
|
+
const { value, done } = await resetIdle();
|
|
975
|
+
if (done) break;
|
|
976
|
+
if (value) {
|
|
977
|
+
chunkQueue.push(Buffer.from(value));
|
|
978
|
+
queuedBytes += value.length;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// Drain every complete frame currently buffered.
|
|
982
|
+
while (queuedBytes >= 5) {
|
|
983
|
+
const header = peek(5);
|
|
984
|
+
if (!header) break;
|
|
985
|
+
const flags = header[0];
|
|
986
|
+
const len = header.readUInt32BE(1);
|
|
987
|
+
if (queuedBytes < 5 + len) break; // frame still arriving
|
|
988
|
+
drop(5);
|
|
989
|
+
const raw = peek(len) ?? Buffer.alloc(0);
|
|
990
|
+
drop(len);
|
|
991
|
+
|
|
992
|
+
let payload = raw;
|
|
993
|
+
if (flags & 0x01) {
|
|
994
|
+
try {
|
|
995
|
+
payload = zlib.gunzipSync(raw);
|
|
996
|
+
} catch (gzipErr) {
|
|
997
|
+
// Corrupt compressed frame — surface as a CloudChatError instead
|
|
998
|
+
// of falling through and re-parsing raw gzip bytes as proto
|
|
999
|
+
// (which used to misparse silently downstream).
|
|
1000
|
+
throw new CloudChatError(`Connect frame gunzip failed: ${(gzipErr as Error).message}`);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
const eos = (flags & 0x02) !== 0;
|
|
1004
|
+
|
|
1005
|
+
if (eos) {
|
|
1006
|
+
sawEos = true;
|
|
1007
|
+
// Trailer: {} on success, {"error":{code,message}} on failure.
|
|
1008
|
+
const text = payload.toString('utf8');
|
|
1009
|
+
if (text && text.includes('"error"')) {
|
|
1010
|
+
let code: string | undefined;
|
|
1011
|
+
let message = text;
|
|
1012
|
+
try {
|
|
1013
|
+
const j = JSON.parse(text) as { error?: { code?: string; message?: string } };
|
|
1014
|
+
code = j.error?.code;
|
|
1015
|
+
if (j.error?.message) message = j.error.message;
|
|
1016
|
+
} catch { /* keep raw */ }
|
|
1017
|
+
const traceMatch = message.match(TRACE_ID_RE);
|
|
1018
|
+
trailerError = { code, message, traceId: traceMatch?.[1] };
|
|
1019
|
+
}
|
|
1020
|
+
continue;
|
|
1021
|
+
}
|
|
1022
|
+
yield* decodeChatFrame(payload);
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
} finally {
|
|
1026
|
+
// Always clear the idle timer. The previous "clear on normal exit
|
|
1027
|
+
// only" path leaked a 120s setTimeout into the event loop on any
|
|
1028
|
+
// throw (idle timeout, gunzip error, trailer error, etc), keeping
|
|
1029
|
+
// the process from exiting promptly.
|
|
1030
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
1031
|
+
// Cancel the underlying body stream on any non-clean exit so the TCP
|
|
1032
|
+
// connection is released. `releaseLock` alone leaves the body in a
|
|
1033
|
+
// dangling state; we have to call `cancel` on the response body
|
|
1034
|
+
// itself (cancel-via-reader requires holding the lock). Fire and
|
|
1035
|
+
// forget — there's nothing meaningful to do if cancel rejects.
|
|
1036
|
+
try { reader.releaseLock(); } catch { /* */ }
|
|
1037
|
+
try { void resp.body?.cancel(); } catch { /* */ }
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
if (trailerError) {
|
|
1041
|
+
// Cognition uses `permission_denied: "an internal error occurred (trace
|
|
1042
|
+
// ID: …)"` as a catch-all for "your account can't run this model" — same
|
|
1043
|
+
// root cause issue #14 reported. The pre-flight above catches this when
|
|
1044
|
+
// the catalog disagrees with the call, but the catalog can lag (a model
|
|
1045
|
+
// that was enabled at fetch time may have been gated between then and
|
|
1046
|
+
// now) or be missing (network failure caused a fall-through). When the
|
|
1047
|
+
// raw trailer is this exact shape, swap in a message that names the
|
|
1048
|
+
// model and explains the likely cause rather than re-passing
|
|
1049
|
+
// Cognition's opaque text. The cloud's original message is appended in
|
|
1050
|
+
// parens so users (and bug reports) still have it verbatim.
|
|
1051
|
+
const isOpaquePermissionDenial =
|
|
1052
|
+
trailerError.code === 'permission_denied' &&
|
|
1053
|
+
/an internal error occurred/i.test(trailerError.message);
|
|
1054
|
+
if (isOpaquePermissionDenial) {
|
|
1055
|
+
const enriched =
|
|
1056
|
+
`Cognition denied this request for model "${req.modelUid}" with the opaque ` +
|
|
1057
|
+
`"an internal error occurred" message. This almost always means the model ` +
|
|
1058
|
+
`is not enabled for your account/tier — see https://codeium.com/account. ` +
|
|
1059
|
+
`(cloud trace ID: ${trailerError.traceId ?? 'n/a'}; raw message: ${trailerError.message})`;
|
|
1060
|
+
throw new CloudChatError(enriched, trailerError.code, trailerError.traceId);
|
|
1061
|
+
}
|
|
1062
|
+
throw new CloudChatError(trailerError.message, trailerError.code, trailerError.traceId);
|
|
1063
|
+
}
|
|
1064
|
+
// Truncation detection: the cloud always terminates a successful stream
|
|
1065
|
+
// with an EOS trailer. If we hit `done` from the body reader without one,
|
|
1066
|
+
// the connection dropped mid-frame and any bytes still in the queue are
|
|
1067
|
+
// garbage. Previously those leftover bytes were silently discarded and
|
|
1068
|
+
// the consumer saw a clean stop with no error — looked like the model
|
|
1069
|
+
// had finished. Now we surface it.
|
|
1070
|
+
if (!sawEos) {
|
|
1071
|
+
throw new CloudChatError(
|
|
1072
|
+
`Cloud stream ended without EOS trailer (${queuedBytes} bytes orphaned). ` +
|
|
1073
|
+
`Connection likely dropped mid-response.`,
|
|
1074
|
+
'truncated_stream',
|
|
1075
|
+
);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
/**
|
|
1080
|
+
* Back-compat: yield text content only (drops tool calls). The plugin uses
|
|
1081
|
+
* streamChatEvents directly when it needs to surface tool_calls.
|
|
1082
|
+
*/
|
|
1083
|
+
export async function* streamChat(req: CloudChatRequest): AsyncGenerator<string> {
|
|
1084
|
+
for await (const ev of streamChatEvents(req)) {
|
|
1085
|
+
if (ev.kind === 'text') yield ev.text;
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
// `parseConnectFrames` is no longer needed by streamChat itself, but exported
|
|
1090
|
+
// from wire.ts for one-shot callers + tests.
|
|
1091
|
+
void parseConnectFrames;
|