@ngotrnghia1811/opencode-windsurf-auth 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,535 @@
1
+ import { launchProxyStream } from "./thinking-proxy";
2
+ import { loadWindsurfJwt } from "./credentials";
3
+ import { encodeGetChatMessageRequest } from "./chat-request";
4
+ import { streamGetChatMessage } from "./chat-client";
5
+ function getDevinPath() {
6
+ const p = Bun.which("devin");
7
+ if (!p)
8
+ throw new Error("devin CLI not found — run `devin /login` first");
9
+ return p;
10
+ }
11
+ const ZERO_USAGE = {
12
+ inputTokens: { total: undefined, noCache: undefined, cacheRead: undefined, cacheWrite: undefined },
13
+ outputTokens: { total: undefined, text: undefined, reasoning: undefined },
14
+ };
15
+ const STOP_REASON = { unified: "stop", raw: "stop" };
16
+ const TOOL_CALLS_REASON = { unified: "tool-calls", raw: "tool_use" };
17
+ const ERROR_REASON = { unified: "error", raw: "error" };
18
+ const STREAM_TIMEOUT_MS = 300_000;
19
+ const EMPTY_RESULT_ERROR = new Error("Windsurf produced no content (empty stream — possible backend drop or timeout)");
20
+ function trackContent(tracker, type) {
21
+ if (type === "text-delta" ||
22
+ type === "text-start" ||
23
+ type === "reasoning-delta" ||
24
+ type === "reasoning-start" ||
25
+ type === "tool-call" ||
26
+ type === "tool-input-start") {
27
+ tracker.emitted = true;
28
+ }
29
+ }
30
+ function stripDevinBanner(text) {
31
+ const noAnsi = text.replace(/\x1b\[[0-9;]*m/g, "");
32
+ const bannerPatterns = [
33
+ "Welcome to Devin CLI",
34
+ "Logged in as",
35
+ "You're all set",
36
+ "✓ Organization",
37
+ ];
38
+ const lines = noAnsi.split("\n");
39
+ let start = 0;
40
+ for (let i = 0; i < lines.length; i++) {
41
+ if (bannerPatterns.some((p) => lines[i].includes(p))) {
42
+ start = i + 1;
43
+ continue;
44
+ }
45
+ break;
46
+ }
47
+ return lines.slice(start).join("\n");
48
+ }
49
+ function flattenHistory(options) {
50
+ const tools = options.tools;
51
+ const toolsPrompt = tools
52
+ ? `<tools>\n${Object.entries(tools)
53
+ .map(([name, def]) => ` ${name}: ${def.description ?? name}`)
54
+ .join("\n")}\n</tools>\n\n`
55
+ : "";
56
+ const messages = options.prompt
57
+ .map((msg) => {
58
+ const role = msg.role;
59
+ const parts = typeof msg.content === "string" ? msg.content : msg.content;
60
+ if (typeof parts === "string")
61
+ return `${role}: ${parts}`;
62
+ return `${role}: ${parts
63
+ .map((p) => {
64
+ if (p.type === "text")
65
+ return p.text;
66
+ if (p.type === "tool-result") {
67
+ const tr = p;
68
+ return `[tool result #${tr.toolCallId}: ${JSON.stringify(tr.output)}]`;
69
+ }
70
+ return "";
71
+ })
72
+ .join("")}`;
73
+ })
74
+ .join("\n\n");
75
+ return toolsPrompt + messages;
76
+ }
77
+ // ── Level-2: direct Connect-RPC to Windsurf API ─────────────────────────────
78
+ function extractSystemPrompt(options) {
79
+ for (const msg of options.prompt) {
80
+ if (msg.role === "system")
81
+ return msg.content;
82
+ }
83
+ return "";
84
+ }
85
+ /** Extract a plain string from a LanguageModelV3ToolResultOutput. */
86
+ function extractToolOutput(output) {
87
+ const o = output;
88
+ if (o.type === "text" && typeof o.value === "string")
89
+ return o.value;
90
+ if (o.type === "json")
91
+ return JSON.stringify(o.value);
92
+ if (o.type === "error-text" && typeof o.value === "string")
93
+ return o.value;
94
+ if (o.type === "error-json")
95
+ return JSON.stringify(o.value);
96
+ if (o.type === "execution-denied")
97
+ return `Execution denied${o.reason ? `: ${o.reason}` : ""}`;
98
+ if (o.type === "content")
99
+ return JSON.stringify(o.value);
100
+ return JSON.stringify(output);
101
+ }
102
+ function convertToProtoMessages(options) {
103
+ const result = [];
104
+ for (const msg of options.prompt) {
105
+ if (msg.role === "system")
106
+ continue; // handled separately as system_prompt f2
107
+ if (msg.role === "user") {
108
+ const content = typeof msg.content === "string"
109
+ ? msg.content
110
+ : msg.content
111
+ .filter((p) => p.type === "text")
112
+ .map((p) => p.text)
113
+ .join("");
114
+ result.push({ role: 1, content });
115
+ continue;
116
+ }
117
+ if (msg.role === "assistant") {
118
+ const parts = msg.content;
119
+ // Collect text/reasoning parts into a single text message
120
+ const text = parts
121
+ .filter((p) => p.type === "text" || p.type === "reasoning")
122
+ .map((p) => p.text)
123
+ .join("");
124
+ // Emit tool-call parts as separate role=2 messages with f6
125
+ const toolCalls = parts.filter((p) => p.type === "tool-call");
126
+ // If there's text, emit as standalone role=2 text message.
127
+ // Prefer separate from tool-call messages when both are present.
128
+ if (text) {
129
+ result.push({ role: 2, content: text });
130
+ }
131
+ // Emit each tool-call as a pure role=2 tool-call message (no text attached)
132
+ for (const tc of toolCalls) {
133
+ const argumentsJson = typeof tc.input === "string"
134
+ ? tc.input
135
+ : JSON.stringify(tc.input);
136
+ result.push({
137
+ role: 2,
138
+ toolCall: { id: tc.toolCallId, name: tc.toolName, argumentsJson },
139
+ });
140
+ }
141
+ continue;
142
+ }
143
+ if (msg.role === "tool") {
144
+ const parts = msg.content;
145
+ for (const tr of parts) {
146
+ if (tr.type !== "tool-result")
147
+ continue;
148
+ result.push({
149
+ role: 4,
150
+ content: extractToolOutput(tr.output),
151
+ toolResult: { toolCallId: tr.toolCallId },
152
+ });
153
+ }
154
+ continue;
155
+ }
156
+ }
157
+ return result;
158
+ }
159
+ function convertTools(options) {
160
+ const tools = options.tools;
161
+ if (!tools)
162
+ return [];
163
+ return tools
164
+ .filter((t) => t.type === "function")
165
+ .map((t) => {
166
+ const ft = t;
167
+ return {
168
+ name: ft.name,
169
+ description: ft.description ?? ft.name,
170
+ parametersJsonSchema: JSON.stringify(ft.inputSchema),
171
+ };
172
+ });
173
+ }
174
+ async function streamViaDirectConnect(controller, options, modelId, tracker, signal) {
175
+ if (signal?.aborted)
176
+ return false;
177
+ const jwt = await loadWindsurfJwt();
178
+ if (!jwt)
179
+ return false;
180
+ const systemPrompt = extractSystemPrompt(options);
181
+ const messages = convertToProtoMessages(options);
182
+ const tools = convertTools(options);
183
+ const body = encodeGetChatMessageRequest({
184
+ jwt,
185
+ systemPrompt,
186
+ messages,
187
+ tools,
188
+ modelId,
189
+ });
190
+ let reasoningStarted = false;
191
+ let textStarted = false;
192
+ let finished = false;
193
+ const toolCalls = new Map();
194
+ let toolCallStarted = false;
195
+ const events = streamGetChatMessage(body, signal);
196
+ function flushToolCall(id) {
197
+ const tc = toolCalls.get(id);
198
+ if (!tc)
199
+ return;
200
+ toolCalls.delete(id);
201
+ const input = tc.argsChunks.join("");
202
+ controller.enqueue({ type: "tool-input-end", id });
203
+ controller.enqueue({ type: "tool-call", toolCallId: id, toolName: tc.name, input });
204
+ trackContent(tracker, "tool-call");
205
+ }
206
+ function flushAllToolCalls() {
207
+ for (const id of toolCalls.keys()) {
208
+ flushToolCall(id);
209
+ }
210
+ toolCallStarted = false;
211
+ }
212
+ try {
213
+ for await (const event of events) {
214
+ if (finished)
215
+ break;
216
+ switch (event.type) {
217
+ case "reasoning": {
218
+ if (!reasoningStarted) {
219
+ controller.enqueue({ type: "reasoning-start", id: "0" });
220
+ trackContent(tracker, "reasoning-start");
221
+ reasoningStarted = true;
222
+ }
223
+ controller.enqueue({ type: "reasoning-delta", id: "0", delta: event.delta });
224
+ trackContent(tracker, "reasoning-delta");
225
+ break;
226
+ }
227
+ case "text": {
228
+ if (reasoningStarted) {
229
+ controller.enqueue({ type: "reasoning-end", id: "0" });
230
+ reasoningStarted = false;
231
+ }
232
+ if (!textStarted) {
233
+ controller.enqueue({ type: "text-start", id: "1" });
234
+ trackContent(tracker, "text-start");
235
+ textStarted = true;
236
+ }
237
+ controller.enqueue({ type: "text-delta", id: "1", delta: event.delta });
238
+ trackContent(tracker, "text-delta");
239
+ break;
240
+ }
241
+ case "tool-call-start": {
242
+ // close any prior text/reasoning stream
243
+ if (reasoningStarted) {
244
+ controller.enqueue({ type: "reasoning-end", id: "0" });
245
+ reasoningStarted = false;
246
+ }
247
+ if (textStarted) {
248
+ controller.enqueue({ type: "text-end", id: "1" });
249
+ textStarted = false;
250
+ }
251
+ // flush any previous incomplete tool call
252
+ flushAllToolCalls();
253
+ // start new tool call
254
+ toolCalls.set(event.id, { name: event.name, argsChunks: [] });
255
+ controller.enqueue({ type: "tool-input-start", id: event.id, toolName: event.name });
256
+ trackContent(tracker, "tool-input-start");
257
+ toolCallStarted = true;
258
+ break;
259
+ }
260
+ case "tool-call-delta": {
261
+ const tc = toolCalls.get(event.id);
262
+ if (tc) {
263
+ tc.argsChunks.push(event.argsChunk);
264
+ }
265
+ controller.enqueue({ type: "tool-input-delta", id: event.id, delta: event.argsChunk });
266
+ break;
267
+ }
268
+ case "finish": {
269
+ finished = true;
270
+ if (reasoningStarted) {
271
+ controller.enqueue({ type: "reasoning-end", id: "0" });
272
+ reasoningStarted = false;
273
+ }
274
+ if (textStarted) {
275
+ controller.enqueue({ type: "text-end", id: "1" });
276
+ textStarted = false;
277
+ }
278
+ // flush any pending tool calls
279
+ flushAllToolCalls();
280
+ const isToolUse = event.stopReason === 10;
281
+ const usage = {
282
+ inputTokens: {
283
+ total: event.inputTokens,
284
+ noCache: undefined,
285
+ cacheRead: undefined,
286
+ cacheWrite: undefined,
287
+ },
288
+ outputTokens: {
289
+ total: event.outputTokens,
290
+ text: event.outputTokens,
291
+ reasoning: undefined,
292
+ },
293
+ };
294
+ controller.enqueue({
295
+ type: "finish",
296
+ finishReason: isToolUse ? TOOL_CALLS_REASON : STOP_REASON,
297
+ usage,
298
+ });
299
+ break;
300
+ }
301
+ }
302
+ }
303
+ }
304
+ catch {
305
+ return false;
306
+ }
307
+ if (!finished) {
308
+ if (reasoningStarted)
309
+ controller.enqueue({ type: "reasoning-end", id: "0" });
310
+ if (textStarted)
311
+ controller.enqueue({ type: "text-end", id: "1" });
312
+ flushAllToolCalls();
313
+ controller.enqueue({ type: "finish", finishReason: STOP_REASON, usage: ZERO_USAGE });
314
+ }
315
+ if (!tracker.emitted) {
316
+ controller.enqueue({ type: "error", error: EMPTY_RESULT_ERROR });
317
+ }
318
+ controller.close();
319
+ return true;
320
+ }
321
+ // ── Fallback: devin -p path ──────────────────────────────────────────────────
322
+ async function fallbackDevinRun(modelId, prompt) {
323
+ const proc = Bun.spawn([getDevinPath(), "--permission-mode", "bypass", "--model", modelId, "-p", "--", prompt], {
324
+ stdout: "pipe",
325
+ stderr: "pipe",
326
+ });
327
+ const output = await new Response(proc.stdout).text();
328
+ const stderr = await new Response(proc.stderr).text();
329
+ const exitCode = await proc.exited;
330
+ return { output, exitCode, stderr };
331
+ }
332
+ async function doGenerateViaFallback(options, modelId) {
333
+ const prompt = flattenHistory(options);
334
+ const { output, exitCode, stderr } = await fallbackDevinRun(modelId, prompt);
335
+ if (exitCode !== 0) {
336
+ throw new Error(`devin exited with code ${exitCode}: ${stderr.slice(0, 500)}`);
337
+ }
338
+ return {
339
+ content: [{ type: "text", text: stripDevinBanner(output) }],
340
+ finishReason: STOP_REASON,
341
+ usage: ZERO_USAGE,
342
+ warnings: [],
343
+ };
344
+ }
345
+ // ── Proxy streaming path (best-effort reasoning enrichment) ─────────────────
346
+ async function streamViaProxy(controller, events, tracker) {
347
+ let reasoningStarted = false;
348
+ let textStarted = false;
349
+ let finished = false;
350
+ try {
351
+ for await (const event of events) {
352
+ if (finished)
353
+ break;
354
+ switch (event.type) {
355
+ case "reasoning": {
356
+ const t = event.text;
357
+ if (!reasoningStarted) {
358
+ controller.enqueue({ type: "reasoning-start", id: "0" });
359
+ trackContent(tracker, "reasoning-start");
360
+ reasoningStarted = true;
361
+ }
362
+ controller.enqueue({ type: "reasoning-delta", id: "0", delta: t });
363
+ trackContent(tracker, "reasoning-delta");
364
+ break;
365
+ }
366
+ case "text": {
367
+ const t = event.text;
368
+ if (reasoningStarted) {
369
+ controller.enqueue({ type: "reasoning-end", id: "0" });
370
+ reasoningStarted = false;
371
+ }
372
+ if (!textStarted) {
373
+ controller.enqueue({ type: "text-start", id: "1" });
374
+ trackContent(tracker, "text-start");
375
+ textStarted = true;
376
+ }
377
+ controller.enqueue({ type: "text-delta", id: "1", delta: t });
378
+ trackContent(tracker, "text-delta");
379
+ break;
380
+ }
381
+ case "finish": {
382
+ finished = true;
383
+ const f = event;
384
+ if (reasoningStarted) {
385
+ controller.enqueue({ type: "reasoning-end", id: "0" });
386
+ reasoningStarted = false;
387
+ }
388
+ if (textStarted) {
389
+ controller.enqueue({ type: "text-end", id: "1" });
390
+ textStarted = false;
391
+ }
392
+ const usage = {
393
+ inputTokens: {
394
+ total: f.input_tokens,
395
+ noCache: undefined,
396
+ cacheRead: undefined,
397
+ cacheWrite: undefined,
398
+ },
399
+ outputTokens: {
400
+ total: f.output_tokens,
401
+ text: f.output_tokens,
402
+ reasoning: undefined,
403
+ },
404
+ };
405
+ controller.enqueue({ type: "finish", finishReason: STOP_REASON, usage });
406
+ break;
407
+ }
408
+ }
409
+ }
410
+ }
411
+ catch {
412
+ return false;
413
+ }
414
+ // If we never got a finish event, emit one with zero usage
415
+ if (!finished) {
416
+ if (reasoningStarted)
417
+ controller.enqueue({ type: "reasoning-end", id: "0" });
418
+ if (textStarted)
419
+ controller.enqueue({ type: "text-end", id: "1" });
420
+ controller.enqueue({ type: "finish", finishReason: STOP_REASON, usage: ZERO_USAGE });
421
+ }
422
+ if (!tracker.emitted) {
423
+ controller.enqueue({ type: "error", error: EMPTY_RESULT_ERROR });
424
+ }
425
+ controller.close();
426
+ return true;
427
+ }
428
+ // ── Fallback: direct devin -p word-split (no reasoning) ─────────────────────
429
+ async function streamViaFallback(controller, modelId, text, tracker, signal) {
430
+ if (signal?.aborted) {
431
+ controller.enqueue({ type: "error", error: EMPTY_RESULT_ERROR });
432
+ controller.enqueue({ type: "finish", finishReason: ERROR_REASON, usage: ZERO_USAGE });
433
+ controller.close();
434
+ return;
435
+ }
436
+ const proc = Bun.spawn([getDevinPath(), "--permission-mode", "bypass", "--model", modelId, "-p", "--", text], {
437
+ stdout: "pipe",
438
+ stderr: "pipe",
439
+ signal,
440
+ });
441
+ const output = await new Response(proc.stdout).text();
442
+ const stripped = stripDevinBanner(output);
443
+ const wordPattern = /\S+\s*/g;
444
+ const words = stripped.match(wordPattern) ?? [];
445
+ controller.enqueue({ type: "text-start", id: "0" });
446
+ trackContent(tracker, "text-start");
447
+ for (const word of words) {
448
+ controller.enqueue({ type: "text-delta", id: "0", delta: word });
449
+ trackContent(tracker, "text-delta");
450
+ }
451
+ const exitCode = await proc.exited;
452
+ if (exitCode !== 0) {
453
+ const stderr = await new Response(proc.stderr).text();
454
+ controller.enqueue({ type: "error", error: new Error(`devin exit ${exitCode}: ${stderr.slice(0, 200)}`) });
455
+ controller.enqueue({ type: "finish", finishReason: ERROR_REASON, usage: ZERO_USAGE });
456
+ controller.close();
457
+ return;
458
+ }
459
+ controller.enqueue({ type: "text-end", id: "0" });
460
+ controller.enqueue({ type: "finish", finishReason: STOP_REASON, usage: ZERO_USAGE });
461
+ if (!tracker.emitted) {
462
+ controller.enqueue({ type: "error", error: EMPTY_RESULT_ERROR });
463
+ }
464
+ controller.close();
465
+ }
466
+ // ── Model class ──────────────────────────────────────────────────────────────
467
+ export class WindsurfLanguageModel {
468
+ specificationVersion = "v3";
469
+ provider;
470
+ modelId;
471
+ supportedUrls = {};
472
+ constructor(modelId) {
473
+ this.provider = "windsurf";
474
+ this.modelId = modelId;
475
+ }
476
+ async doGenerate(options) {
477
+ return doGenerateViaFallback(options, this.modelId);
478
+ }
479
+ async doStream(options) {
480
+ const text = flattenHistory(options);
481
+ const modelId = this.modelId;
482
+ const stream = new ReadableStream({
483
+ async start(controller) {
484
+ const tracker = { emitted: false };
485
+ // Combined abort signal: internal timeout + external signal from caller
486
+ const timeoutAc = new AbortController();
487
+ const timeoutId = setTimeout(() => timeoutAc.abort(new Error("Stream timeout")), STREAM_TIMEOUT_MS);
488
+ const externalSignal = options.abortSignal;
489
+ const onExternalAbort = () => {
490
+ clearTimeout(timeoutId);
491
+ timeoutAc.abort(externalSignal?.reason);
492
+ };
493
+ if (externalSignal) {
494
+ if (externalSignal.aborted) {
495
+ clearTimeout(timeoutId);
496
+ timeoutAc.abort(externalSignal.reason);
497
+ }
498
+ externalSignal.addEventListener("abort", onExternalAbort, { once: true });
499
+ }
500
+ try {
501
+ controller.enqueue({ type: "stream-start", warnings: [] });
502
+ // Level-2: direct Connect-RPC to Windsurf API (with tools)
503
+ const directSuccess = await streamViaDirectConnect(controller, options, modelId, tracker, timeoutAc.signal);
504
+ if (directSuccess)
505
+ return;
506
+ // Try proxy path first (best-effort reasoning enrichment)
507
+ const proxyStream = await launchProxyStream(modelId, text);
508
+ if (proxyStream.ok) {
509
+ const success = await streamViaProxy(controller, proxyStream.events, tracker);
510
+ await proxyStream.cleanup();
511
+ if (success)
512
+ return;
513
+ }
514
+ // Fallback: direct devin -p word-split (no reasoning)
515
+ await streamViaFallback(controller, modelId, text, tracker, timeoutAc.signal);
516
+ }
517
+ finally {
518
+ clearTimeout(timeoutId);
519
+ if (externalSignal) {
520
+ externalSignal.removeEventListener("abort", onExternalAbort);
521
+ }
522
+ }
523
+ },
524
+ });
525
+ return { stream };
526
+ }
527
+ }
528
+ export function createWindsurf(opts) {
529
+ return {
530
+ languageModel(modelId) {
531
+ return new WindsurfLanguageModel(modelId);
532
+ },
533
+ };
534
+ }
535
+ export * as WindsurfProvider from ".";
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@ngotrnghia1811/opencode-windsurf-auth",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "keywords": ["opencode", "windsurf", "llm", "provider", "plugin", "ai"],
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/ngotrnghia1811/opencode-windsurf-auth"
12
+ },
13
+ "main": "./dist/index.js",
14
+ "types": "./dist/index.d.ts",
15
+ "dependencies": {},
16
+ "devDependencies": {
17
+ "@ai-sdk/provider": "3.0.8",
18
+ "@opencode-ai/plugin": "1.14.50",
19
+ "@opencode-ai/sdk": "1.15.12",
20
+ "@tsconfig/bun": "1.0.10",
21
+ "@types/bun": "1.3.14",
22
+ "typescript": "6.0.3"
23
+ },
24
+ "peerDependencies": {
25
+ "@opencode-ai/plugin": "*"
26
+ },
27
+ "description": "OpenCode plugin — Level-2 Connect-RPC provider for Windsurf/Cascade models via Devin CLI credentials",
28
+ "engines": {
29
+ "bun": "1.3.14"
30
+ },
31
+ "files": [
32
+ "dist",
33
+ "README.md",
34
+ "LICENSE"
35
+ ],
36
+ "exports": {
37
+ ".": {
38
+ "import": "./dist/index.js"
39
+ }
40
+ },
41
+ "scripts": {
42
+ "prepare": "tsc -p tsconfig.build.json",
43
+ "build": "tsc -p tsconfig.build.json",
44
+ "typecheck": "tsc",
45
+ "test": "bun test",
46
+ "dev": "bun scripts/dev.ts"
47
+ }
48
+ }