feat(oauth): zed/trae/windsurf providers + harden callback proxies

- zed live model discovery; codebuddy-intl handler; remove duplicate workbuddy
- split oauth providers.js into per-provider files (facade re-export)
- fold 5 standard refresh providers into config-driven generic
- hide trae/windsurf from registry (no tool calling support)
- fix login-CSRF + SSRF on trae/windsurf/zed local callback proxies
  via loopback-origin guard + strict state validation + apiOrigins allowlist
- move zed RSA private key transit to POST body; redact proxy logs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
decolua
2026-07-25 17:25:19 +07:00
co-authored by Claude Fable 5
parent 783e271c16
commit 8e04fe1734
46 changed files with 4569 additions and 2203 deletions
-3
View File
@@ -21,7 +21,6 @@ import { XiaomiTokenplanExecutor } from "./xiaomi-tokenplan.js";
import { MimoFreeExecutor } from "./mimo-free.js";
import { CodeBuddyExecutor } from "./codebuddy-cn.js";
import { CodeBuddyIntlExecutor } from "./codebuddy-intl.js";
import { WorkBuddyExecutor } from "./workbuddy.js";
import TraeExecutor from "./trae.js";
import ZedExecutor from "./zed.js";
import WindsurfExecutor from "./windsurf.js";
@@ -56,7 +55,6 @@ const executors = {
mmf: new MimoFreeExecutor(), // Alias for mimo-free
"codebuddy-cn": new CodeBuddyExecutor(),
"codebuddy-intl": new CodeBuddyIntlExecutor(),
workbuddy: new WorkBuddyExecutor(),
trae: new TraeExecutor(),
zed: new ZedExecutor(),
windsurf: new WindsurfExecutor(),
@@ -99,7 +97,6 @@ export { XiaomiTokenplanExecutor } from "./xiaomi-tokenplan.js";
export { MimoFreeExecutor } from "./mimo-free.js";
export { CodeBuddyExecutor } from "./codebuddy-cn.js";
export { CodeBuddyIntlExecutor } from "./codebuddy-intl.js";
export { WorkBuddyExecutor } from "./workbuddy.js";
export { default as TraeExecutor } from "./trae.js";
export { default as ZedExecutor } from "./zed.js";
export { default as WindsurfExecutor } from "./windsurf.js";
+331 -14
View File
@@ -1,22 +1,339 @@
import { DefaultExecutor } from "./default.js";
import { BaseExecutor } from "./base.js";
import { proxyAwareFetch } from "../utils/proxyFetch.js";
import { PROVIDERS } from "../config/providers.js";
// Trae executor — inject x-cloudide-token (raw access token) + Authorization Bearer.
// Mirrors trae_account.rs request_trae_json header set.
export default class TraeExecutor extends DefaultExecutor {
// Trae executor — SOLO remote agent API.
//
// Flow:
// 1. POST {base}/chat_sessions → { code:0, data:{ chat_session_id, message_id } }
// 2. GET {base}/chat_sessions/{id}/events?reply_to_message_id={message_id}
// → text/event-stream. Assistant text streams in `plan_item` events under
// the `thought` field (cumulative per plan-item id). `token_usage` carries
// usage; `done` ends the turn; `error` carries upstream errors.
//
// Auth: header `Authorization: Cloud-IDE-JWT <jwt>` (RS256, ~14-day lifetime).
// Identity fields for common_params live in credentials.providerSpecificData.
const STREAM_TIMEOUT_MS = parseInt(process.env.TRAE_STREAM_TIMEOUT_MS || "300000", 10);
const TRAE_UA =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
function flattenQuery(messages) {
const parts = [];
for (const m of messages) {
let content = "";
if (typeof m.content === "string") content = m.content;
else if (Array.isArray(m.content)) {
content = m.content
.map((p) => {
if (typeof p === "string") return p;
if (p && typeof p === "object") return String(p.text ?? "");
return "";
})
.join("");
}
if (m.role === "system") parts.push(`[System]\n${content}`);
else if (m.role === "assistant") parts.push(`[Assistant]\n${content}`);
else parts.push(content);
}
// Trae expects query as a JSON-encoded string of typed content blocks.
return JSON.stringify([{ type: "text", data: { content: parts.join("\n\n") } }]);
}
export default class TraeExecutor extends BaseExecutor {
constructor() {
super("trae");
super("trae", PROVIDERS.trae);
}
base() {
return (this.config.baseUrl || "https://core-normal.trae.ai/api/remote/v1").replace(/\/$/, "");
}
buildHeaders(credentials, stream = true) {
const headers = super.buildHeaders(credentials, stream);
const token = credentials?.accessToken;
if (token) {
// Raw token (no Bearer prefix) on x-cloudide-token — matches official client.
headers["x-cloudide-token"] = token;
headers["Authorization"] = `Bearer ${token}`;
}
return headers;
const token = credentials?.accessToken || "";
const psd = credentials?.providerSpecificData || {};
return {
Authorization: `Cloud-IDE-JWT ${token}`,
"Content-Type": "application/json",
"X-Trae-Client-Type": "web",
"X-Preferenced-Language": psd.appLanguage || "en",
"x-user-region": psd.userRegion || "US",
Referer: "https://solo.trae.ai/",
"User-Agent": TRAE_UA,
Accept: stream ? "text/event-stream" : "application/json",
};
}
// TODO verify: if Chat is JSON-RPC shaped, override transformRequest here.
// SOLO session modes: "code" (model picker) vs "work" (fast auto lane).
resolveMode(model) {
const m = (model || "").trim().toLowerCase();
if (m === "work" || m === "auto-work" || m === "solo-work") {
return { mode: "work", strategy: "auto", modelName: "" };
}
const auto = !m || m === "auto";
return { mode: "code", strategy: auto ? "auto" : "manual", modelName: auto ? "" : model };
}
// common_params is a JSON-encoded string embedded inside initial_message.
commonParams(psd, mode, sessionId) {
const cp = {
language: "en-us",
app_language: psd.appLanguage || "en",
quality: "stable",
app_version: psd.appVersion || "1.0.0.1229",
web_id: psd.webId || "",
user_identity: psd.userIdentity || "Free",
is_freshman: "0",
biz_user_id: psd.bizUserId || "",
user_unique_id: psd.userUniqueId || "",
scope: psd.scope || "marscode-us",
tenant: psd.tenant || "marscode",
region: psd.region || "US-East",
aiRegion: psd.aiRegion || psd.region || "US-East",
is_privacy_mode: 0,
privacy_mode: "off",
solo_chat_mode: mode,
};
if (sessionId) cp.biz_session_id = sessionId;
return JSON.stringify(cp);
}
// POST /chat_sessions — creates a session and submits the first turn.
async createSession(headers, query, model, psd, signal) {
const { mode, strategy, modelName } = this.resolveMode(model);
const body = {
mode,
environment_id: "default",
initial_message: {
chat_session_id: "",
content: [],
query,
model_name: modelName,
agent_type: "solo_agent_remote",
model_selection_strategy: strategy,
common_params: this.commonParams(psd, mode),
},
env: "remote",
auto_create_project: false,
origin: "web",
};
const res = await proxyAwareFetch(`${this.base()}/chat_sessions`, {
method: "POST",
headers,
body: JSON.stringify(body),
signal,
}, null);
const text = await res.text();
if (!res.ok) throw new Error(`[${res.status}] ${text}`);
const json = JSON.parse(text);
if (json?.code !== 0) throw new Error(`Trae create_session: ${JSON.stringify(json)}`);
return { sessionId: json.data.chat_session_id, messageId: json.data.message_id };
}
// GET /events SSE → invoke onEvent(eventType, dataObj) per frame.
// Resolves when `done`/`error` arrives, the stream ends, or timeout fires.
async streamEvents(headers, sessionId, replyTo, onEvent, signal) {
const url = `${this.base()}/chat_sessions/${sessionId}/events?reply_to_message_id=${encodeURIComponent(replyTo)}`;
const ctrl = new AbortController();
if (signal?.aborted) ctrl.abort();
const timer = setTimeout(() => ctrl.abort(new Error("trae stream timeout")), STREAM_TIMEOUT_MS);
const onAbort = () => ctrl.abort();
if (signal) signal.addEventListener("abort", onAbort, { once: true });
try {
const res = await proxyAwareFetch(url, { method: "GET", headers, signal: ctrl.signal }, null);
if (!res.ok || !res.body) throw new Error(`[${res.status}] events stream failed`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
let ev = null;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
let nl;
while ((nl = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, nl).replace(/\r$/, "");
buf = buf.slice(nl + 1);
if (line.startsWith("event:")) ev = line.slice(6).trim();
else if (line.startsWith("data:")) {
const payload = line.slice(5).trim();
let data;
try { data = JSON.parse(payload); } catch { data = { _raw: payload }; }
if (onEvent(ev, data)) {
await reader.cancel().catch(() => {});
return;
}
} else if (line === "") ev = null;
}
}
} finally {
clearTimeout(timer);
if (signal) signal.removeEventListener("abort", onAbort);
}
}
async execute({ model, body, stream, credentials, signal }) {
const headers = this.buildHeaders(credentials, stream !== false);
const psd = credentials?.providerSpecificData || {};
const query = flattenQuery(body?.messages || []);
const responseId = `chatcmpl-trae-${Date.now()}`;
const created = Math.floor(Date.now() / 1000);
const errResponse = (status, message) => new Response(
JSON.stringify({ error: { message, type: "api_error", code: "" } }),
{ status, headers: { "Content-Type": "application/json" } }
);
let session;
try {
session = await this.createSession(headers, query, model, psd, signal);
} catch (err) {
return { response: errResponse(502, err?.message ? String(err.message) : String(err)), url: this.base(), headers, transformedBody: body };
}
// Shared per-turn state: plan_item thoughts (cumulative, longest wins).
const order = [];
const thoughts = {};
let sent = 0;
let usage = null;
let errorEvent = null;
const renderNewText = (data) => {
const pid = data.id;
if (!pid) return "";
if (!(pid in thoughts)) order.push(pid);
const t = data.thought || "";
if (t.length >= (thoughts[pid] || "").length) thoughts[pid] = t;
const full = order.map((i) => thoughts[i]).join("");
const piece = full.slice(sent);
sent = full.length;
return piece;
};
if (stream !== false) {
const enc = new TextEncoder();
const sse = new ReadableStream({
start: async (controller) => {
const emit = (obj) => controller.enqueue(enc.encode(`data: ${JSON.stringify(obj)}\n\n`));
emit({
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
});
try {
await this.streamEvents(headers, session.sessionId, session.messageId, (ev, data) => {
if (ev === "error") { errorEvent = data; return true; }
if (ev === "token_usage") usage = data;
if (ev === "plan_item") {
const piece = renderNewText(data);
if (piece) {
emit({
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: { content: piece }, finish_reason: null }],
});
}
}
return ev === "done";
}, signal);
if (errorEvent) {
emit({
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [],
error: { message: `trae ${errorEvent.code || ""}: ${errorEvent.message || ""}`, type: "api_error" },
});
} else {
emit({
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
});
if (usage) {
emit({
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [],
usage: {
prompt_tokens: usage.prompt_tokens || 0,
completion_tokens: usage.completion_tokens || 0,
total_tokens: usage.total_tokens || 0,
},
});
}
}
controller.enqueue(enc.encode("data: [DONE]\n\n"));
controller.close();
} catch (err) {
controller.error(err);
}
},
});
return {
response: new Response(sse, {
status: 200,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
}),
url: this.base(),
headers,
transformedBody: body,
};
}
// Non-streaming: drive to completion, return chat.completion JSON.
try {
await this.streamEvents(headers, session.sessionId, session.messageId, (ev, data) => {
if (ev === "error") { errorEvent = data; return true; }
if (ev === "token_usage") usage = data;
if (ev === "plan_item") renderNewText(data);
return ev === "done";
}, signal);
} catch (err) {
return { response: errResponse(502, err?.message ? String(err.message) : String(err)), url: this.base(), headers, transformedBody: body };
}
if (errorEvent) {
return { response: errResponse(502, `trae ${errorEvent.code || ""}: ${errorEvent.message || ""}`), url: this.base(), headers, transformedBody: body };
}
const content = order.map((i) => thoughts[i]).join("");
const out = {
id: responseId,
object: "chat.completion",
created,
model,
choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }],
};
if (usage) {
out.usage = {
prompt_tokens: usage.prompt_tokens || 0,
completion_tokens: usage.completion_tokens || 0,
total_tokens: usage.total_tokens || 0,
};
}
return {
response: new Response(JSON.stringify(out), { status: 200, headers: { "Content-Type": "application/json" } }),
url: this.base(),
headers,
transformedBody: body,
};
}
// Refresh hook placeholder — Cloud-IDE-JWT is long-lived (~14d); refresh via
// ExchangeToken (refresh→access) is wired in services/tokenRefresh/providers.js.
async refreshCredentials() {
return null;
}
}
+572 -26
View File
@@ -1,40 +1,586 @@
import { DefaultExecutor } from "./default.js";
import { BaseExecutor } from "./base.js";
import { proxyAwareFetch } from "../utils/proxyFetch.js";
import { PROVIDERS } from "../config/providers.js";
import { randomUUID } from "node:crypto";
// Windsurf chat = Codeium binary protobuf gRPC-Web.
// The .proto schema for exa.server_pb.ServerService is NOT in either source
// repo, so request/response encode+decode cannot be implemented truthfully.
// Auth, headers, quota are wired; the chat payload is intentionally a hard
// failure rather than a fabricated protobuf body.
export class WindsurfExecutor extends DefaultExecutor {
// WindsurfExecutor — Codeium gRPC-web chat.
//
// Wire protocol: gRPC-web over HTTPS (Content-Type: application/grpc-web+proto).
// Service: exa.language_server_pb.LanguageServerService
// Method: GetChatMessage (unary request → streamed CompletionChunk frames)
//
// Auth: credentials.accessToken = Codeium apiKey (sk-ws-... or Firebase-derived)
// — placed in Metadata.api_key protobuf field of every request + Bearer header.
const WS_BASE_URL = "https://server.codeium.com";
const WS_SERVICE = "exa.language_server_pb.LanguageServerService";
const WS_METHOD_CHAT = "GetChatMessage";
const WS_CHAT_URL = `${WS_BASE_URL}/${WS_SERVICE}/${WS_METHOD_CHAT}`;
const WS_IDE_NAME = "windsurf";
const WS_IDE_VERSION = "3.14.0";
const WS_EXT_VERSION = "3.14.0";
const WS_LOCALE = "en-US";
// ─── Model alias map (catalog name → Windsurf wire name) ─────────────────────
const MODEL_ALIAS_MAP = {
// ── Cognition SWE ───────────────────────────────────────────────────────
"swe-1.6-fast": "swe-1-6-fast",
"swe-1.6": "swe-1-6",
"swe-1.5-fast": "swe-1-5-fast",
"swe-1.5": "swe-1-5",
// ── Claude Opus 4.7 — effort-tiered ─────────────────────────────────────
"claude-opus-4.7-max": "claude-opus-4-7-max",
"claude-opus-4.7-xhigh": "claude-opus-4-7-xhigh",
"claude-opus-4.7-high": "claude-opus-4-7-high",
"claude-opus-4.7-medium": "claude-opus-4-7-medium",
"claude-opus-4.7-low": "claude-opus-4-7-low",
"claude-opus-4.7-review": "opus-4-7-review",
// ── Claude Opus/Sonnet 4.6 ──────────────────────────────────────────────
"claude-sonnet-4.6-thinking-1m": "claude-sonnet-4-6-thinking-1m",
"claude-sonnet-4.6-1m": "claude-sonnet-4-6-1m",
"claude-sonnet-4.6-thinking": "claude-sonnet-4-6-thinking",
"claude-sonnet-4.6": "claude-sonnet-4-6",
"claude-opus-4.6-thinking": "claude-opus-4-6-thinking",
"claude-opus-4.6": "claude-opus-4-6",
// ── Claude 4.5 ──────────────────────────────────────────────────────────
"claude-opus-4.5-thinking": "MODEL_CLAUDE_4_5_OPUS_THINKING",
"claude-opus-4.5": "MODEL_CLAUDE_4_5_OPUS",
"claude-sonnet-4.5-thinking": "MODEL_PRIVATE_3",
"claude-sonnet-4.5": "MODEL_PRIVATE_2",
"claude-haiku-4.5": "MODEL_PRIVATE_11",
// ── GPT-5.5 ─────────────────────────────────────────────────────────────
"gpt-5.5-xhigh-fast": "gpt-5-5-xhigh-priority",
"gpt-5.5-high-fast": "gpt-5-5-high-priority",
"gpt-5.5-medium-fast": "gpt-5-5-medium-priority",
"gpt-5.5-low-fast": "gpt-5-5-low-priority",
"gpt-5.5-none-fast": "gpt-5-5-none-priority",
"gpt-5.5-xhigh": "gpt-5-5-xhigh",
"gpt-5.5-high": "gpt-5-5-high",
"gpt-5.5-medium": "gpt-5-5-medium",
"gpt-5.5-low": "gpt-5-5-low",
"gpt-5.5-none": "gpt-5-5-none",
"gpt-5.5-review": "gpt-5-5-review",
"gpt-5.5": "gpt-5-5-medium",
// ── GPT-5.4 ─────────────────────────────────────────────────────────────
"gpt-5.4-xhigh-fast": "gpt-5-4-xhigh-priority",
"gpt-5.4-high-fast": "gpt-5-4-high-priority",
"gpt-5.4-medium-fast": "gpt-5-4-medium-priority",
"gpt-5.4-low-fast": "gpt-5-4-low-priority",
"gpt-5.4-none-fast": "gpt-5-4-none-priority",
"gpt-5.4-xhigh": "gpt-5-4-xhigh",
"gpt-5.4-high": "gpt-5-4-high",
"gpt-5.4-medium": "gpt-5-4-medium",
"gpt-5.4-low": "gpt-5-4-low",
"gpt-5.4-none": "gpt-5-4-none",
"gpt-5.4-mini-xhigh": "gpt-5-4-mini-xhigh",
"gpt-5.4-mini-high": "gpt-5-4-mini-high",
"gpt-5.4-mini-medium": "gpt-5-4-mini-medium",
"gpt-5.4-mini-low": "gpt-5-4-mini-low",
"gpt-5.4": "gpt-5-4-medium",
// ── GPT-5.3-Codex ───────────────────────────────────────────────────────
"gpt-5.3-codex-xhigh-fast": "gpt-5-3-codex-xhigh-priority",
"gpt-5.3-codex-high-fast": "gpt-5-3-codex-high-priority",
"gpt-5.3-codex-medium-fast": "gpt-5-3-codex-medium-priority",
"gpt-5.3-codex-low-fast": "gpt-5-3-codex-low-priority",
"gpt-5.3-codex-xhigh": "gpt-5-3-codex-xhigh",
"gpt-5.3-codex-high": "gpt-5-3-codex-high",
"gpt-5.3-codex-medium": "gpt-5-3-codex-medium",
"gpt-5.3-codex-low": "gpt-5-3-codex-low",
"gpt-5.3-codex": "gpt-5-3-codex-medium",
// ── GPT-5.2 ─────────────────────────────────────────────────────────────
"gpt-5.2-xhigh": "MODEL_GPT_5_2_XHIGH",
"gpt-5.2-high": "MODEL_GPT_5_2_HIGH",
"gpt-5.2-medium": "MODEL_GPT_5_2_MEDIUM",
"gpt-5.2-low": "MODEL_GPT_5_2_LOW",
"gpt-5.2-none": "MODEL_GPT_5_2_NONE",
"gpt-5.2": "MODEL_GPT_5_2_MEDIUM",
// ── GPT-5 ───────────────────────────────────────────────────────────────
"gpt-5": "gpt-5",
// ── GPT-4.1 / 4o ────────────────────────────────────────────────────────
"gpt-4.1": "MODEL_CHAT_GPT_4_1_2025_04_14",
"gpt-4.1-mini": "gpt-4.1-mini",
"gpt-4o": "MODEL_CHAT_GPT_4O_2024_08_06",
// ── Gemini ──────────────────────────────────────────────────────────────
"gemini-3.1-pro-high": "gemini-3-1-pro-high",
"gemini-3.1-pro-low": "gemini-3-1-pro-low",
"gemini-3.1-pro": "gemini-3-1-pro-high",
"gemini-3.0-flash-high": "MODEL_GOOGLE_GEMINI_3_0_FLASH_HIGH",
"gemini-3.0-flash-medium": "MODEL_GOOGLE_GEMINI_3_0_FLASH_MEDIUM",
"gemini-3.0-flash-low": "MODEL_GOOGLE_GEMINI_3_0_FLASH_LOW",
"gemini-3.0-flash-minimal": "MODEL_GOOGLE_GEMINI_3_0_FLASH_MINIMAL",
"gemini-3.0-flash": "MODEL_GOOGLE_GEMINI_3_0_FLASH_HIGH",
"gemini-2.5-pro": "MODEL_GOOGLE_GEMINI_2_5_PRO",
// ── Others ──────────────────────────────────────────────────────────────
"deepseek-v4": "deepseek-v4",
"kimi-k2.6": "kimi-k2-6",
"kimi-k2.5": "kimi-k2-5",
"glm-5.1": "glm-5-1",
};
export function resolveWsModelId(model) {
return MODEL_ALIAS_MAP[model] ?? model;
}
// ─── Minimal protobuf encoder ────────────────────────────────────────────────
// Wire types: 0 = varint, 2 = length-delimited.
function encodeVarint(value) {
const bytes = [];
let v = value >>> 0;
while (v > 0x7f) {
bytes.push((v & 0x7f) | 0x80);
v >>>= 7;
}
bytes.push(v & 0x7f);
return new Uint8Array(bytes);
}
function concatBytes(arrays) {
const total = arrays.reduce((n, a) => n + a.length, 0);
const out = new Uint8Array(total);
let off = 0;
for (const a of arrays) {
out.set(a, off);
off += a.length;
}
return out;
}
const TEXT_ENC = new TextEncoder();
const TEXT_DEC = new TextDecoder();
function encodeField(fieldNum, payload) {
const tag = encodeVarint((fieldNum << 3) | 2);
const len = encodeVarint(payload.length);
return concatBytes([tag, len, payload]);
}
function encodeString(fieldNum, value) {
return encodeField(fieldNum, TEXT_ENC.encode(value));
}
function encodeMessage(fieldNum, msg) {
return encodeField(fieldNum, msg);
}
// ─── Protobuf message builders ───────────────────────────────────────────────
function buildMetadata(apiKey, sessionId) {
return concatBytes([
encodeString(1, apiKey),
encodeString(2, WS_IDE_NAME),
encodeString(3, WS_IDE_VERSION),
encodeString(4, WS_EXT_VERSION),
encodeString(5, sessionId),
encodeString(6, WS_LOCALE),
]);
}
function buildModelOrAlias(model) {
return encodeString(1, model);
}
function buildChatMessage(msg) {
const parts = [encodeString(1, msg.role), encodeString(2, msg.content)];
if (msg.toolCallId) parts.push(encodeString(3, msg.toolCallId));
return concatBytes(parts);
}
export function buildGetChatMessageRequest(apiKey, model, messages) {
const sessionId = randomUUID();
const cascadeId = randomUUID();
const parts = [
encodeMessage(1, buildMetadata(apiKey, sessionId)), // metadata
encodeString(2, cascadeId), // cascade_id
encodeMessage(3, buildModelOrAlias(model)), // model_or_alias
];
for (const msg of messages) {
parts.push(encodeMessage(4, buildChatMessage(msg))); // repeated messages
}
return concatBytes(parts);
}
// ─── gRPC-web framing ────────────────────────────────────────────────────────
export function grpcWebFrame(payload) {
const frame = new Uint8Array(5 + payload.length);
frame[0] = 0x00; // no compression
const view = new DataView(frame.buffer);
view.setUint32(1, payload.length, false); // big-endian length
frame.set(payload, 5);
return frame;
}
// ─── Protobuf response decoder ───────────────────────────────────────────────
// CompletionChunk (oneof):
// field 1 → ContentChunk { field 1: string text }
// field 2 → ToolCallChunk (skipped)
// field 3 → DoneChunk { field 1: UsageStats{ field1: prompt, field2: completion } }
// field 4 → ErrorChunk { field 1: string message }
function readVarint(buf, offset) {
let result = 0;
let shift = 0;
while (offset < buf.length) {
const b = buf[offset++];
result |= (b & 0x7f) << shift;
if ((b & 0x80) === 0) break;
shift += 7;
}
return [result >>> 0, offset];
}
function decodeStringField(buf, targetField) {
let offset = 0;
while (offset < buf.length) {
let tag;
[tag, offset] = readVarint(buf, offset);
const fieldNum = tag >>> 3;
const wireType = tag & 0x07;
if (wireType === 2) {
let len;
[len, offset] = readVarint(buf, offset);
const payload = buf.slice(offset, offset + len);
offset += len;
if (fieldNum === targetField) return TEXT_DEC.decode(payload);
} else if (wireType === 0) {
let v;
[v, offset] = readVarint(buf, offset);
} else if (wireType === 1) {
offset += 8;
} else if (wireType === 5) {
offset += 4;
} else {
break;
}
}
return null;
}
function decodeDoneChunk(buf) {
// DoneChunk: field 1 = UsageStats (nested)
// UsageStats: field 1 = prompt_tokens (varint), field 2 = completion_tokens (varint)
let offset = 0;
let usageBytes = null;
while (offset < buf.length) {
let tag;
[tag, offset] = readVarint(buf, offset);
const fieldNum = tag >>> 3;
const wireType = tag & 0x07;
if (wireType === 2) {
let len;
[len, offset] = readVarint(buf, offset);
if (fieldNum === 1) usageBytes = buf.slice(offset, offset + len);
offset += len;
} else if (wireType === 0) {
let v;
[v, offset] = readVarint(buf, offset);
} else {
break;
}
}
if (!usageBytes) return [0, 0];
let promptTokens = 0;
let completionTokens = 0;
offset = 0;
while (offset < usageBytes.length) {
let tag;
[tag, offset] = readVarint(usageBytes, offset);
const fieldNum = tag >>> 3;
const wireType = tag & 0x07;
if (wireType === 0) {
let v;
[v, offset] = readVarint(usageBytes, offset);
if (fieldNum === 1) promptTokens = v;
else if (fieldNum === 2) completionTokens = v;
} else if (wireType === 2) {
let len;
[len, offset] = readVarint(usageBytes, offset);
offset += len;
} else {
break;
}
}
return [promptTokens, completionTokens];
}
export function decodeCompletionChunk(buf) {
let offset = 0;
while (offset < buf.length) {
let tag;
[tag, offset] = readVarint(buf, offset);
const fieldNum = tag >>> 3;
const wireType = tag & 0x07;
if (wireType === 2) {
let len;
[len, offset] = readVarint(buf, offset);
const payload = buf.slice(offset, offset + len);
offset += len;
if (fieldNum === 1) {
const text = decodeStringField(payload, 1);
if (text !== null) return { kind: "content", text };
} else if (fieldNum === 3) {
const usage = decodeDoneChunk(payload);
return { kind: "done", promptTokens: usage[0], completionTokens: usage[1] };
} else if (fieldNum === 4) {
const msg = decodeStringField(payload, 1);
return { kind: "error", message: msg ?? "unknown windsurf error" };
}
// field 2 = ToolCallChunk — not yet handled; skip
} else if (wireType === 0) {
let v;
[v, offset] = readVarint(buf, offset);
} else if (wireType === 1) {
offset += 8;
} else if (wireType === 5) {
offset += 4;
} else {
break;
}
}
return { kind: "unknown" };
}
// ─── OpenAI messages → Windsurf wire ─────────────────────────────────────────
function openAIMessagesToWs(messages) {
const out = [];
for (const m of messages) {
const role = String(m.role || "user");
let content = "";
if (typeof m.content === "string") {
content = m.content;
} else if (Array.isArray(m.content)) {
for (const part of m.content) {
if (part && typeof part === "object" && part.type === "text") {
content += String(part.text || "");
}
}
}
out.push({ role, content, toolCallId: m.tool_call_id });
}
return out;
}
// ─── WindsurfExecutor ────────────────────────────────────────────────────────
export class WindsurfExecutor extends BaseExecutor {
constructor() {
super("windsurf");
super("windsurf", PROVIDERS.windsurf || { id: "windsurf", baseUrl: WS_CHAT_URL });
}
buildUrl() {
return WS_CHAT_URL;
}
buildHeaders(credentials, stream = true) {
const headers = {
"Content-Type": "application/proto",
"Connect-Protocol-Version": "1",
ideName: "Windsurf",
extensionName: "codeium.windsurf",
...(this.config.headers || {}),
const token = credentials?.accessToken || credentials?.apiKey || "";
return {
"Content-Type": "application/grpc-web+proto",
Accept: "application/grpc-web+proto",
// Codeium apiKey also goes in Metadata.api_key (protobuf field) — see request body.
...(token ? { Authorization: `Bearer ${token}` } : {}),
"User-Agent": `windsurf/${WS_IDE_VERSION}`,
"X-Grpc-Web": "1",
};
// apiKey from RegisterUser (sk-ws-..., Firebase-derived, or Devin ide_token).
const token = credentials?.apiKey || credentials?.accessToken;
if (token) headers["Authorization"] = `Bearer ${token}`;
return headers;
}
// TODO(proto): implement once Codeium server_pb .proto is recovered.
// - encode request: chat history + model + system → protobuf bytes
// - decode response: stream protobuf frames → OpenAI-shaped chunks
async execute() {
throw new Error(
"Windsurf chat (Codeium protobuf) not yet implemented — needs .proto schema. Auth/quota wired."
);
// Request body is built manually in execute() — requires model + messages.
transformRequest() {
return null;
}
async execute({ model, body, stream, credentials, signal, log, upstreamExtraHeaders, proxyOptions = null }) {
const apiKey = credentials?.accessToken || credentials?.apiKey || "";
const wsModel = resolveWsModelId(model);
const b = body ?? {};
const rawMessages = Array.isArray(b.messages) ? b.messages : [];
let wsMessages = openAIMessagesToWs(rawMessages);
if (wsMessages.length === 0) {
wsMessages.push({ role: "user", content: "" });
}
const protoPayload = buildGetChatMessageRequest(apiKey, wsModel, wsMessages);
const framedPayload = grpcWebFrame(protoPayload);
const url = this.buildUrl();
const headers = this.buildHeaders(credentials);
if (upstreamExtraHeaders) Object.assign(headers, upstreamExtraHeaders);
log?.debug?.("WS", `Windsurf → ${wsModel} (${wsMessages.length} messages)`);
const upstream = await proxyAwareFetch(url, {
method: "POST",
headers,
body: framedPayload,
signal,
}, proxyOptions);
if (!upstream.ok && upstream.status !== 200) {
return { response: upstream, url, headers, transformedBody: protoPayload };
}
const sseResponse = this.transformToSSE(upstream, model);
return { response: sseResponse, url, headers, transformedBody: protoPayload };
}
// Convert a gRPC-web binary response into an OpenAI-compatible SSE stream.
transformToSSE(upstream, model) {
const responseId = `chatcmpl-ws-${Date.now()}`;
const created = Math.floor(Date.now() / 1000);
const executor = this;
const sseStream = new ReadableStream({
async start(controller) {
const enc = new TextEncoder();
let roleEmitted = false;
let totalText = "";
let promptTokens = 0;
let completionTokens = 0;
let hadError = null;
const emit = (data) => controller.enqueue(enc.encode(data));
try {
let pending = new Uint8Array(0);
const reader = upstream.body?.getReader();
const handleFrame = (flag, payload) => {
if (flag === 0x80) {
// Trailer frame — contains grpc-status, grpc-message
const trailer = TEXT_DEC.decode(payload);
const statusMatch = /grpc-status:\s*(\d+)/i.exec(trailer);
if (statusMatch && statusMatch[1] !== "0") {
const msgMatch = /grpc-message:\s*(.+)/i.exec(trailer);
hadError = msgMatch
? decodeURIComponent(msgMatch[1].trim())
: `gRPC status ${statusMatch[1]}`;
}
return;
}
if (flag !== 0x00) return; // skip unknown flags
const chunk = executor.constructor.decodeCompletionChunk
? executor.constructor.decodeCompletionChunk(payload)
: decodeCompletionChunk(payload);
if (chunk.kind === "content" && chunk.text) {
totalText += chunk.text;
if (!roleEmitted) {
emit(`data: ${JSON.stringify({
id: responseId, object: "chat.completion.chunk", created, model,
choices: [{ index: 0, delta: { role: "assistant", content: "" }, finish_reason: null }],
})}\n\n`);
roleEmitted = true;
}
emit(`data: ${JSON.stringify({
id: responseId, object: "chat.completion.chunk", created, model,
choices: [{ index: 0, delta: { content: chunk.text }, finish_reason: null }],
})}\n\n`);
} else if (chunk.kind === "done") {
promptTokens = chunk.promptTokens;
completionTokens = chunk.completionTokens;
} else if (chunk.kind === "error") {
hadError = chunk.message;
}
};
const drainFrames = () => {
let offset = 0;
while (offset + 5 <= pending.length) {
const flag = pending[offset];
const len =
(pending[offset + 1] << 24) |
(pending[offset + 2] << 16) |
(pending[offset + 3] << 8) |
pending[offset + 4];
if (len < 0 || offset + 5 + len > pending.length) break;
handleFrame(flag, pending.slice(offset + 5, offset + 5 + len));
offset += 5 + len;
}
if (offset > 0) pending = pending.slice(offset);
};
if (reader) {
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
pending = pending.length === 0 ? value : concatBytes([pending, value]);
drainFrames();
}
} finally {
reader.releaseLock();
}
}
drainFrames();
if (hadError) {
emit(`data: ${JSON.stringify({
error: { message: hadError, type: "windsurf_error", code: "upstream_error" },
})}\n\n`);
emit("data: [DONE]\n\n");
controller.close();
return;
}
// Unary fallback: nothing streamed but text decoded → emit as one chunk.
if (!roleEmitted && totalText) {
emit(`data: ${JSON.stringify({
id: responseId, object: "chat.completion.chunk", created, model,
choices: [{ index: 0, delta: { role: "assistant", content: "" }, finish_reason: null }],
})}\n\n`);
emit(`data: ${JSON.stringify({
id: responseId, object: "chat.completion.chunk", created, model,
choices: [{ index: 0, delta: { content: totalText }, finish_reason: null }],
})}\n\n`);
}
const finishPayload = {
id: responseId, object: "chat.completion.chunk", created, model,
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
};
if (promptTokens > 0 || completionTokens > 0) {
finishPayload.usage = {
prompt_tokens: promptTokens,
completion_tokens: completionTokens,
total_tokens: promptTokens + completionTokens,
};
}
emit(`data: ${JSON.stringify(finishPayload)}\n\n`);
emit("data: [DONE]\n\n");
} catch (err) {
const msg = err?.message ? String(err.message) : String(err);
emit(`data: ${JSON.stringify({
error: { message: `Windsurf stream error: ${msg}`, type: "windsurf_error" },
})}\n\n`);
emit("data: [DONE]\n\n");
}
controller.close();
},
});
return new Response(sseStream, {
status: 200,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
// apiKey is long-lived (Firebase-derived or Devin ide_token); refresh handled out-of-band.
async refreshCredentials() {
// Windsurf apiKey is long-lived (like cursor); refresh handled out-of-band.
return null;
}
}
-31
View File
@@ -1,31 +0,0 @@
import { DefaultExecutor } from "./default.js";
/**
* WorkBuddyExecutor — talks to https://www.codebuddy.cn/v2/chat/completions
*
* WorkBuddy is a B2B/enterprise skin of CodeBuddy CN (same codebuddy.cn
* OpenAI-compatible gateway). Behavior mirrors CodeBuddyExecutor:
* gateway rejects non-stream requests, and reasoning must be surfaced via
* OpenAI-style reasoning_effort + reasoning_summary:"auto" (vendor-native
* thinking shapes are not honored by the unified gateway).
*/
export class WorkBuddyExecutor extends DefaultExecutor {
constructor() {
super("workbuddy");
}
transformRequest(model, body, stream, credentials) {
const transformed = super.transformRequest(model, body, stream, credentials);
transformed.stream = true;
const eff = transformed.reasoning_effort;
if (eff === "none" || eff === "off") {
delete transformed.reasoning_effort;
} else if (eff) {
transformed.reasoning_summary = "auto";
}
return transformed;
}
}
export default WorkBuddyExecutor;
+6 -6
View File
@@ -100,10 +100,11 @@ import p97 from "./xiaomi-tokenplan.js";
import p98 from "./youcom.js";
import p99 from "./alims-intl.js";
import p100 from "./codebuddy-intl.js";
import p101 from "./workbuddy.js";
import p102 from "./trae.js";
// Temporarily hidden — no tool calling support (trae SOLO agent / windsurf gRPC skip ToolCallChunk).
// Re-enable by uncommenting both the import and the array entry below.
// import p102 from "./trae.js";
import p103 from "./zed.js";
import p104 from "./windsurf.js";
// import p104 from "./windsurf.js";
export default [
p0,
@@ -207,8 +208,7 @@ export default [
p98,
p99,
p100,
p101,
p102,
// p102, // trae — hidden, no tool calling
p103,
p104,
// p104, // windsurf — hidden, no tool calling
];
+13 -14
View File
@@ -1,7 +1,8 @@
// Trae (ByteDance marscode) provider registry entry.
// Auth + exchange URLs verified from cockpit-tools/src-tauri/src/modules/trae_oauth.rs.
// Region origins verified from trae_account.rs lines 63-66.
// Chat endpoint path /cloudide/api/v3/trae/Chat is GUESSED (TODO verify upstream).
// Chat = SOLO remote agent API:
// POST {base}/chat_sessions → {data:{chat_session_id, message_id}}
// GET {base}/chat_sessions/{id}/events?reply_to_message_id=... → SSE
// Auth: Authorization: Cloud-IDE-JWT <jwt>
export default {
id: "trae",
alias: "tr",
@@ -20,21 +21,19 @@ export default {
notice: { signupUrl: "https://www.trae.ai" },
},
transport: {
// IDE flow (cockpit-tools verified): x-cloudide-token auth, OpenAI-shaped SSE.
baseUrl: "https://api.marscode.com/cloudide/api/v3/trae/Chat",
// SOLO remote agent base — verified working chat endpoint.
baseUrl: "https://core-normal.trae.ai/api/remote/v1",
format: "openai",
headers: {
"x-app-version": "3.5.54",
"x-app-type": "stable",
"x-env": "production",
"client_id": "ono9krqynydwx5",
"User-Agent": "Trae/1.0.0 antigravity-cockpit-tools",
"X-Trae-Client-Type": "web",
"X-Preferenced-Language": "en",
"Referer": "https://solo.trae.ai/",
},
// Auth: x-cloudide-token + Authorization: Bearer — injected by executor buildHeaders.
// Auth: Cloud-IDE-JWT scheme on Authorization — injected by executor buildHeaders.
auth: {
combined: true,
header: "x-cloudide-token",
scheme: "raw",
header: "Authorization",
scheme: "Cloud-IDE-JWT",
},
usage: {
url: "https://api.marscode.com/cloudide/api/v3/trae/GetUserInfo",
@@ -61,7 +60,7 @@ export default {
// Trae refresh uses custom JSON body, not OAuth form — handled by refresh.js, not config-driven.
refresh: { encoding: "json" },
},
// Model catalog sourced from OmniRoute (IDE flow, core-normal.trae.ai).
// Model catalog (IDE flow, core-normal.trae.ai).
models: [
{ id: "auto", name: "Auto (Server Picks)" },
{ id: "work", name: "Work (Fast)" },
+12 -15
View File
@@ -1,6 +1,7 @@
// Windsurf provider registry — Firebase+Codeium+Devin auth chain.
// Chat transport is Codeium protobuf gRPC-Web: endpoint + schema are GUESS,
// the cockpit-tools source only documents auth/quota (SeatManagement) paths.
// Chat = Codeium gRPC-web protobuf:
// POST {base} Content-Type: application/grpc-web+proto
// Service: exa.language_server_pb.LanguageServerService / GetChatMessage
export default {
id: "windsurf",
alias: "ws",
@@ -17,19 +18,16 @@ export default {
hasOAuth: true,
authModes: ["oauth", "apikey"],
// TODO(chat): Codeium ServerService protobuf schema unknown — endpoint is a guess.
transport: {
// GUESS: Codeium chat lives under /exa.server_pb.ServerService/GetChatMessage.
baseUrl: "https://server.codeium.com/exa.server_pb.ServerService/GetChatMessage",
format: "windsurf",
baseUrl: "https://server.codeium.com/exa.language_server_pb.LanguageServerService/GetChatMessage",
format: "openai",
headers: {
"Content-Type": "application/proto",
"Connect-Protocol-Version": "1",
"ideName": "Windsurf",
"extensionName": "codeium.windsurf",
"Content-Type": "application/grpc-web+proto",
"Accept": "application/grpc-web+proto",
"X-Grpc-Web": "1",
},
// Bearer of apiKey (sk-ws-... / Firebase-derived / Devin session) — Connect-Protocol scheme unverified.
auth: { combined: true, header: "Authorization" },
// apiKey (sk-ws-... or Firebase-derived) as Bearer + in protobuf Metadata.api_key.
auth: { combined: true, header: "Authorization", scheme: "Bearer" },
},
// Auth chain (4 terminal paths, all yield apiKey):
@@ -52,9 +50,8 @@ export default {
},
// Catalog verified against model_configs_v2.bin from Devin CLI (2026.5.x).
// Source: OmniRoute registry (guanxiaol/WindsurfPoolAPI). Dot-notation ids; the
// executor MODEL_ALIAS_MAP would map these to Windsurf modelUid once proto chat
// is implemented. contextLength dropped — 9router schema uses id+name only.
// Dot-notation ids; the executor MODEL_ALIAS_MAP maps these to Windsurf modelUid.
// contextLength dropped — 9router schema uses id+name only.
models: [
// Cognition / SWE
{ id: "swe-1.6-fast", name: "SWE-1.6 Fast" },
-73
View File
@@ -1,73 +0,0 @@
export default {
id: "workbuddy",
// Short model prefix (wb/glm-5.2). WorkBuddy is a B2B/enterprise skin of
// CodeBuddy CN (same codebuddy.cn backend), so models mirror codebuddy-cn.
alias: "wb",
uiAlias: "wb",
hidden: false,
priority: 90,
display: {
name: "WorkBuddy",
icon: "smart_toy",
color: "#006EFF",
website: "https://www.codebuddy.cn",
notice: {
signupUrl: "https://www.codebuddy.cn",
},
},
category: "oauth",
authModes: ["oauth", "apikey"],
hasOAuth: true,
transport: {
// Same OpenAI-compatible gateway as codebuddy-cn; platform=workbuddy is
// distinguished at the OAuth layer, not the chat endpoint.
baseUrl: "https://www.codebuddy.cn/v2/chat/completions",
forceStream: true,
thinkingFormat: "openai",
headers: {
"User-Agent": "CLI/2.108.1 CodeBuddy/2.108.1",
"X-Product": "SaaS",
"X-IDE-Type": "CLI",
"X-IDE-Name": "CLI",
"x-requested-with": "XMLHttpRequest",
"x-codebuddy-request": "1",
},
auth: {
combined: true,
header: "Authorization",
scheme: "bearer",
},
},
models: [
{ id: "glm-5.2", name: "GLM-5.2" },
{ id: "glm-5.1", name: "GLM-5.1" },
{ id: "glm-5.0", name: "GLM-5.0" },
{ id: "glm-5.0-turbo", name: "GLM-5.0-Turbo" },
{ id: "glm-5v-turbo", name: "GLM-5v-Turbo" },
{ id: "glm-4.7", name: "GLM-4.7" },
{ id: "minimax-m3", name: "MiniMax-M3" },
{ id: "minimax-m2.7", name: "MiniMax-M2.7" },
{ id: "kimi-k2.7", name: "Kimi-K2.7-Code" },
{ id: "kimi-k2.6", name: "Kimi-K2.6" },
{ id: "kimi-k2.5", name: "Kimi-K2.5" },
{ id: "hy3-preview", name: "Hy3 Preview" },
{ id: "deepseek-v4-pro", name: "DeepSeek-V4-Pro" },
{ id: "deepseek-v4-flash", name: "DeepSeek-V4-Flash" },
{ id: "deepseek-v3-2-volc", name: "DeepSeek-V3.2" },
],
oauth: {
// Same codebuddy.cn host as codebuddy-cn; only platform param differs
// (workbuddy vs CLI). Prefix /v2/plugin matches cockpit-tools Rust.
baseUrl: "https://www.codebuddy.cn",
stateUrl: "https://www.codebuddy.cn/v2/plugin/auth/state",
tokenUrl: "https://www.codebuddy.cn/v2/plugin/auth/token",
refreshUrl: "https://www.codebuddy.cn/v2/plugin/auth/token/refresh",
userAgent: "CLI/2.63.2 CodeBuddy/2.63.2",
platform: "workbuddy",
pollInterval: 5000,
},
features: {
usage: true,
usageApikey: true,
},
};
-3
View File
@@ -14,7 +14,6 @@ import {
refreshCopilotToken,
refreshCodebuddyToken,
refreshCodebuddyIntlToken,
refreshWorkbuddyToken,
refreshTraeToken,
refreshZedToken,
refreshWindsurfToken,
@@ -35,7 +34,6 @@ export {
refreshCopilotToken,
refreshCodebuddyToken,
refreshCodebuddyIntlToken,
refreshWorkbuddyToken,
refreshTraeToken,
refreshZedToken,
refreshWindsurfToken,
@@ -149,7 +147,6 @@ const REFRESH_HANDLERS = {
gcli: (c, log) => refreshXaiToken(c.refreshToken, log),
"codebuddy-cn": (c, log) => refreshCodebuddyToken(c.refreshToken, log),
"codebuddy-intl": (c, log) => refreshCodebuddyIntlToken(c.refreshToken, log),
workbuddy: (c, log) => refreshWorkbuddyToken(c.refreshToken, log),
trae: (c, log) => refreshTraeToken(c.refreshToken, c, log),
zed: () => refreshZedToken(),
windsurf: (c, log) => refreshWindsurfToken(c, log),
+80 -275
View File
@@ -31,10 +31,68 @@ export async function refreshXaiToken(refreshToken, log) {
}, log);
}
// Per-provider refresh variants for the generic path. Keys not listed fall back
// to the default form-encoded OAuth2 refresh with client_id + client_secret.
const REFRESH_PROFILES = {
claude: {
bodyFormat: "json",
includeClientSecret: false,
url: () => OAUTH_ENDPOINTS.anthropic.token,
dedupKey: "claude",
},
qwen: {
url: () => OAUTH_ENDPOINTS.qwen.token,
dedupKey: "qwen",
parse: (tokens) => tokens.resource_url ? { providerSpecificData: { resourceUrl: tokens.resource_url } } : {},
},
iflow: {
url: () => OAUTH_ENDPOINTS.iflow.token,
dedupKey: "iflow",
extraHeaders: (creds, cfg) => ({
Authorization: `Basic ${btoa(`${cfg.clientId}:${cfg.clientSecret}`)}`,
}),
},
github: {
url: () => OAUTH_ENDPOINTS.github.token,
dedupKey: "github",
includeClientSecret: (cfg) => !!cfg?.clientSecret,
},
kimi: {
dedupKey: "kimi",
extraHeaders: (creds) => buildKimiHeaders(creds?.providerSpecificData?.deviceId),
},
};
function resolveRefreshUrl(provider, config, profile) {
if (profile?.url) {
try { return profile.url(); } catch { /* fall through */ }
}
return config?.refreshUrl || PROVIDER_OAUTH[provider]?.tokenUrl || null;
}
function buildRefreshBody(profile, config, refreshToken) {
const fmt = profile?.bodyFormat === "json" ? "json" : "form";
const includeSecret = profile?.includeClientSecret === undefined
? true
: typeof profile.includeClientSecret === "function"
? profile.includeClientSecret(config)
: profile.includeClientSecret;
const payload = {
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: config.clientId,
};
if (includeSecret && config.clientSecret) payload.client_secret = config.clientSecret;
if (fmt === "json") return { format: "json", body: JSON.stringify(payload) };
return { format: "form", body: new URLSearchParams(payload) };
}
export async function refreshAccessToken(provider, refreshToken, credentials, log) {
const config = PROVIDERS[provider];
const profile = REFRESH_PROFILES[provider] || {};
const url = resolveRefreshUrl(provider, config, profile);
if (!config || !config.refreshUrl) {
if (!config || !url) {
log?.warn?.("TOKEN_REFRESH", `No refresh URL configured for provider: ${provider}`);
return null;
}
@@ -44,21 +102,17 @@ export async function refreshAccessToken(provider, refreshToken, credentials, lo
return null;
}
return dedupRefresh(provider, refreshToken, async () => {
const dedupKey = profile.dedupKey || provider;
return dedupRefresh(dedupKey, refreshToken, async () => {
try {
const response = await fetch(config.refreshUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: config.clientId,
client_secret: config.clientSecret,
}),
});
const { format: bodyFormat, body } = buildRefreshBody(profile, config, refreshToken);
const headers = {
"Content-Type": bodyFormat === "json" ? "application/json" : "application/x-www-form-urlencoded",
Accept: "application/json",
...(profile.extraHeaders ? (profile.extraHeaders(credentials, config) || {}) : {}),
};
const response = await fetch(url, { method: "POST", headers, body });
if (!response.ok) {
const errorText = await response.text();
@@ -81,6 +135,7 @@ export async function refreshAccessToken(provider, refreshToken, credentials, lo
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || refreshToken,
expiresIn: tokens.expires_in,
...(profile.parse ? (profile.parse(tokens) || {}) : {}),
};
} catch (error) {
log?.error?.("TOKEN_REFRESH", `Error refreshing token for ${provider}`, {
@@ -92,82 +147,14 @@ export async function refreshAccessToken(provider, refreshToken, credentials, lo
}
// CLIProxyAPI DeviceFlowClient.RefreshToken: form body (no client_secret) + X-Msh-* headers
// Delegate to refreshAccessToken("kimi", ...) — profile carries the X-Msh headers.
export async function refreshKimiToken(refreshToken, credentials, log) {
const config = PROVIDERS.kimi;
if (!config?.refreshUrl || !config?.clientId) {
log?.warn?.("TOKEN_REFRESH", "No Kimi refresh URL/clientId configured");
return null;
}
if (!refreshToken) return null;
return dedupRefresh("kimi", refreshToken, async () => {
try {
const headers = {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
...buildKimiHeaders(credentials?.providerSpecificData?.deviceId),
};
const response = await fetch(config.refreshUrl, {
method: "POST",
headers,
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: config.clientId,
}),
});
if (!response.ok) {
const errorText = await response.text();
log?.error?.("TOKEN_REFRESH", `Failed to refresh token for kimi`, {
status: response.status,
error: errorText,
});
return null;
}
const tokens = await response.json();
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || refreshToken,
expiresIn: tokens.expires_in,
};
} catch (error) {
log?.error?.("TOKEN_REFRESH", `Error refreshing token for kimi`, { error: error.message });
return null;
}
}, log);
return refreshAccessToken("kimi", refreshToken, credentials, log);
}
// Claude OAuth: JSON body, client_id only. Delegate to refreshAccessToken("claude", ...).
export async function refreshClaudeOAuthToken(refreshToken, log) {
if (!refreshToken) return null;
return dedupRefresh("claude", refreshToken, async () => {
try {
const response = await fetch(OAUTH_ENDPOINTS.anthropic.token, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: PROVIDERS.claude.clientId,
}),
});
if (!response.ok) {
const errorText = await response.text();
log?.error?.("TOKEN_REFRESH", "Failed to refresh Claude OAuth token", { status: response.status, error: errorText });
return null;
}
const tokens = await response.json();
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Claude OAuth token", { hasNewAccessToken: !!tokens.access_token, expiresIn: tokens.expires_in });
return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in };
} catch (error) {
log?.error?.("TOKEN_REFRESH", `Network error refreshing Claude token: ${error.message}`);
return null;
}
}, log);
return refreshAccessToken("claude", refreshToken, {}, log);
}
export async function refreshGoogleToken(refreshToken, clientId, clientSecret, log) {
@@ -204,58 +191,9 @@ export async function refreshGoogleToken(refreshToken, clientId, clientSecret, l
}, log);
}
// Qwen: form body + clientId, surfaces resource_url. Delegate to refreshAccessToken("qwen", ...).
export async function refreshQwenToken(refreshToken, log) {
if (!refreshToken) return null;
return dedupRefresh("qwen", refreshToken, async () => {
const endpoint = OAUTH_ENDPOINTS.qwen.token;
try {
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: PROVIDERS.qwen.clientId,
}),
});
if (response.status === 200) {
const tokens = await response.json();
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Qwen token", {
hasNewAccessToken: !!tokens.access_token,
hasNewRefreshToken: !!tokens.refresh_token,
expiresIn: tokens.expires_in,
});
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || refreshToken,
expiresIn: tokens.expires_in,
providerSpecificData: tokens.resource_url
? { resourceUrl: tokens.resource_url }
: undefined,
};
} else {
const errorText = await response.text().catch(() => "");
log?.warn?.("TOKEN_REFRESH", `Error with Qwen endpoint`, {
status: response.status,
error: errorText,
});
}
} catch (error) {
log?.warn?.("TOKEN_REFRESH", `Network error trying Qwen endpoint`, {
error: error.message,
});
}
log?.error?.("TOKEN_REFRESH", "Failed to refresh Qwen token");
return null;
}, log);
return refreshAccessToken("qwen", refreshToken, {}, log);
}
export function classifyOAuthRefreshError(errorText = "", status = 0) {
@@ -480,95 +418,14 @@ export async function refreshKiroToken(refreshToken, providerSpecificData, log,
}, log);
}
// iFlow: Basic Auth + client_id+client_secret in body. Delegate to refreshAccessToken("iflow", ...).
export async function refreshIflowToken(refreshToken, log) {
if (!refreshToken) return null;
return dedupRefresh("iflow", refreshToken, async () => {
const basicAuth = btoa(`${PROVIDERS.iflow.clientId}:${PROVIDERS.iflow.clientSecret}`);
const response = await fetch(OAUTH_ENDPOINTS.iflow.token, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
Authorization: `Basic ${basicAuth}`,
},
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: PROVIDERS.iflow.clientId,
client_secret: PROVIDERS.iflow.clientSecret,
}),
});
if (!response.ok) {
const errorText = await response.text();
log?.error?.("TOKEN_REFRESH", "Failed to refresh iFlow token", {
status: response.status,
error: errorText,
});
return null;
}
const tokens = await response.json();
log?.info?.("TOKEN_REFRESH", "Successfully refreshed iFlow token", {
hasNewAccessToken: !!tokens.access_token,
hasNewRefreshToken: !!tokens.refresh_token,
expiresIn: tokens.expires_in,
});
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || refreshToken,
expiresIn: tokens.expires_in,
};
}, log);
return refreshAccessToken("iflow", refreshToken, {}, log);
}
// GitHub: optional client_secret. Delegate to refreshAccessToken("github", ...).
export async function refreshGitHubToken(refreshToken, log) {
if (!refreshToken) return null;
return dedupRefresh("github", refreshToken, async () => {
const params = {
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: PROVIDERS.github.clientId,
};
if (PROVIDERS.github.clientSecret) {
params.client_secret = PROVIDERS.github.clientSecret;
}
const response = await fetch(OAUTH_ENDPOINTS.github.token, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams(params),
});
if (!response.ok) {
const errorText = await response.text();
log?.error?.("TOKEN_REFRESH", "Failed to refresh GitHub token", {
status: response.status,
error: errorText,
});
return null;
}
const tokens = await response.json();
log?.info?.("TOKEN_REFRESH", "Successfully refreshed GitHub token", {
hasNewAccessToken: !!tokens.access_token,
hasNewRefreshToken: !!tokens.refresh_token,
expiresIn: tokens.expires_in,
});
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || refreshToken,
expiresIn: tokens.expires_in,
};
}, log);
return refreshAccessToken("github", refreshToken, {}, log);
}
export async function refreshCopilotToken(githubAccessToken, log) {
@@ -720,60 +577,8 @@ export async function refreshCodebuddyIntlToken(refreshToken, log) {
}, log);
}
export async function refreshWorkbuddyToken(refreshToken, log) {
if (!refreshToken) return null;
return dedupRefresh("workbuddy", refreshToken, async () => {
const oauth = PROVIDER_OAUTH["workbuddy"] || {};
const response = await fetch(oauth.refreshUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"User-Agent": oauth.userAgent,
"X-Requested-With": "XMLHttpRequest",
"X-Domain": "www.codebuddy.cn",
"X-Refresh-Token": refreshToken,
"X-Auth-Refresh-Source": "plugin",
"X-Product": "SaaS",
},
body: "{}",
});
if (!response.ok) {
const errorText = await response.text();
log?.error?.("TOKEN_REFRESH", "Failed to refresh WorkBuddy token", {
status: response.status,
error: errorText,
});
return null;
}
const data = await response.json();
if (data.code !== 0 || !data.data?.accessToken) {
log?.error?.("TOKEN_REFRESH", "WorkBuddy token refresh returned no token", {
code: data.code,
msg: data.msg,
});
return null;
}
log?.info?.("TOKEN_REFRESH", "Successfully refreshed WorkBuddy token", {
hasNewAccessToken: !!data.data.accessToken,
hasNewRefreshToken: !!data.data.refreshToken,
expiresIn: data.data.expiresIn,
});
return {
accessToken: data.data.accessToken,
refreshToken: data.data.refreshToken || refreshToken,
expiresIn: data.data.expiresIn,
};
}, log);
}
// Trae refresh — POST ExchangeToken with JSON body {ClientID, RefreshToken, ClientSecret, UserID}.
// Response: {Result: {AccessToken, RefreshToken, TokenType, ExpiresAt}}.
// Source: cockpit-tools/src-tauri/src/modules/trae_oauth.rs (TRAE_EXCHANGE_TOKEN_PATH).
export async function refreshTraeToken(refreshToken, credentials, log) {
if (!refreshToken) return null;
const oauth = PROVIDER_OAUTH.trae || {};
+15
View File
@@ -198,6 +198,21 @@ export function parseGrokCliBilling(billing, user = null) {
};
}
// SuperGrok weekly shared-pool usage (subscription tier). creditUsagePercent is
// the single total used %; productUsage is a breakdown legend, NOT independent
// quotas — never split it into separate bars.
const usedPct = unwrapVal(
config.creditUsagePercent ?? config.credit_usage_percent ?? root.creditUsagePercent,
NaN,
);
if (Number.isFinite(usedPct) && usedPct >= 0) {
quotas["Weekly SuperGrok"] = makeQuota({
used: Math.max(0, Math.min(100, usedPct)),
total: 100,
resetAt: periodEnd,
});
}
// Opportunistic richer credit envelopes (future / other account types)
const creditBags = [
root.credits,