@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.
- package/LICENSE +21 -0
- package/README.md +105 -0
- package/dist/__tests__/models.test.d.ts +1 -0
- package/dist/__tests__/models.test.js +38 -0
- package/dist/chat-client.d.ts +23 -0
- package/dist/chat-client.js +329 -0
- package/dist/chat-request.d.ts +24 -0
- package/dist/chat-request.js +118 -0
- package/dist/credentials.d.ts +1 -0
- package/dist/credentials.js +39 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +49 -0
- package/dist/models.d.ts +2 -0
- package/dist/models.js +206 -0
- package/dist/proto.d.ts +21 -0
- package/dist/proto.js +100 -0
- package/dist/thinking-proxy.d.ts +22 -0
- package/dist/thinking-proxy.js +151 -0
- package/dist/windsurf-provider.d.ts +16 -0
- package/dist/windsurf-provider.js +535 -0
- package/package.json +48 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 nghiango
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# opencode-windsurf-auth
|
|
2
|
+
|
|
3
|
+
OpenCode plugin providing a **Level-2 Connect-RPC provider** for Windsurf/Cascade
|
|
4
|
+
models (Claude, GPT, Gemini, Grok, DeepSeek, and more) via the **Devin CLI**
|
|
5
|
+
credentials flow.
|
|
6
|
+
|
|
7
|
+
> **Warning:** This plugin comes with no guarantees. You might be banned for
|
|
8
|
+
> breaking the Windsurf/Devin Terms of Service. Use at your own risk.
|
|
9
|
+
|
|
10
|
+
## What This Plugin Does
|
|
11
|
+
|
|
12
|
+
1. Reads the Windsurf JWT from `~/.local/share/devin/credentials.toml`
|
|
13
|
+
(stored by the Devin CLI after `devin /login`).
|
|
14
|
+
2. Authenticates against Codeium's HTTP/2 Connect-RPC endpoint
|
|
15
|
+
(`server.codeium.com/exa.api_server_pb.ApiServerService/GetChatMessage`).
|
|
16
|
+
3. Provides a full `LanguageModelV3` implementation (`createWindsurf`) so
|
|
17
|
+
opencode can use Windsurf models through its standard provider SDK.
|
|
18
|
+
4. Registers ~130+ models (Claude Opus/Sonnet/Haiku, GPT-5.x, Gemini 3.x,
|
|
19
|
+
Grok, DeepSeek, Kimi, GLM, MiniMax, SWE, and Windsurf-native models).
|
|
20
|
+
|
|
21
|
+
## Prerequisites
|
|
22
|
+
|
|
23
|
+
- **[Devin CLI](https://devin.ai)** installed and authenticated:
|
|
24
|
+
```bash
|
|
25
|
+
devin /login
|
|
26
|
+
```
|
|
27
|
+
- **[mitmproxy](https://mitmproxy.org/)** (specifically `mitmdump`) for
|
|
28
|
+
the thinking-proxy fallback path. Install via pip/brew:
|
|
29
|
+
```bash
|
|
30
|
+
pip install mitmproxy
|
|
31
|
+
```
|
|
32
|
+
- **[Bun](https://bun.sh)** >= 1.3.14
|
|
33
|
+
- **[OpenCode](https://github.com/anomalyco/opencode)** with plugin support
|
|
34
|
+
|
|
35
|
+
## Configuration
|
|
36
|
+
|
|
37
|
+
Add the plugin to your `opencode.json`:
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"plugin": ["file:///path/to/opencode-windsurf-auth/dist/index.js"]
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Or for npm (once published):
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
{
|
|
49
|
+
"plugin": ["opencode-windsurf-auth"]
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Provider Registration
|
|
54
|
+
|
|
55
|
+
The provider SDK uses the `providerID` `windsurf-devin-provider`. The model
|
|
56
|
+
entries in `models.ts` expose `api.npm` pointing to this package (via `file://`
|
|
57
|
+
for local dev, or the package name for published use).
|
|
58
|
+
|
|
59
|
+
The package exports both:
|
|
60
|
+
- **`createWindsurf`** (named) — consumed by the provider SDK loader's
|
|
61
|
+
`create*` key scan.
|
|
62
|
+
- **default export (V1 plugin)** — `{ id: "windsurf-auth", server: WindsurfPlugin }`
|
|
63
|
+
consumed by the plugin loader's `readV1Plugin` path.
|
|
64
|
+
|
|
65
|
+
These two consumers look at different slots on the module namespace and do
|
|
66
|
+
not conflict.
|
|
67
|
+
|
|
68
|
+
## Architecture
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
src/
|
|
72
|
+
├── index.ts # Entry point: createWindsurf + V1 plugin default
|
|
73
|
+
├── windsurf-provider.ts # LanguageModelV3 impl (doGenerate / doStream)
|
|
74
|
+
├── chat-client.ts # HTTP/2 Connect-RPC streaming client
|
|
75
|
+
├── chat-request.ts # Protobuf GetChatMessageRequest encoder
|
|
76
|
+
├── proto.ts # Low-level varint/field/Connect-frame helpers
|
|
77
|
+
├── credentials.ts # JWT loader from Devin CLI credentials.toml
|
|
78
|
+
├── models.ts # ~130+ model definitions
|
|
79
|
+
└── thinking-proxy.ts # mitmproxy-based fallback for reasoning/thinking
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Transport
|
|
83
|
+
|
|
84
|
+
- **Primary path**: Direct HTTP/2 to `server.codeium.com` via Bun's
|
|
85
|
+
built-in ALPN-aware `fetch()`.
|
|
86
|
+
- **Fallback path** (thinking-aware): `thinking-proxy.ts` spawns
|
|
87
|
+
`mitmdump` + `devin -p` and tails a JSONLines sink.
|
|
88
|
+
|
|
89
|
+
## Development
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
bun install
|
|
93
|
+
bun typecheck # type-check the source
|
|
94
|
+
bun run build # compile to dist/
|
|
95
|
+
bun test # run unit tests
|
|
96
|
+
bun run dev # build + symlink into .opencode/plugins/ + watch
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The `bun run dev` script creates a symlink at
|
|
100
|
+
`.opencode/plugins/windsurf-auth.js` → `dist/index.js` so opencode can
|
|
101
|
+
load the plugin via `file://` while you iterate.
|
|
102
|
+
|
|
103
|
+
## License
|
|
104
|
+
|
|
105
|
+
MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { WINDSURF_MODELS } from "../models";
|
|
3
|
+
describe("WINDSURF_MODELS", () => {
|
|
4
|
+
test("record is non-empty", () => {
|
|
5
|
+
const entries = Object.entries(WINDSURF_MODELS);
|
|
6
|
+
expect(entries.length).toBeGreaterThan(0);
|
|
7
|
+
});
|
|
8
|
+
test("every model has id starting with windsurf/", () => {
|
|
9
|
+
for (const model of Object.values(WINDSURF_MODELS)) {
|
|
10
|
+
expect(model.id.startsWith("windsurf/")).toBe(true);
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
test("every model has api.npm containing windsurf-auth", () => {
|
|
14
|
+
for (const [_, model] of Object.entries(WINDSURF_MODELS)) {
|
|
15
|
+
expect(model.api.npm).toBeString();
|
|
16
|
+
expect(model.api.npm.includes("windsurf-auth")).toBe(true);
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
test("every model has a non-empty api.id", () => {
|
|
20
|
+
for (const model of Object.values(WINDSURF_MODELS)) {
|
|
21
|
+
expect(model.api.id.length).toBeGreaterThan(0);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
test("summary", () => {
|
|
25
|
+
const entries = Object.entries(WINDSURF_MODELS);
|
|
26
|
+
const counts = {};
|
|
27
|
+
for (const [, model] of entries) {
|
|
28
|
+
const prefix = model.id.split("/")[1]?.split("-")[0] ?? "unknown";
|
|
29
|
+
counts[prefix] = (counts[prefix] ?? 0) + 1;
|
|
30
|
+
}
|
|
31
|
+
console.log(`\nTotal models: ${entries.length}`);
|
|
32
|
+
console.log("By id prefix:");
|
|
33
|
+
for (const [prefix, count] of Object.entries(counts).sort(([, a], [, b]) => b - a)) {
|
|
34
|
+
console.log(` ${prefix}: ${count}`);
|
|
35
|
+
}
|
|
36
|
+
expect(entries.length).toBeGreaterThan(100);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type ResponseEvent = {
|
|
2
|
+
type: "text";
|
|
3
|
+
delta: string;
|
|
4
|
+
} | {
|
|
5
|
+
type: "reasoning";
|
|
6
|
+
delta: string;
|
|
7
|
+
} | {
|
|
8
|
+
type: "tool-call-start";
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
} | {
|
|
12
|
+
type: "tool-call-delta";
|
|
13
|
+
id: string;
|
|
14
|
+
argsChunk: string;
|
|
15
|
+
} | {
|
|
16
|
+
type: "finish";
|
|
17
|
+
model?: string;
|
|
18
|
+
inputTokens?: number;
|
|
19
|
+
outputTokens?: number;
|
|
20
|
+
msgId?: string;
|
|
21
|
+
stopReason?: number;
|
|
22
|
+
};
|
|
23
|
+
export declare function streamGetChatMessage(requestBody: Uint8Array, signal?: AbortSignal): AsyncGenerator<ResponseEvent>;
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
// chat-client.ts — HTTP/2 Connect-RPC streaming client for Windsurf GetChatMessage
|
|
2
|
+
//
|
|
3
|
+
// Transport: Bun fetch with HTTPS (auto-negotiates HTTP/2 via ALPN).
|
|
4
|
+
// Bun's fetch supports streaming response bodies via response.body.getReader().
|
|
5
|
+
// This is the preferred approach over node:http2 because:
|
|
6
|
+
// 1. Bun fetch negotiates HTTP/2 automatically for HTTPS URLs
|
|
7
|
+
// 2. It provides a clean ReadableStream API for reading response bytes
|
|
8
|
+
// 3. No manual ALPN / TLS configuration needed
|
|
9
|
+
// Verified: earlier Python replay experiments used raw h2 connection and
|
|
10
|
+
// confirmed the server accepts HTTP/2 with Connect-RPC content-type.
|
|
11
|
+
//
|
|
12
|
+
// Response field map from:
|
|
13
|
+
// opencode-windsurf-auth/research/connect_decode.py
|
|
14
|
+
// GetChatMessageResponse: f3=delta_text, f9=delta_thinking, f5=stop_reason,
|
|
15
|
+
// f10+f21=="anthropic"=signature frame, f7=stats sub-msg
|
|
16
|
+
import { parseConnectFrames, CONNECT_FLAG_EOS } from "./proto";
|
|
17
|
+
const ENDPOINT = "https://server.codeium.com/exa.api_server_pb.ApiServerService/GetChatMessage";
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Low-level protobuf field walking (mirrors connect_decode.py)
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
function parseVarint(buf, offset) {
|
|
22
|
+
let result = 0;
|
|
23
|
+
let shift = 0;
|
|
24
|
+
while (true) {
|
|
25
|
+
const byte = buf[offset];
|
|
26
|
+
offset++;
|
|
27
|
+
result |= (byte & 0x7f) << shift;
|
|
28
|
+
if (!(byte & 0x80))
|
|
29
|
+
break;
|
|
30
|
+
shift += 7;
|
|
31
|
+
}
|
|
32
|
+
return [result, offset];
|
|
33
|
+
}
|
|
34
|
+
function walkFields(body) {
|
|
35
|
+
const fields = [];
|
|
36
|
+
let i = 0;
|
|
37
|
+
while (i < body.byteLength) {
|
|
38
|
+
const [tag, newI] = parseVarint(body, i);
|
|
39
|
+
i = newI;
|
|
40
|
+
const field = tag >> 3;
|
|
41
|
+
const wire = tag & 7;
|
|
42
|
+
if (wire === 0) {
|
|
43
|
+
const [v, ni] = parseVarint(body, i);
|
|
44
|
+
i = ni;
|
|
45
|
+
fields.push({ field, wire, value: v });
|
|
46
|
+
}
|
|
47
|
+
else if (wire === 2) {
|
|
48
|
+
const [ln, ni] = parseVarint(body, i);
|
|
49
|
+
i = ni;
|
|
50
|
+
const val = body.slice(i, i + ln);
|
|
51
|
+
i += ln;
|
|
52
|
+
fields.push({ field, wire, value: val });
|
|
53
|
+
}
|
|
54
|
+
else if (wire === 1) {
|
|
55
|
+
i += 8;
|
|
56
|
+
fields.push({ field, wire, value: 0 });
|
|
57
|
+
}
|
|
58
|
+
else if (wire === 5) {
|
|
59
|
+
i += 4;
|
|
60
|
+
fields.push({ field, wire, value: 0 });
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return fields;
|
|
67
|
+
}
|
|
68
|
+
function fieldDict(body) {
|
|
69
|
+
const m = new Map();
|
|
70
|
+
for (const f of walkFields(body)) {
|
|
71
|
+
m.set(f.field, f.value);
|
|
72
|
+
}
|
|
73
|
+
return m;
|
|
74
|
+
}
|
|
75
|
+
function extractString(body, fieldNum) {
|
|
76
|
+
for (const f of walkFields(body)) {
|
|
77
|
+
if (f.field !== fieldNum || f.wire !== 2)
|
|
78
|
+
continue;
|
|
79
|
+
const val = f.value;
|
|
80
|
+
if (!(val instanceof Uint8Array))
|
|
81
|
+
continue;
|
|
82
|
+
try {
|
|
83
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(val);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// try nested
|
|
87
|
+
for (const sf of walkFields(val)) {
|
|
88
|
+
if (sf.wire !== 2 || !(sf.value instanceof Uint8Array))
|
|
89
|
+
continue;
|
|
90
|
+
try {
|
|
91
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(sf.value);
|
|
92
|
+
}
|
|
93
|
+
catch { }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
function isSignatureFrame(body) {
|
|
100
|
+
const fd = fieldDict(body);
|
|
101
|
+
const f21 = fd.get(21);
|
|
102
|
+
if (f21 instanceof Uint8Array) {
|
|
103
|
+
try {
|
|
104
|
+
if (new TextDecoder().decode(f21) === "anthropic")
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
catch { }
|
|
108
|
+
}
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
function getStopReason(body) {
|
|
112
|
+
const f5 = fieldDict(body).get(5);
|
|
113
|
+
if (typeof f5 === "number")
|
|
114
|
+
return f5;
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
function extractToolCall(body) {
|
|
118
|
+
const f6 = fieldDict(body).get(6);
|
|
119
|
+
if (!(f6 instanceof Uint8Array))
|
|
120
|
+
return null;
|
|
121
|
+
const sf = fieldDict(f6);
|
|
122
|
+
const result = {};
|
|
123
|
+
const f1 = sf.get(1);
|
|
124
|
+
if (f1 instanceof Uint8Array) {
|
|
125
|
+
try {
|
|
126
|
+
result.id = new TextDecoder().decode(f1);
|
|
127
|
+
}
|
|
128
|
+
catch { }
|
|
129
|
+
}
|
|
130
|
+
const f2 = sf.get(2);
|
|
131
|
+
if (f2 instanceof Uint8Array) {
|
|
132
|
+
try {
|
|
133
|
+
result.name = new TextDecoder().decode(f2);
|
|
134
|
+
}
|
|
135
|
+
catch { }
|
|
136
|
+
}
|
|
137
|
+
const f3 = sf.get(3);
|
|
138
|
+
if (f3 instanceof Uint8Array) {
|
|
139
|
+
try {
|
|
140
|
+
result.argsChunk = new TextDecoder().decode(f3);
|
|
141
|
+
}
|
|
142
|
+
catch { }
|
|
143
|
+
}
|
|
144
|
+
if (result.id || result.name || result.argsChunk)
|
|
145
|
+
return result;
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
function extractStats(body) {
|
|
149
|
+
const fd = fieldDict(body);
|
|
150
|
+
const f7 = fd.get(7);
|
|
151
|
+
if (!(f7 instanceof Uint8Array))
|
|
152
|
+
return null;
|
|
153
|
+
const sf = fieldDict(f7);
|
|
154
|
+
const result = {};
|
|
155
|
+
const modelBytes = sf.get(9);
|
|
156
|
+
if (modelBytes instanceof Uint8Array) {
|
|
157
|
+
try {
|
|
158
|
+
result.model = new TextDecoder().decode(modelBytes);
|
|
159
|
+
}
|
|
160
|
+
catch { }
|
|
161
|
+
}
|
|
162
|
+
const inputF4 = sf.get(4);
|
|
163
|
+
const inputF3 = sf.get(3);
|
|
164
|
+
if (typeof inputF4 === "number") {
|
|
165
|
+
result.inputTokens = inputF4;
|
|
166
|
+
if (typeof inputF3 === "number")
|
|
167
|
+
result.cacheCreationInputTokens = inputF3;
|
|
168
|
+
}
|
|
169
|
+
else if (typeof inputF3 === "number") {
|
|
170
|
+
result.inputTokens = inputF3;
|
|
171
|
+
}
|
|
172
|
+
const outputTokens = sf.get(5);
|
|
173
|
+
if (typeof outputTokens === "number")
|
|
174
|
+
result.outputTokens = outputTokens;
|
|
175
|
+
const msgIdBytes = sf.get(7);
|
|
176
|
+
if (msgIdBytes instanceof Uint8Array) {
|
|
177
|
+
try {
|
|
178
|
+
result.msgId = new TextDecoder().decode(msgIdBytes);
|
|
179
|
+
}
|
|
180
|
+
catch { }
|
|
181
|
+
}
|
|
182
|
+
return Object.keys(result).length > 0 ? result : null;
|
|
183
|
+
}
|
|
184
|
+
function freshDecodeState() {
|
|
185
|
+
return { currentToolCallId: null, emittedFinish: false };
|
|
186
|
+
}
|
|
187
|
+
function mergeStats(state, stats) {
|
|
188
|
+
if (!stats)
|
|
189
|
+
return state;
|
|
190
|
+
const result = { ...state };
|
|
191
|
+
if (stats.model !== undefined)
|
|
192
|
+
result.model = stats.model;
|
|
193
|
+
if (stats.inputTokens !== undefined)
|
|
194
|
+
result.inputTokens = stats.inputTokens;
|
|
195
|
+
if (stats.outputTokens !== undefined)
|
|
196
|
+
result.outputTokens = stats.outputTokens;
|
|
197
|
+
if (stats.msgId !== undefined)
|
|
198
|
+
result.msgId = stats.msgId;
|
|
199
|
+
return result;
|
|
200
|
+
}
|
|
201
|
+
function buildFinishEvent(state, stopReason) {
|
|
202
|
+
return {
|
|
203
|
+
type: "finish",
|
|
204
|
+
model: state.model,
|
|
205
|
+
inputTokens: state.inputTokens,
|
|
206
|
+
outputTokens: state.outputTokens,
|
|
207
|
+
msgId: state.msgId,
|
|
208
|
+
stopReason,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
function decodeFrame(body, state) {
|
|
212
|
+
// signature frame: skip (redacted thinking → answer boundary)
|
|
213
|
+
if (isSignatureFrame(body))
|
|
214
|
+
return { event: null, state };
|
|
215
|
+
// tool call sub-message f6
|
|
216
|
+
const toolCall = extractToolCall(body);
|
|
217
|
+
if (toolCall) {
|
|
218
|
+
// frame with f6.f1+f2 (id+name): start of a new tool call
|
|
219
|
+
if (toolCall.id) {
|
|
220
|
+
const newState = { ...state, currentToolCallId: toolCall.id };
|
|
221
|
+
if (toolCall.name) {
|
|
222
|
+
return { event: { type: "tool-call-start", id: toolCall.id, name: toolCall.name }, state: newState };
|
|
223
|
+
}
|
|
224
|
+
// id-only f6 frame: update tracking id, check for args chunk too
|
|
225
|
+
if (toolCall.argsChunk) {
|
|
226
|
+
return { event: { type: "tool-call-delta", id: toolCall.id, argsChunk: toolCall.argsChunk }, state: newState };
|
|
227
|
+
}
|
|
228
|
+
return { event: null, state: newState };
|
|
229
|
+
}
|
|
230
|
+
// frame with f6.f3 only (args delta): use the tracked current tool call id
|
|
231
|
+
if (toolCall.argsChunk) {
|
|
232
|
+
const id = state.currentToolCallId ?? "0";
|
|
233
|
+
return { event: { type: "tool-call-delta", id, argsChunk: toolCall.argsChunk }, state };
|
|
234
|
+
}
|
|
235
|
+
return { event: null, state };
|
|
236
|
+
}
|
|
237
|
+
// stop frame: any stop_reason from f5 (4=end_turn, 10=tool_use)
|
|
238
|
+
const stopReason = getStopReason(body);
|
|
239
|
+
if (stopReason !== null) {
|
|
240
|
+
// accumulate any stats in this frame too, then emit the single terminal finish
|
|
241
|
+
const stats = extractStats(body);
|
|
242
|
+
const finalState = mergeStats(mergeStats(state, stats), null);
|
|
243
|
+
return {
|
|
244
|
+
event: buildFinishEvent(finalState, stopReason),
|
|
245
|
+
state: { ...finalState, emittedFinish: true },
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
// text / reasoning / stats-only frames — accumulate stats silently, never emit finish
|
|
249
|
+
let newState = state;
|
|
250
|
+
const stats = extractStats(body);
|
|
251
|
+
if (stats)
|
|
252
|
+
newState = mergeStats(newState, stats);
|
|
253
|
+
const deltaThinking = extractString(body, 9);
|
|
254
|
+
if (deltaThinking !== null) {
|
|
255
|
+
return { event: { type: "reasoning", delta: deltaThinking }, state: newState };
|
|
256
|
+
}
|
|
257
|
+
const deltaText = extractString(body, 3);
|
|
258
|
+
if (deltaText !== null) {
|
|
259
|
+
return { event: { type: "text", delta: deltaText }, state: newState };
|
|
260
|
+
}
|
|
261
|
+
// stats-only frame (no delta, no stop, no tool-call) — already accumulated above
|
|
262
|
+
return { event: null, state: newState };
|
|
263
|
+
}
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
// Main streaming client
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
export async function* streamGetChatMessage(requestBody, signal) {
|
|
268
|
+
const resp = await fetch(ENDPOINT, {
|
|
269
|
+
method: "POST",
|
|
270
|
+
headers: {
|
|
271
|
+
"content-type": "application/connect+proto",
|
|
272
|
+
"connect-protocol-version": "1",
|
|
273
|
+
},
|
|
274
|
+
body: Buffer.from(requestBody),
|
|
275
|
+
signal,
|
|
276
|
+
});
|
|
277
|
+
if (!resp.ok) {
|
|
278
|
+
const errText = await resp.text().catch(() => "");
|
|
279
|
+
throw new Error(`GetChatMessage HTTP ${resp.status}: ${errText.slice(0, 500)}`);
|
|
280
|
+
}
|
|
281
|
+
const reader = resp.body?.getReader();
|
|
282
|
+
if (!reader)
|
|
283
|
+
throw new Error("Response body is not readable");
|
|
284
|
+
let buffer = new Uint8Array(0);
|
|
285
|
+
let decodeState = freshDecodeState();
|
|
286
|
+
try {
|
|
287
|
+
while (true) {
|
|
288
|
+
if (signal?.aborted)
|
|
289
|
+
break;
|
|
290
|
+
const { done, value } = await reader.read();
|
|
291
|
+
if (done)
|
|
292
|
+
break;
|
|
293
|
+
// Append to buffer
|
|
294
|
+
if (value) {
|
|
295
|
+
const newBuf = new Uint8Array(buffer.byteLength + value.byteLength);
|
|
296
|
+
newBuf.set(buffer);
|
|
297
|
+
newBuf.set(value, buffer.byteLength);
|
|
298
|
+
buffer = newBuf;
|
|
299
|
+
}
|
|
300
|
+
// Parse all complete frames from buffer
|
|
301
|
+
let remaining = buffer;
|
|
302
|
+
const frames = parseConnectFrames(remaining);
|
|
303
|
+
if (frames.length === 0)
|
|
304
|
+
continue;
|
|
305
|
+
// Calculate consumed bytes
|
|
306
|
+
let consumed = 0;
|
|
307
|
+
for (const frame of frames) {
|
|
308
|
+
consumed += 5 + frame.body.byteLength;
|
|
309
|
+
}
|
|
310
|
+
buffer = buffer.slice(consumed);
|
|
311
|
+
for (const frame of frames) {
|
|
312
|
+
if (frame.flag & CONNECT_FLAG_EOS)
|
|
313
|
+
continue; // skip EOS/trailer frame
|
|
314
|
+
const { event, state: newState } = decodeFrame(frame.body, decodeState);
|
|
315
|
+
decodeState = newState;
|
|
316
|
+
if (event)
|
|
317
|
+
yield event;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
finally {
|
|
322
|
+
reader.releaseLock();
|
|
323
|
+
}
|
|
324
|
+
// Terminal — if the stream ended (EOS) without an explicit stop_reason frame,
|
|
325
|
+
// emit exactly one finish with accumulated stats.
|
|
326
|
+
if (!decodeState.emittedFinish) {
|
|
327
|
+
yield buildFinishEvent(decodeState);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface GetChatMessageInput {
|
|
2
|
+
jwt: string;
|
|
3
|
+
systemPrompt: string;
|
|
4
|
+
messages: Array<{
|
|
5
|
+
role: number;
|
|
6
|
+
content?: string;
|
|
7
|
+
toolCall?: {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
argumentsJson: string;
|
|
11
|
+
};
|
|
12
|
+
toolResult?: {
|
|
13
|
+
toolCallId: string;
|
|
14
|
+
};
|
|
15
|
+
}>;
|
|
16
|
+
tools: Array<{
|
|
17
|
+
name: string;
|
|
18
|
+
description: string;
|
|
19
|
+
parametersJsonSchema: string;
|
|
20
|
+
}>;
|
|
21
|
+
modelId: string;
|
|
22
|
+
sessionId?: string;
|
|
23
|
+
}
|
|
24
|
+
export declare function encodeGetChatMessageRequest(input: GetChatMessageInput): Uint8Array;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// chat-request.ts — encode GetChatMessageRequest protobuf + Connect framing
|
|
2
|
+
//
|
|
3
|
+
// Field map from:
|
|
4
|
+
// opencode-windsurf-auth/research/GETCHATMESSAGE_REQUEST_PROTO.md
|
|
5
|
+
// opencode-windsurf-auth/research/decode_request.py
|
|
6
|
+
//
|
|
7
|
+
// Validation: the output of encodeGetChatMessageRequest can be round-trip
|
|
8
|
+
// decoded with decode_request.py to verify field counts and structure.
|
|
9
|
+
import { encodeConnectFrame, encodeFixed64Field, encodeMessageField, encodeStringField, encodeVarintField, } from "./proto";
|
|
10
|
+
function uuid() {
|
|
11
|
+
return crypto.randomUUID();
|
|
12
|
+
}
|
|
13
|
+
function concat(...arrays) {
|
|
14
|
+
const total = arrays.reduce((s, a) => s + a.byteLength, 0);
|
|
15
|
+
const result = new Uint8Array(total);
|
|
16
|
+
let offset = 0;
|
|
17
|
+
for (const a of arrays) {
|
|
18
|
+
result.set(a, offset);
|
|
19
|
+
offset += a.byteLength;
|
|
20
|
+
}
|
|
21
|
+
return result;
|
|
22
|
+
}
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Sub-message builders
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
/** Build the client_info sub-message (f1). */
|
|
27
|
+
function encodeClientInfo(jwt) {
|
|
28
|
+
return concat(encodeStringField(1, "chisel"), // f1: client_name
|
|
29
|
+
encodeStringField(2, "2026.5.26-2"), // f2: client_version
|
|
30
|
+
encodeStringField(3, `devin-session-token$${jwt}`), // f3: jwt_token
|
|
31
|
+
encodeStringField(4, "en"), // f4: locale
|
|
32
|
+
encodeStringField(5, "mac"), // f5: platform
|
|
33
|
+
encodeStringField(7, "2026.5.26-2"), // f7: client_version_2
|
|
34
|
+
encodeStringField(12, "chisel"));
|
|
35
|
+
}
|
|
36
|
+
/** Build the ChatToolCall sub-message used in f6 of role=2 messages and on the response side.
|
|
37
|
+
* Fields: f1=tool_call_id, f2=tool_name, f3=arguments_json. */
|
|
38
|
+
function encodeChatToolCall(tc) {
|
|
39
|
+
return concat(encodeStringField(1, tc.id), encodeStringField(2, tc.name), encodeStringField(3, tc.argumentsJson));
|
|
40
|
+
}
|
|
41
|
+
/** Build one message entry (f3 repeated).
|
|
42
|
+
* Role 1 (user): f1=msg_id, f2=1, f3=content_text
|
|
43
|
+
* Role 2 (assistant): f1=msg_id, f2=2, [f3=content_text if non-empty,] f6=ChatToolCall
|
|
44
|
+
* Role 4 (tool_result): f1=msg_id, f2=4, f3=content_text, f7=tool_call_id
|
|
45
|
+
* Default (role||1): same as role=1 for backward compatibility. */
|
|
46
|
+
function encodeMessage(m) {
|
|
47
|
+
const msgId = uuid();
|
|
48
|
+
const role = m.role || 1;
|
|
49
|
+
if (role === 2) {
|
|
50
|
+
const parts = [
|
|
51
|
+
encodeStringField(1, msgId),
|
|
52
|
+
encodeVarintField(2, 2),
|
|
53
|
+
];
|
|
54
|
+
if (m.content)
|
|
55
|
+
parts.push(encodeStringField(3, m.content));
|
|
56
|
+
if (m.toolCall)
|
|
57
|
+
parts.push(encodeMessageField(6, encodeChatToolCall(m.toolCall)));
|
|
58
|
+
return concat(...parts);
|
|
59
|
+
}
|
|
60
|
+
if (role === 4) {
|
|
61
|
+
return concat(encodeStringField(1, msgId), encodeVarintField(2, 4), encodeStringField(3, m.content ?? ""), encodeStringField(7, m.toolResult?.toolCallId ?? ""));
|
|
62
|
+
}
|
|
63
|
+
// role=1 (user) — default path
|
|
64
|
+
return concat(encodeStringField(1, msgId), encodeVarintField(2, role), encodeStringField(3, m.content ?? ""));
|
|
65
|
+
}
|
|
66
|
+
/** Build one tool entry (f10 repeated). */
|
|
67
|
+
function encodeTool(name, description, parametersJsonSchema) {
|
|
68
|
+
return concat(encodeStringField(1, name), encodeStringField(2, description), encodeStringField(3, parametersJsonSchema));
|
|
69
|
+
}
|
|
70
|
+
/** Build the model_params sub-message (f8). */
|
|
71
|
+
function encodeModelParams() {
|
|
72
|
+
return concat(encodeVarintField(1, 1), // f1: unk1 (=1)
|
|
73
|
+
encodeVarintField(2, 128000), // f2: max_context_tokens
|
|
74
|
+
encodeVarintField(3, 400), // f3: max_output_tokens
|
|
75
|
+
encodeFixed64Field(5, 1.0), // f5: temperature
|
|
76
|
+
encodeVarintField(7, 40), // f7: top_k
|
|
77
|
+
encodeFixed64Field(8, 0.95));
|
|
78
|
+
}
|
|
79
|
+
/** Build the unk15 sub-message (f15).
|
|
80
|
+
*
|
|
81
|
+
* Matches the known-good title-gen capture shape: {f1:uuid, f2:1, f3:4}.
|
|
82
|
+
* The main-chat captures additionally carry f4 (a think-budget value) but the
|
|
83
|
+
* minimal title-gen request — proven to be accepted by the server — omits it.
|
|
84
|
+
* We mirror the minimal known-good shape to maximise acceptance. */
|
|
85
|
+
function encodeUnk15() {
|
|
86
|
+
return concat(encodeStringField(1, uuid()), // f1: uuid (per-request)
|
|
87
|
+
encodeVarintField(2, 1), // f2: unk2
|
|
88
|
+
encodeVarintField(3, 4));
|
|
89
|
+
}
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// Main encoder
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
export function encodeGetChatMessageRequest(input) {
|
|
94
|
+
const sessionId = input.sessionId ?? uuid();
|
|
95
|
+
const body = concat(
|
|
96
|
+
// f1: client_info
|
|
97
|
+
encodeMessageField(1, encodeClientInfo(input.jwt)),
|
|
98
|
+
// f2: system_prompt
|
|
99
|
+
encodeStringField(2, input.systemPrompt),
|
|
100
|
+
// f3: messages[] (repeated). Role 1=user, 2=assistant (with optional
|
|
101
|
+
// ChatToolCall in f6), 4=tool_result (with tool_call_id in f7).
|
|
102
|
+
...input.messages.map((m) => encodeMessageField(3, encodeMessage(m))),
|
|
103
|
+
// f7: unk7 = 5
|
|
104
|
+
encodeVarintField(7, 5),
|
|
105
|
+
// f8: model_params
|
|
106
|
+
encodeMessageField(8, encodeModelParams()),
|
|
107
|
+
// f10: tools[] (repeated)
|
|
108
|
+
...input.tools.map((t) => encodeMessageField(10, encodeTool(t.name, t.description, t.parametersJsonSchema))),
|
|
109
|
+
// f15: unk15
|
|
110
|
+
encodeMessageField(15, encodeUnk15()),
|
|
111
|
+
// f16: session_id
|
|
112
|
+
encodeStringField(16, sessionId),
|
|
113
|
+
// f20: unk20 = 1
|
|
114
|
+
encodeVarintField(20, 1),
|
|
115
|
+
// f21: model_id
|
|
116
|
+
encodeStringField(21, input.modelId));
|
|
117
|
+
return encodeConnectFrame(body);
|
|
118
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function loadWindsurfJwt(): Promise<string | null>;
|