pi-windsurf-beta 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/chat.ts ADDED
@@ -0,0 +1,501 @@
1
+ /**
2
+ * Cloud-direct streaming chat. Translates OpenAI chat requests → Cognition Connect-RPC
3
+ * GetChatMessage wire format, streams responses back as SSE events.
4
+ */
5
+ import * as crypto from "crypto";
6
+ import * as zlib from "zlib";
7
+ import {
8
+ encodeMessage,
9
+ encodeString,
10
+ encodeVarintField,
11
+ frameConnectStream,
12
+ iterFields,
13
+ } from "./wire";
14
+ import { buildMetadata } from "./metadata";
15
+ import { getCachedUserJwt } from "./auth";
16
+
17
+ // ----------------------------------------------------------------------------
18
+ // Types
19
+ // ----------------------------------------------------------------------------
20
+
21
+ const CLOUD_STREAM_IDLE_MS = 120_000;
22
+ const CLOUD_STREAM_TTFB_MS = 60_000;
23
+
24
+ export type ContentPart =
25
+ | { type: "text"; text: string }
26
+ | { type: "image"; mimeType: string; base64Data: string; caption?: string };
27
+
28
+ export interface ChatHistoryItem {
29
+ role: "user" | "assistant" | "system" | "tool";
30
+ content: string | ContentPart[];
31
+ tool_call_id?: string;
32
+ tool_calls?: Array<{ id: string; name: string; arguments: string }>;
33
+ }
34
+
35
+ export interface ToolDef {
36
+ name: string;
37
+ description: string;
38
+ parameters: unknown;
39
+ }
40
+
41
+ export type CloudChatEvent =
42
+ | { kind: "text"; text: string }
43
+ | { kind: "reasoning"; text: string }
44
+ | { kind: "tool_call_start"; id: string; name: string }
45
+ | { kind: "tool_call_args"; argsDelta: string; id?: string }
46
+ | { kind: "finish"; reason: "stop" | "tool_calls" | "length" | "content_filter" }
47
+ | { kind: "usage"; promptTokens?: number; completionTokens?: number; totalTokens?: number;
48
+ cachedInputTokens?: number; cacheCreationInputTokens?: number; reasoningTokens?: number; };
49
+
50
+ export interface CloudChatRequest {
51
+ apiKey: string;
52
+ apiServerUrl?: string;
53
+ modelUid: string;
54
+ messages: ChatHistoryItem[];
55
+ tools?: ToolDef[];
56
+ cascadeId?: string;
57
+ completionOpts?: { maxOutputTokens?: number; maxInputTokens?: number; temperature?: number; topK?: number; topP?: number; };
58
+ requestType?: number;
59
+ signal?: AbortSignal;
60
+ }
61
+
62
+ export class CloudChatError extends Error {
63
+ constructor(message: string, public readonly code?: string, public readonly traceId?: string) {
64
+ super(message);
65
+ this.name = "CloudChatError";
66
+ }
67
+ }
68
+
69
+ // ----------------------------------------------------------------------------
70
+ // Helpers
71
+ // ----------------------------------------------------------------------------
72
+
73
+ function anySignal(signals: AbortSignal[]): AbortSignal {
74
+ const builtin = (AbortSignal as unknown as { any?: (s: AbortSignal[]) => AbortSignal }).any;
75
+ if (typeof builtin === "function") return builtin(signals);
76
+ const controller = new AbortController();
77
+ const onAbort = (reason: unknown): void => {
78
+ if (!controller.signal.aborted) controller.abort(reason);
79
+ };
80
+ for (const s of signals) {
81
+ if (s.aborted) { onAbort(s.reason); break; }
82
+ s.addEventListener("abort", () => onAbort(s.reason), { once: true });
83
+ }
84
+ return controller.signal;
85
+ }
86
+
87
+ // Session/cascade ID cache
88
+ interface SessionIds { sessionId: string; cascadeId: string; }
89
+ const sessionCache = new Map<string, SessionIds>();
90
+
91
+ function getOrAllocateSessionIds(apiKey: string, host: string, cascadeIdOverride?: string): SessionIds {
92
+ const key = `${host}\x1f${apiKey}`;
93
+ let ids = sessionCache.get(key);
94
+ if (!ids) {
95
+ ids = { sessionId: crypto.randomUUID(), cascadeId: cascadeIdOverride ?? crypto.randomUUID() };
96
+ sessionCache.set(key, ids);
97
+ } else if (cascadeIdOverride && ids.cascadeId !== cascadeIdOverride) {
98
+ ids = { sessionId: ids.sessionId, cascadeId: cascadeIdOverride };
99
+ sessionCache.set(key, ids);
100
+ }
101
+ return ids;
102
+ }
103
+
104
+ export function clearSessionIds(): void { sessionCache.clear(); }
105
+
106
+ // ----------------------------------------------------------------------------
107
+ // Content normalization
108
+ // ----------------------------------------------------------------------------
109
+
110
+ function normalizeContent(content: string | ContentPart[] | unknown): ContentPart[] {
111
+ if (typeof content === "string") return [{ type: "text", text: content }];
112
+ if (!Array.isArray(content)) return [];
113
+ const out: ContentPart[] = [];
114
+ const parts = content as Array<Record<string, unknown>>;
115
+ for (const p of parts) {
116
+ if (!p || typeof p !== "object") continue;
117
+ if (p.type === "text" && typeof p.text === "string") {
118
+ out.push({ type: "text", text: p.text });
119
+ } else if (p.type === "image" && typeof p.base64Data === "string") {
120
+ out.push({ type: "image", mimeType: (typeof p.mimeType === "string" ? p.mimeType : "image/png"), base64Data: p.base64Data, caption: typeof p.caption === "string" ? p.caption : undefined });
121
+ } else if (p.type === "image_url" && p.image_url) {
122
+ const imgRef = p.image_url as string | { url?: string };
123
+ const url: string = typeof imgRef === "string" ? imgRef : (imgRef.url ?? "");
124
+ const m = url.match(/^data:([^;]+);base64,(.+)$/);
125
+ if (m) out.push({ type: "image", mimeType: m[1], base64Data: m[2] });
126
+ else if (url) out.push({ type: "text", text: `[image url: ${url}]` });
127
+ }
128
+ }
129
+ return out;
130
+ }
131
+
132
+ function collapseSystemIntoUser(messages: ChatHistoryItem[]): ChatHistoryItem[] {
133
+ const out: ChatHistoryItem[] = [];
134
+ let pendingSystem: string[] = [];
135
+ const flushText = (c: ContentPart[]): string =>
136
+ c.filter((p): p is { type: "text"; text: string } => p.type === "text").map(p => p.text).join("\n");
137
+
138
+ for (const m of messages) {
139
+ if (m.role === "system") {
140
+ const parts = normalizeContent(m.content);
141
+ const text = flushText(parts);
142
+ if (text) pendingSystem.push(text);
143
+ } else if (m.role === "user" && pendingSystem.length > 0) {
144
+ const userParts = normalizeContent(m.content);
145
+ const userText = flushText(userParts);
146
+ const userImages = userParts.filter(p => p.type === "image");
147
+ const wrapped = `<system>\n${pendingSystem.join("\n\n")}\n</system>\n${userText}`;
148
+ out.push({ role: "user", content: [{ type: "text", text: wrapped }, ...userImages] });
149
+ pendingSystem = [];
150
+ } else {
151
+ out.push(m);
152
+ }
153
+ }
154
+ if (pendingSystem.length > 0) {
155
+ out.push({ role: "user", content: [{ type: "text", text: `<system>\n${pendingSystem.join("\n\n")}\n</system>` }] });
156
+ }
157
+ return out;
158
+ }
159
+
160
+ // ----------------------------------------------------------------------------
161
+ // Proto encoders
162
+ // ----------------------------------------------------------------------------
163
+
164
+ const MAX_TOOL_DESC_LEN = 6998;
165
+
166
+ function encodeToolDef(tool: ToolDef): Buffer {
167
+ const rawDesc = tool.description ?? "";
168
+ const desc = rawDesc.length > MAX_TOOL_DESC_LEN
169
+ ? rawDesc.slice(0, MAX_TOOL_DESC_LEN - 24) + "\n…(truncated for cloud)"
170
+ : rawDesc;
171
+ return Buffer.concat([
172
+ encodeString(1, tool.name),
173
+ encodeString(2, desc),
174
+ encodeString(3, JSON.stringify(tool.parameters ?? {})),
175
+ ]);
176
+ }
177
+
178
+ function encodeImageData(img: { mimeType: string; base64Data: string; caption?: string }): Buffer {
179
+ const parts: Buffer[] = [encodeString(1, img.base64Data), encodeString(2, img.mimeType)];
180
+ if (img.caption) parts.push(encodeString(3, img.caption));
181
+ return Buffer.concat(parts);
182
+ }
183
+
184
+ function encodeChatToolCall(tc: { id: string; name: string; arguments: string }): Buffer {
185
+ return Buffer.concat([encodeString(1, tc.id), encodeString(2, tc.name), encodeString(3, tc.arguments)]);
186
+ }
187
+
188
+ function encodeChatMessagePrompt(
189
+ content: ContentPart[],
190
+ source: number,
191
+ opts?: { toolCallId?: string; toolCalls?: Array<{ id: string; name: string; arguments: string }> },
192
+ ): Buffer {
193
+ const textParts = content.filter((p): p is { type: "text"; text: string } => p.type === "text");
194
+ const imageParts = content.filter((p): p is { type: "image"; mimeType: string; base64Data: string; caption?: string } => p.type === "image");
195
+ const joined = textParts.map(p => p.text).join("\n");
196
+ const parts: Buffer[] = [
197
+ encodeVarintField(2, source),
198
+ encodeString(3, joined),
199
+ encodeVarintField(4, Math.max(1, Math.floor(joined.length / 4))),
200
+ encodeVarintField(5, 1),
201
+ ];
202
+ if (opts?.toolCallId) parts.push(encodeString(7, opts.toolCallId));
203
+ if (opts?.toolCalls && opts.toolCalls.length > 0) {
204
+ for (const tc of opts.toolCalls) parts.push(encodeMessage(6, encodeChatToolCall(tc)));
205
+ }
206
+ for (const img of imageParts) parts.push(encodeMessage(10, encodeImageData(img)));
207
+ return Buffer.concat(parts);
208
+ }
209
+
210
+ const SOURCE_BY_ROLE: Record<string, number> = { user: 1, assistant: 2, system: 1, tool: 4 };
211
+
212
+ function encodeCompletionConfiguration(opts: {
213
+ maxOutputTokens?: number; maxInputTokens?: number; temperature?: number; topK?: number; topP?: number;
214
+ }): Buffer {
215
+ const enc64 = (fieldNum: number, n: number): Buffer => {
216
+ const b = Buffer.alloc(8);
217
+ b.writeDoubleLE(n, 0);
218
+ return Buffer.concat([Buffer.from([(fieldNum << 3) | 1]), b]);
219
+ };
220
+ return Buffer.concat([
221
+ encodeVarintField(1, 1),
222
+ encodeVarintField(2, opts.maxInputTokens ?? 64000),
223
+ encodeVarintField(3, opts.maxOutputTokens ?? 128_000),
224
+ enc64(5, opts.temperature ?? 0.7),
225
+ enc64(6, opts.topP ?? 0.95),
226
+ encodeVarintField(7, opts.topK ?? 50),
227
+ enc64(8, 1.0),
228
+ enc64(11, 1.0),
229
+ ]);
230
+ }
231
+
232
+ interface BuildArgs {
233
+ apiKey: string; userJwt: string; modelUid: string; messages: ChatHistoryItem[];
234
+ cascadeId: string; promptId: string; sessionId: string; requestId: bigint; triggerId: string;
235
+ tools?: ToolDef[]; requestType?: number;
236
+ completionOpts?: { maxOutputTokens?: number; maxInputTokens?: number; temperature?: number; topK?: number; topP?: number; };
237
+ }
238
+
239
+ function buildGetChatMessageRequest(args: BuildArgs): Buffer {
240
+ const metadata = buildMetadata({
241
+ apiKey: args.apiKey, userJwt: args.userJwt, sessionId: args.sessionId,
242
+ requestId: args.requestId, triggerId: args.triggerId,
243
+ });
244
+ const collapsed = collapseSystemIntoUser(args.messages);
245
+ const promptParts = collapsed.map((m) =>
246
+ encodeMessage(3, encodeChatMessagePrompt(
247
+ normalizeContent(m.content),
248
+ SOURCE_BY_ROLE[m.role] ?? 1,
249
+ { toolCallId: m.role === "tool" ? m.tool_call_id : undefined, toolCalls: m.role === "assistant" ? m.tool_calls : undefined },
250
+ )),
251
+ );
252
+ const completion = encodeCompletionConfiguration(args.completionOpts ?? {});
253
+ const toolParts: Buffer[] = (args.tools ?? []).map((t) => encodeMessage(10, encodeToolDef(t)));
254
+ return Buffer.concat([
255
+ encodeMessage(1, metadata),
256
+ ...promptParts,
257
+ encodeVarintField(7, args.requestType ?? 5),
258
+ encodeMessage(8, completion),
259
+ ...toolParts,
260
+ encodeString(16, args.cascadeId),
261
+ encodeString(21, args.modelUid),
262
+ encodeString(22, args.promptId),
263
+ ]);
264
+ }
265
+
266
+ // ----------------------------------------------------------------------------
267
+ // Response decoding
268
+ // ----------------------------------------------------------------------------
269
+
270
+ function* decodeChatFrame(proto: Buffer): Generator<CloudChatEvent> {
271
+ for (const f of iterFields(proto)) {
272
+ if (f.num === 3 && f.wire === 2 && Buffer.isBuffer(f.value)) {
273
+ const s = (f.value as Buffer).toString("utf8");
274
+ if (s) yield { kind: "text", text: s };
275
+ } else if (f.num === 9 && f.wire === 2 && Buffer.isBuffer(f.value)) {
276
+ const s = (f.value as Buffer).toString("utf8");
277
+ if (s) yield { kind: "reasoning", text: s };
278
+ } else if (f.num === 6 && f.wire === 2 && Buffer.isBuffer(f.value)) {
279
+ let id: string | undefined;
280
+ let name: string | undefined;
281
+ let argsDelta: string | undefined;
282
+ for (const sf of iterFields(f.value as Buffer)) {
283
+ if (sf.wire === 2 && Buffer.isBuffer(sf.value)) {
284
+ const s = (sf.value as Buffer).toString("utf8");
285
+ if (sf.num === 1) id = s;
286
+ else if (sf.num === 2) name = s;
287
+ else if (sf.num === 3) argsDelta = s;
288
+ }
289
+ }
290
+ if (id !== undefined && name !== undefined) yield { kind: "tool_call_start", id, name };
291
+ if (argsDelta !== undefined) yield { kind: "tool_call_args", argsDelta, ...(id !== undefined ? { id } : {}) };
292
+ } else if (f.num === 5 && f.wire === 0) {
293
+ const v = Number(f.value);
294
+ let reason: "stop" | "tool_calls" | "length" | "content_filter" = "stop";
295
+ if (v === 10) reason = "tool_calls";
296
+ else if (v === 11) reason = "content_filter";
297
+ else if (v === 1 || v === 3) reason = "length";
298
+ yield { kind: "finish", reason };
299
+ } else if (f.num === 28 && f.wire === 2 && Buffer.isBuffer(f.value)) {
300
+ const usage = decodeUsageBlock(f.value as Buffer);
301
+ if (usage) yield usage;
302
+ }
303
+ }
304
+ }
305
+
306
+ function decodeUsageBlock(buf: Buffer): CloudChatEvent | null {
307
+ let promptTokens: number | undefined;
308
+ let completionTokens: number | undefined;
309
+ let cachedInputTokens: number | undefined;
310
+ let cacheCreationInputTokens: number | undefined;
311
+ let reasoningTokens: number | undefined;
312
+
313
+ for (const f of iterFields(buf)) {
314
+ if (f.num !== 2 || f.wire !== 2 || !Buffer.isBuffer(f.value)) continue;
315
+ let entryMetric: string | undefined;
316
+ let entryValue: number | undefined;
317
+ for (const sf of iterFields(f.value as Buffer)) {
318
+ if (sf.num === 5 && sf.wire === 2 && Buffer.isBuffer(sf.value)) {
319
+ entryMetric = (sf.value as Buffer).toString("utf8");
320
+ } else if (sf.num === 4 && sf.wire === 2 && Buffer.isBuffer(sf.value)) {
321
+ for (const ssf of iterFields(sf.value as Buffer)) {
322
+ if (ssf.num === 2 && ssf.wire === 5 && Buffer.isBuffer(ssf.value)) {
323
+ entryValue = (ssf.value as Buffer).readFloatLE(0);
324
+ break;
325
+ }
326
+ }
327
+ }
328
+ }
329
+ if (entryMetric && entryValue !== undefined && Number.isFinite(entryValue)) {
330
+ const n = Math.round(entryValue);
331
+ if (entryMetric === "input_tokens") promptTokens = n;
332
+ else if (entryMetric === "output_tokens") completionTokens = n;
333
+ else if (entryMetric === "cached_input_tokens" || entryMetric === "cache_read_input_tokens") cachedInputTokens = (cachedInputTokens ?? 0) + n;
334
+ else if (entryMetric === "cache_creation_input_tokens") cacheCreationInputTokens = (cacheCreationInputTokens ?? 0) + n;
335
+ else if (entryMetric === "reasoning_tokens" || entryMetric === "output_reasoning_tokens") reasoningTokens = (reasoningTokens ?? 0) + n;
336
+ }
337
+ }
338
+ if (promptTokens === undefined && completionTokens === undefined) return null;
339
+ return { kind: "usage", promptTokens, completionTokens, totalTokens: (promptTokens ?? 0) + (completionTokens ?? 0), cachedInputTokens, cacheCreationInputTokens, reasoningTokens };
340
+ }
341
+
342
+ // ----------------------------------------------------------------------------
343
+ // Public API: streamChatEvents
344
+ // ----------------------------------------------------------------------------
345
+
346
+ const TRACE_ID_RE = /\(trace ID: ([0-9a-f]+)\)/i;
347
+
348
+ export async function* streamChatEvents(req: CloudChatRequest): AsyncGenerator<CloudChatEvent> {
349
+ const host = (req.apiServerUrl ?? "https://server.codeium.com").replace(/\/$/, "");
350
+ const userJwt = await getCachedUserJwt(req.apiKey, host, req.signal);
351
+ const sessionIds = getOrAllocateSessionIds(req.apiKey, host, req.cascadeId);
352
+
353
+ const proto = buildGetChatMessageRequest({
354
+ apiKey: req.apiKey, userJwt, modelUid: req.modelUid, messages: req.messages,
355
+ tools: req.tools, cascadeId: sessionIds.cascadeId, promptId: crypto.randomUUID(),
356
+ sessionId: sessionIds.sessionId, requestId: BigInt(Date.now()), triggerId: crypto.randomUUID(),
357
+ requestType: req.requestType, completionOpts: req.completionOpts,
358
+ });
359
+ const body = frameConnectStream(proto, true);
360
+
361
+ const ttfbController = new AbortController();
362
+ const ttfbTimer = setTimeout(() => ttfbController.abort(new Error(`TTFB timeout (${CLOUD_STREAM_TTFB_MS}ms)`)), CLOUD_STREAM_TTFB_MS);
363
+ const ttfbSignal = ttfbController.signal;
364
+ const initialSignal: AbortSignal = req.signal ? anySignal([req.signal, ttfbSignal]) : ttfbSignal;
365
+
366
+ let resp: Response;
367
+ try {
368
+ resp = await fetch(`${host}/exa.api_server_pb.ApiServerService/GetChatMessage`, {
369
+ method: "POST",
370
+ headers: {
371
+ "Content-Type": "application/connect+proto",
372
+ "Connect-Protocol-Version": "1",
373
+ "Connect-Content-Encoding": "gzip",
374
+ "Connect-Accept-Encoding": "gzip",
375
+ },
376
+ body,
377
+ signal: initialSignal,
378
+ });
379
+ } finally {
380
+ clearTimeout(ttfbTimer);
381
+ }
382
+
383
+ if (!resp.ok) {
384
+ const text = await resp.text();
385
+ throw new CloudChatError(`GetChatMessage HTTP ${resp.status}: ${text.slice(0, 300)}`);
386
+ }
387
+ if (!resp.body) throw new CloudChatError("GetChatMessage response had no body stream");
388
+
389
+ const chunkQueue: Buffer[] = [];
390
+ let queuedBytes = 0;
391
+ const reader = resp.body.getReader() as ReadableStreamDefaultReader<Uint8Array>;
392
+ let trailerError: { code?: string; message: string; traceId?: string } | null = null;
393
+ let sawEos = false;
394
+
395
+ function peek(n: number): Buffer | null {
396
+ if (queuedBytes < n) return null;
397
+ if (chunkQueue.length === 1 && chunkQueue[0].length >= n) return chunkQueue[0].slice(0, n);
398
+ const parts: Buffer[] = [];
399
+ let remaining = n;
400
+ for (const c of chunkQueue) {
401
+ if (remaining <= 0) break;
402
+ if (c.length <= remaining) { parts.push(c); remaining -= c.length; }
403
+ else { parts.push(c.slice(0, remaining)); remaining = 0; }
404
+ }
405
+ return Buffer.concat(parts, n);
406
+ }
407
+
408
+ function drop(n: number): void {
409
+ queuedBytes -= n;
410
+ let remaining = n;
411
+ while (remaining > 0 && chunkQueue.length > 0) {
412
+ const head = chunkQueue[0];
413
+ if (head.length <= remaining) { chunkQueue.shift(); remaining -= head.length; }
414
+ else { chunkQueue[0] = head.slice(remaining); remaining = 0; }
415
+ }
416
+ }
417
+
418
+ let idleTimer: ReturnType<typeof setTimeout> | null = null;
419
+ try {
420
+ const resetIdle = (): Promise<{ value?: Uint8Array; done: boolean }> => {
421
+ if (idleTimer) clearTimeout(idleTimer);
422
+ const idleController = new AbortController();
423
+ idleTimer = setTimeout(
424
+ () => idleController.abort(new Error(`Idle timeout (${CLOUD_STREAM_IDLE_MS}ms)`)),
425
+ CLOUD_STREAM_IDLE_MS,
426
+ );
427
+ return new Promise((resolve, reject) => {
428
+ let settled = false;
429
+ const settle = (fn: () => void): void => { if (settled) return; settled = true; fn(); };
430
+ const readP = reader.read();
431
+ readP.catch(() => {});
432
+ idleController.signal.addEventListener("abort", () => {
433
+ try { void resp.body?.cancel(idleController.signal.reason ?? new Error("idle abort")); } catch {}
434
+ settle(() => reject(idleController.signal.reason ?? new Error("idle abort")));
435
+ }, { once: true });
436
+ readP.then(v => settle(() => resolve(v)), e => settle(() => reject(e)));
437
+ });
438
+ };
439
+
440
+ while (true) {
441
+ const { value, done } = await resetIdle();
442
+ if (done) break;
443
+ if (value) { chunkQueue.push(Buffer.from(value)); queuedBytes += value.length; }
444
+
445
+ while (queuedBytes >= 5) {
446
+ const header = peek(5);
447
+ if (!header) break;
448
+ const flags = header[0];
449
+ const len = header.readUInt32BE(1);
450
+ if (queuedBytes < 5 + len) break;
451
+ drop(5);
452
+ const raw = peek(len) ?? Buffer.alloc(0);
453
+ drop(len);
454
+
455
+ let payload = raw;
456
+ if (flags & 0x01) {
457
+ try { payload = zlib.gunzipSync(raw); }
458
+ catch (gzipErr) { throw new CloudChatError(`Connect frame gunzip failed: ${(gzipErr as Error).message}`); }
459
+ }
460
+ const eos = (flags & 0x02) !== 0;
461
+
462
+ if (eos) {
463
+ sawEos = true;
464
+ const text = payload.toString("utf8");
465
+ if (text && text.includes('"error"')) {
466
+ let code: string | undefined;
467
+ let message = text;
468
+ try {
469
+ const j = JSON.parse(text) as { error?: { code?: string; message?: string } };
470
+ code = j.error?.code;
471
+ if (j.error?.message) message = j.error.message;
472
+ } catch {}
473
+ const traceMatch = message.match(TRACE_ID_RE);
474
+ trailerError = { code, message, traceId: traceMatch?.[1] };
475
+ }
476
+ continue;
477
+ }
478
+ yield* decodeChatFrame(payload);
479
+ }
480
+ }
481
+ } finally {
482
+ if (idleTimer) clearTimeout(idleTimer);
483
+ try { reader.releaseLock(); } catch {}
484
+ try { void resp.body?.cancel(); } catch {}
485
+ }
486
+
487
+ if (trailerError) {
488
+ const isOpaquePermissionDenial =
489
+ trailerError.code === "permission_denied" && /an internal error occurred/i.test(trailerError.message);
490
+ if (isOpaquePermissionDenial) {
491
+ throw new CloudChatError(
492
+ `Cognition denied this request for model "${req.modelUid}". This almost always means the model is not enabled for your account/tier. (trace ID: ${trailerError.traceId ?? "n/a"})`,
493
+ trailerError.code, trailerError.traceId,
494
+ );
495
+ }
496
+ throw new CloudChatError(trailerError.message, trailerError.code, trailerError.traceId);
497
+ }
498
+ if (!sawEos) {
499
+ throw new CloudChatError(`Cloud stream ended without EOS trailer (${queuedBytes} bytes orphaned).`, "truncated_stream");
500
+ }
501
+ }
package/index.ts ADDED
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Windsurf Provider for Pi
3
+ *
4
+ * Enables Windsurf/Cognition models via cloud-direct API.
5
+ *
6
+ * Usage: /login windsurf → /model windsurf/<id>
7
+ */
8
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
+ import type { OAuthCredentials, OAuthLoginCallbacks } from "@earendil-works/pi-ai";
10
+ import { startProxy, stopProxy, PROXY_SECRET, setProxyCredentials } from "./proxy";
11
+ import { loadCredentials, saveCredentials, deleteCredentials, DEFAULT_REGION, runLoginLoopback, registerUser, type PersistedCredentials } from "./oauth";
12
+ import { clearCachedUserJwt } from "./auth";
13
+ import { clearSessionIds } from "./chat";
14
+ import { clearCachedCatalog, getCachedCatalog } from "./catalog";
15
+
16
+ let _pi: ExtensionAPI | null = null;
17
+
18
+ const STATIC_MODELS = [
19
+ { id: "claude-opus-4.8:medium", name: "Claude Opus 4.8 Medium", reasoning: true, input: ["text","image"] as ("text"|"image")[], cost: { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 }, contextWindow: 1000000, maxTokens: 128000 },
20
+ { id: "claude-opus-4.8:high", name: "Claude Opus 4.8 High", reasoning: true, input: ["text","image"] as ("text"|"image")[], cost: { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 }, contextWindow: 1000000, maxTokens: 128000 },
21
+ { id: "claude-opus-4.8:xhigh", name: "Claude Opus 4.8 XHigh", reasoning: true, input: ["text","image"] as ("text"|"image")[], cost: { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 }, contextWindow: 1000000, maxTokens: 128000 },
22
+ { id: "claude-opus-4.8:max", name: "Claude Opus 4.8 Max", reasoning: true, input: ["text","image"] as ("text"|"image")[], cost: { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 }, contextWindow: 1000000, maxTokens: 128000 },
23
+ { id: "claude-opus-4.7:medium", name: "Claude Opus 4.7 Medium", reasoning: true, input: ["text","image"] as ("text"|"image")[], cost: { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 }, contextWindow: 1000000, maxTokens: 128000 },
24
+ { id: "claude-opus-4.7:high", name: "Claude Opus 4.7 High", reasoning: true, input: ["text","image"] as ("text"|"image")[], cost: { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 }, contextWindow: 1000000, maxTokens: 128000 },
25
+ { id: "claude-opus-4.7:xhigh", name: "Claude Opus 4.7 XHigh", reasoning: true, input: ["text","image"] as ("text"|"image")[], cost: { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 }, contextWindow: 1000000, maxTokens: 128000 },
26
+ { id: "claude-opus-4.7:max", name: "Claude Opus 4.7 Max", reasoning: true, input: ["text","image"] as ("text"|"image")[], cost: { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 }, contextWindow: 1000000, maxTokens: 128000 },
27
+ { id: "claude-opus-4.6:thinking", name: "Claude Opus 4.6 Thinking", reasoning: true, input: ["text","image"] as ("text"|"image")[], cost: { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 }, contextWindow: 1000000, maxTokens: 128000 },
28
+ { id: "gpt-5.5:low", name: "GPT-5.5 Low", reasoning: true, input: ["text","image"] as ("text"|"image")[], cost: { input: 2.5, output: 10, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1050000, maxTokens: 128000 },
29
+ { id: "gpt-5.5:medium", name: "GPT-5.5 Medium", reasoning: true, input: ["text","image"] as ("text"|"image")[], cost: { input: 2.5, output: 10, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1050000, maxTokens: 128000 },
30
+ { id: "gpt-5.5:high", name: "GPT-5.5 High", reasoning: true, input: ["text","image"] as ("text"|"image")[], cost: { input: 2.5, output: 10, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1050000, maxTokens: 128000 },
31
+ { id: "gpt-5.5:xhigh", name: "GPT-5.5 XHigh", reasoning: true, input: ["text","image"] as ("text"|"image")[], cost: { input: 2.5, output: 10, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1050000, maxTokens: 128000 },
32
+ { id: "gpt-5.4:low", name: "GPT-5.4 Low", reasoning: true, input: ["text","image"] as ("text"|"image")[], cost: { input: 2.5, output: 10, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1050000, maxTokens: 128000 },
33
+ { id: "gpt-5.4:medium", name: "GPT-5.4 Medium", reasoning: true, input: ["text","image"] as ("text"|"image")[], cost: { input: 2.5, output: 10, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1050000, maxTokens: 128000 },
34
+ { id: "gpt-5.4:high", name: "GPT-5.4 High", reasoning: true, input: ["text","image"] as ("text"|"image")[], cost: { input: 2.5, output: 10, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1050000, maxTokens: 128000 },
35
+ { id: "gpt-5.3-codex:low", name: "GPT-5.3-Codex Low", reasoning: true, input: ["text","image"] as ("text"|"image")[], cost: { input: 2.5, output: 10, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1050000, maxTokens: 128000 },
36
+ { id: "gpt-5.3-codex:medium", name: "GPT-5.3-Codex Medium", reasoning: true, input: ["text","image"] as ("text"|"image")[], cost: { input: 2.5, output: 10, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1050000, maxTokens: 128000 },
37
+ { id: "gpt-5.3-codex:high", name: "GPT-5.3-Codex High", reasoning: true, input: ["text","image"] as ("text"|"image")[], cost: { input: 2.5, output: 10, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1050000, maxTokens: 128000 },
38
+ { id: "gemini-3.5-flash:minimal",name:"Gemini 3.5 Flash Minimal",reasoning: true, input: ["text","image"] as ("text"|"image")[], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1048576, maxTokens: 65536 },
39
+ { id: "gemini-3.5-flash:low", name: "Gemini 3.5 Flash Low", reasoning: true, input: ["text","image"] as ("text"|"image")[], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1048576, maxTokens: 65536 },
40
+ { id: "gemini-3.5-flash:medium",name: "Gemini 3.5 Flash Medium",reasoning: true, input: ["text","image"] as ("text"|"image")[], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1048576, maxTokens: 65536 },
41
+ { id: "gemini-3.5-flash:high", name: "Gemini 3.5 Flash High", reasoning: true, input: ["text","image"] as ("text"|"image")[], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1048576, maxTokens: 65536 },
42
+ { id: "gemini-3.1-pro:low", name: "Gemini 3.1 Pro Low", reasoning: true, input: ["text","image"] as ("text"|"image")[], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1048576, maxTokens: 65536 },
43
+ { id: "gemini-3.1-pro:high", name: "Gemini 3.1 Pro High", reasoning: true, input: ["text","image"] as ("text"|"image")[], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1048576, maxTokens: 65536 },
44
+ { id: "kimi-k2.6", name: "Kimi K2.6", reasoning: true, input: ["text","image"] as ("text"|"image")[], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 262144, maxTokens: 262144 },
45
+ { id: "kimi-k2.7", name: "Kimi K2.7", reasoning: true, input: ["text","image"] as ("text"|"image")[], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 262144, maxTokens: 262144 },
46
+ { id: "glm-5.2", name: "GLM 5.2", reasoning: true, input: ["text"] as ("text"|"image")[], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 200000, maxTokens: 131072 },
47
+ { id: "swe-1.6:base", name: "SWE-1.6", reasoning: true, input: ["text","image"] as ("text"|"image")[], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1000000, maxTokens: 128000 },
48
+ { id: "swe-1.6:fast", name: "SWE-1.6 Fast", reasoning: true, input: ["text","image"] as ("text"|"image")[], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1000000, maxTokens: 128000 },
49
+ { id: "deepseek-v4", name: "DeepSeek V4", reasoning: true, input: ["text"] as ("text"|"image")[], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1000000, maxTokens: 384000 },
50
+ { id: "gpt-5.4-mini:low", name: "GPT-5.4 Mini Low", reasoning: true, input: ["text","image"] as ("text"|"image")[], cost: { input: 0.15, output: 0.6, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1050000, maxTokens: 128000 },
51
+ { id: "gpt-5.4-mini:medium", name: "GPT-5.4 Mini Medium", reasoning: true, input: ["text","image"] as ("text"|"image")[], cost: { input: 0.15, output: 0.6, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1050000, maxTokens: 128000 },
52
+ { id: "gpt-5.4-mini:high", name: "GPT-5.4 Mini High", reasoning: true, input: ["text","image"] as ("text"|"image")[], cost: { input: 0.15, output: 0.6, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1050000, maxTokens: 128000 },
53
+ ];
54
+
55
+ function mc(m: typeof STATIC_MODELS[number]) {
56
+ return { id: m.id, name: m.name, reasoning: m.reasoning, input: m.input, cost: m.cost, contextWindow: m.contextWindow, maxTokens: m.maxTokens };
57
+ }
58
+
59
+ // OAuth
60
+ async function loginWindsurf(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
61
+ let token: string;
62
+ try {
63
+ token = await runLoginLoopback(DEFAULT_REGION, (url) => callbacks.onAuth({ url }));
64
+ } catch {
65
+ const pasted = await callbacks.onPrompt({
66
+ message: `Open this URL, sign in, paste callback URL or token:\n\n ${DEFAULT_REGION.website}/windsurf/signin\n\nPaste:`,
67
+ });
68
+ const trimmed = pasted.trim();
69
+ try {
70
+ const u = new URL(trimmed);
71
+ token = u.searchParams.get("firebase_id_token") ?? u.searchParams.get("access_token") ?? u.searchParams.get("token") ?? trimmed;
72
+ } catch { token = trimmed; }
73
+ }
74
+ if (!token) throw new Error("No token received.");
75
+
76
+ const result = await registerUser(token, DEFAULT_REGION);
77
+ saveCredentials({ ...result, issuedAt: new Date().toISOString(), oauthClientId: DEFAULT_REGION.oauthClientId });
78
+ setProxyCredentials({ apiKey: result.apiKey, apiServerUrl: result.apiServerUrl });
79
+ clearCachedUserJwt();
80
+ clearSessionIds();
81
+ clearCachedCatalog();
82
+ return { refresh: result.apiKey, access: result.apiKey, expires: Date.now() + 365 * 24 * 60 * 60 * 1000 };
83
+ }
84
+
85
+ async function refreshWindsurfToken(c: OAuthCredentials): Promise<OAuthCredentials> { return c; }
86
+
87
+ // Extension entry
88
+ export default async function (pi: ExtensionAPI) {
89
+ _pi = pi;
90
+
91
+ const proxyPort = await startProxy();
92
+ const baseUrl = `http://127.0.0.1:${proxyPort}/v1`;
93
+
94
+ let hasCreds = false;
95
+ try {
96
+ const stored = loadCredentials();
97
+ if (stored) { setProxyCredentials({ apiKey: stored.apiKey, apiServerUrl: stored.apiServerUrl }); hasCreds = true; }
98
+ } catch {}
99
+
100
+ pi.registerProvider("windsurf", {
101
+ name: "Cognition (Windsurf)",
102
+ baseUrl, apiKey: PROXY_SECRET, api: "openai-completions", authHeader: true,
103
+ models: STATIC_MODELS.map(mc),
104
+ oauth: {
105
+ name: "Windsurf (Cognition)",
106
+ login: loginWindsurf,
107
+ refreshToken: refreshWindsurfToken,
108
+ getApiKey: (creds: OAuthCredentials) => creds.access,
109
+ },
110
+ });
111
+
112
+ console.error(hasCreds ? `[windsurf] connected` : `[windsurf] /login windsurf to connect`);
113
+
114
+ pi.registerCommand("windsurf-status", {
115
+ description: "Show Windsurf auth status",
116
+ handler: async (_args, ctx) => {
117
+ const c = loadCredentials();
118
+ ctx.ui.notify(c ? `Windsurf: authenticated (${c.apiServerUrl})` : "Windsurf: not signed in. /login windsurf", c ? "info" : "warning");
119
+ },
120
+ });
121
+
122
+ pi.registerCommand("windsurf-logout", {
123
+ description: "Sign out of Windsurf",
124
+ handler: async (_args, ctx) => {
125
+ const ok = deleteCredentials();
126
+ setProxyCredentials(null);
127
+ clearCachedUserJwt(); clearSessionIds(); clearCachedCatalog();
128
+ ctx.ui.notify(ok ? "Windsurf: signed out." : "Already signed out.", "info");
129
+ },
130
+ });
131
+
132
+ pi.registerCommand("windsurf-refresh", {
133
+ description: "Refresh Windsurf model catalog",
134
+ handler: async (_args, ctx) => {
135
+ const c = loadCredentials();
136
+ if (!c) {
137
+ ctx.ui.notify("Windsurf: not signed in. /login windsurf", "warning");
138
+ return;
139
+ }
140
+ clearCachedCatalog();
141
+ try {
142
+ const catalog = await getCachedCatalog(c.apiKey, c.apiServerUrl);
143
+ if (catalog) {
144
+ ctx.ui.notify(`Windsurf: refreshed ${catalog.byUid.size} models. Restart Pi to apply.`, "info");
145
+ } else {
146
+ ctx.ui.notify("Windsurf: refresh failed. Check connection.", "warning");
147
+ }
148
+ } catch (e) {
149
+ ctx.ui.notify(`Windsurf: refresh error - ${e instanceof Error ? e.message : String(e)}`, "error");
150
+ }
151
+ },
152
+ });
153
+
154
+ pi.on("session_shutdown", async () => { _pi = null; stopProxy(); });
155
+ }