mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-10-04 02:52:55 +08:00
feat: add Grok authentication plugin with OAuth support
- Introduced a new Grok authentication plugin, including essential files such as main.ts, provider.ts, and resources.ts. - Implemented OAuth2 device authorization flow in oauth.ts, allowing users to sign in with xAI. - Added model discovery and quota management functionalities in models.ts and resources.ts. - Created a JSON configuration file (plugin.json) for plugin metadata and permissions. - Included SVG asset for the Grok icon. - Developed comprehensive tests in grok_test.ts to ensure functionality and reliability of the plugin.
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="230" height="230" viewBox="0 0 230 230" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>grok</title>
|
||||
<rect x="22" y="21" width="187" height="187" rx="42" fill="#000000"/>
|
||||
<g fill="#FFFFFF">
|
||||
<path d="M96.5 137.5 L152 82 L166 96 L110.5 151.5 Z"/>
|
||||
<path d="M64 82 L106 124 L92 138 L64 110 Z"/>
|
||||
<path d="M166 148 L166 96 L152 110 L152 148 Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 418 B |
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"imports": {
|
||||
"cursor-byok:plugin": "../../../src/plugin/sdk/plugin.ts",
|
||||
"cursor-byok:provider": "../../../src/plugin/sdk/provider.ts",
|
||||
"cursor-byok:model": "../../../src/plugin/sdk/model.ts",
|
||||
"cursor-byok:resource": "../../../src/plugin/sdk/resource.ts",
|
||||
"cursor-byok:protocol/openai-chat": "../../../src/plugin/sdk/protocol/openai_chat.ts"
|
||||
},
|
||||
"fmt": {
|
||||
"lineWidth": 100,
|
||||
"exclude": ["assets"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
import type {
|
||||
JsonValue,
|
||||
NetworkEventStream,
|
||||
NetworkResponse,
|
||||
PluginContext,
|
||||
} from "cursor-byok:plugin";
|
||||
import type { LlmRequest, ModelEvent } from "cursor-byok:provider";
|
||||
import type { ResourceSnapshot } from "cursor-byok:resource";
|
||||
import { grokDeviceOAuth } from "./oauth.ts";
|
||||
import { FALLBACK_MODELS, grokModels, parseGrokModels } from "./models.ts";
|
||||
import { grokProvider, isQuotaError } from "./provider.ts";
|
||||
import {
|
||||
accountIdentity,
|
||||
credentialDraft,
|
||||
parseCredentialFiles,
|
||||
parseGrokUsage,
|
||||
presentAccount,
|
||||
quotaState,
|
||||
RESOURCE_TYPE,
|
||||
} from "./resources.ts";
|
||||
|
||||
function assert(condition: unknown, message = "assertion failed"): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function assertEquals(actual: unknown, expected: unknown): void {
|
||||
const left = JSON.stringify(actual);
|
||||
const right = JSON.stringify(expected);
|
||||
if (left !== right) throw new Error(`expected ${right}, received ${left}`);
|
||||
}
|
||||
|
||||
function jwt(payload: Record<string, unknown>): string {
|
||||
const encoded = btoa(JSON.stringify(payload)).replace(/=/g, "").replace(/\+/g, "-").replace(
|
||||
/\//g,
|
||||
"_",
|
||||
);
|
||||
return `header.${encoded}.signature`;
|
||||
}
|
||||
|
||||
type RequestInit = { body?: string; headers?: Record<string, string> };
|
||||
type FetchHandler = (url: string, init?: RequestInit) => NetworkResponse;
|
||||
type StreamHandler = (url: string, init?: RequestInit) => NetworkEventStream;
|
||||
|
||||
function context(handlers: { fetch?: FetchHandler; stream?: StreamHandler }): PluginContext {
|
||||
return {
|
||||
network: {
|
||||
fetch: (url, init) => {
|
||||
if (!handlers.fetch) throw new Error("fetch was not expected");
|
||||
return Promise.resolve(handlers.fetch(url, init));
|
||||
},
|
||||
stream: (url, init) => {
|
||||
if (!handlers.stream) throw new Error("stream was not expected");
|
||||
return Promise.resolve(handlers.stream(url, init));
|
||||
},
|
||||
},
|
||||
signal: new AbortController().signal,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshot(privateData: JsonValue): ResourceSnapshot {
|
||||
return {
|
||||
id: "resource-1",
|
||||
type: RESOURCE_TYPE,
|
||||
key: "grok:user-1",
|
||||
privateData,
|
||||
state: { status: "ready" },
|
||||
};
|
||||
}
|
||||
|
||||
async function* sse(lines: string[]): AsyncGenerator<string> {
|
||||
for (const line of lines) yield line;
|
||||
}
|
||||
|
||||
function request(): LlmRequest {
|
||||
return {
|
||||
instructions: "You are a coding assistant.",
|
||||
messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }],
|
||||
tools: [],
|
||||
reasoning: { enabled: true, effort: "medium" },
|
||||
latency: "fast",
|
||||
maxOutputTokens: 32_000,
|
||||
cacheKey: "conversation-1",
|
||||
};
|
||||
}
|
||||
|
||||
Deno.test("account identity uses the JWT subject and drafts keep tokens private-side", async () => {
|
||||
const token = jwt({ sub: "user-1", email: "person@x.ai" });
|
||||
assertEquals(await accountIdentity(token), {
|
||||
key: "grok:user-1",
|
||||
displayName: "person@x.ai",
|
||||
});
|
||||
const draft = await credentialDraft({
|
||||
accessToken: token,
|
||||
refreshToken: null,
|
||||
displayName: null,
|
||||
});
|
||||
assertEquals(draft.key, "grok:user-1");
|
||||
const view = presentAccount(snapshot(draft.privateData));
|
||||
assert(!JSON.stringify(view).includes(token), "resource view exposed an access token");
|
||||
assertEquals(view.displayName, "person@x.ai");
|
||||
});
|
||||
|
||||
Deno.test("credential import accepts Grok credential JSON files", () => {
|
||||
const { credentials, warnings } = parseCredentialFiles([
|
||||
{
|
||||
name: "accounts.json",
|
||||
content: JSON.stringify({
|
||||
accounts: [
|
||||
{ access_token: "token-1", refresh_token: "refresh-1", email: "a@x.ai" },
|
||||
{ access_token: "token-2", disabled: true },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{ name: "broken.json", content: "{not json" },
|
||||
]);
|
||||
assertEquals(credentials, [{
|
||||
accessToken: "token-1",
|
||||
refreshToken: "refresh-1",
|
||||
displayName: "a@x.ai",
|
||||
}]);
|
||||
assertEquals(warnings, ["broken.json: not valid JSON"]);
|
||||
});
|
||||
|
||||
Deno.test("credit usage percent is inverted to remaining and drives cooling", () => {
|
||||
const quota = parseGrokUsage({
|
||||
config: {
|
||||
creditUsagePercent: 34,
|
||||
subscriptionTierDisplay: "SuperGrok",
|
||||
currentPeriod: { end: "2026-09-01T00:00:00Z" },
|
||||
},
|
||||
}, 1_700_000_000_000);
|
||||
assertEquals(quota.planLabel, "SuperGrok");
|
||||
assertEquals(quota.remainingPercent, 66);
|
||||
assertEquals(quota.resetAtMs, Date.parse("2026-09-01T00:00:00Z"));
|
||||
assertEquals(quotaState(quota, 1_700_000_000_000), { status: "ready" });
|
||||
|
||||
const exhausted = parseGrokUsage({
|
||||
config: { creditUsagePercent: 100, currentPeriod: { end: 1_900_000_000 } },
|
||||
}, 1_700_000_000_000);
|
||||
assertEquals(quotaState(exhausted, 1_700_000_000_000), {
|
||||
status: "cooling",
|
||||
retryAtMs: 1_900_000_000_000,
|
||||
message: "Grok credits are exhausted",
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test("missing usage with a billing period counts as unused", () => {
|
||||
const quota = parseGrokUsage({ config: { currentPeriod: { end: 1_900_000_000 } } });
|
||||
assertEquals(quota.remainingPercent, 100);
|
||||
assertEquals(quota.limitReached, false);
|
||||
});
|
||||
|
||||
Deno.test("model discovery parses both language-models and standard list shapes", () => {
|
||||
const richModels = parseGrokModels({
|
||||
models: [
|
||||
{ id: "grok-4", input_modalities: ["text", "image"], context_window: 256_000 },
|
||||
{ id: "grok-3-mini", input_modalities: ["text"] },
|
||||
{ id: "grok-4" },
|
||||
],
|
||||
});
|
||||
assertEquals(richModels.map((model) => model.id), ["grok-4", "grok-3-mini"]);
|
||||
assertEquals(richModels[0].displayName, "Grok 4");
|
||||
assertEquals(richModels[0].contextWindowTokens, 256_000);
|
||||
assertEquals(richModels[0].capabilities, { thinking: false, images: true });
|
||||
assertEquals(richModels[1].capabilities, { thinking: false, images: false });
|
||||
|
||||
const plainModels = parseGrokModels({ data: [{ id: "grok-4-fast" }] });
|
||||
assertEquals(plainModels.map((model) => model.id), ["grok-4-fast"]);
|
||||
assertEquals(plainModels[0].displayName, "Grok 4 Fast");
|
||||
});
|
||||
|
||||
Deno.test("model discovery falls back to known models when the account cannot list", async () => {
|
||||
const token = jwt({ sub: "user-1" });
|
||||
const draft = await credentialDraft({
|
||||
accessToken: token,
|
||||
refreshToken: null,
|
||||
displayName: null,
|
||||
});
|
||||
const models = await grokModels.list(
|
||||
{ resource: snapshot(draft.privateData) },
|
||||
context({
|
||||
fetch: () => ({
|
||||
status: 403,
|
||||
headers: {},
|
||||
body: JSON.stringify({ code: "personal-team-blocked:spending-limit" }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
assertEquals(models, FALLBACK_MODELS);
|
||||
});
|
||||
|
||||
Deno.test("device OAuth begins with a host-held session and completes with a resource draft", async () => {
|
||||
const accessToken = jwt({ sub: "user-oauth", email: "oauth@x.ai" });
|
||||
let requestNumber = 0;
|
||||
const flowContext = context({
|
||||
fetch: (url, init) => {
|
||||
requestNumber += 1;
|
||||
if (requestNumber === 1) {
|
||||
assertEquals(url, "https://auth.x.ai/oauth2/device/code");
|
||||
assert(init?.body?.includes("scope="), "device code request must carry the scope");
|
||||
return {
|
||||
status: 200,
|
||||
headers: {},
|
||||
body: JSON.stringify({
|
||||
device_code: "private-device-code",
|
||||
user_code: "ABCD-EFGH",
|
||||
verification_uri: "https://accounts.x.ai/activate",
|
||||
verification_uri_complete: "https://accounts.x.ai/activate?code=ABCD-EFGH",
|
||||
expires_in: 900,
|
||||
interval: 5,
|
||||
}),
|
||||
};
|
||||
}
|
||||
assertEquals(url, "https://auth.x.ai/oauth2/token");
|
||||
assert(init?.body?.includes("device_code=private-device-code"));
|
||||
if (requestNumber === 2) {
|
||||
return {
|
||||
status: 400,
|
||||
headers: {},
|
||||
body: JSON.stringify({ error: "authorization_pending" }),
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 200,
|
||||
headers: {},
|
||||
body: JSON.stringify({ access_token: accessToken, refresh_token: "refresh-secret" }),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const begun = await grokDeviceOAuth.begin(flowContext);
|
||||
assertEquals(begun.userCode, "ABCD-EFGH");
|
||||
assertEquals(begun.pollIntervalMs, 5000);
|
||||
|
||||
const pending = await grokDeviceOAuth.poll(begun.session, flowContext);
|
||||
assertEquals(pending.status, "pending");
|
||||
|
||||
const polled = await grokDeviceOAuth.poll(begun.session, flowContext);
|
||||
assert(polled.status === "completed", `expected completed, received ${polled.status}`);
|
||||
assertEquals(polled.resources[0].key, "grok:user-oauth");
|
||||
assertEquals(requestNumber, 3);
|
||||
});
|
||||
|
||||
Deno.test("invoke streams normalized events from the xAI Chat Completions API", async () => {
|
||||
const token = jwt({ sub: "user-1" });
|
||||
const draft = await credentialDraft({
|
||||
accessToken: token,
|
||||
refreshToken: null,
|
||||
displayName: null,
|
||||
});
|
||||
let requestBody = "";
|
||||
let requestHeaders: Record<string, string> = {};
|
||||
const events: ModelEvent[] = [];
|
||||
const result = await grokProvider.invoke(
|
||||
{
|
||||
model: { id: "grok-4", displayName: "Grok 4" },
|
||||
resource: snapshot(draft.privateData),
|
||||
request: request(),
|
||||
},
|
||||
{ emit: (event) => events.push(event) },
|
||||
context({
|
||||
stream: (url, init) => {
|
||||
assertEquals(url, "https://api.x.ai/v1/chat/completions");
|
||||
requestBody = init?.body ?? "";
|
||||
requestHeaders = init?.headers ?? {};
|
||||
return {
|
||||
status: 200,
|
||||
headers: {},
|
||||
lines: sse([
|
||||
'data: {"choices":[{"delta":{"content":"Hel"}}]}',
|
||||
'data: {"choices":[{"delta":{"content":"lo"}}]}',
|
||||
'data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":2,"prompt_tokens_details":{"cached_tokens":4}}}',
|
||||
"data: [DONE]",
|
||||
]),
|
||||
};
|
||||
},
|
||||
}),
|
||||
);
|
||||
assertEquals(result, { status: "completed" });
|
||||
const body = JSON.parse(requestBody) as Record<string, unknown>;
|
||||
assertEquals(body.model, "grok-4");
|
||||
assertEquals(body.stream, true);
|
||||
assertEquals(body.prompt_cache_key, "conversation-1");
|
||||
assert(!("reasoning_effort" in body), "xAI endpoint rejects reasoning_effort");
|
||||
assert(!("service_tier" in body), "xAI endpoint rejects service_tier");
|
||||
assertEquals(requestHeaders["authorization"], `Bearer ${token}`);
|
||||
assertEquals(events, [
|
||||
{ type: "text-start" },
|
||||
{ type: "text-delta", text: "Hel" },
|
||||
{ type: "text-delta", text: "lo" },
|
||||
{ type: "text-end" },
|
||||
{
|
||||
type: "usage",
|
||||
usage: {
|
||||
inputTokens: 10,
|
||||
outputTokens: 2,
|
||||
totalTokens: null,
|
||||
cacheReadTokens: 4,
|
||||
cacheWriteTokens: null,
|
||||
reasoningTokens: null,
|
||||
},
|
||||
},
|
||||
{ type: "done", reason: "stop" },
|
||||
]);
|
||||
});
|
||||
|
||||
Deno.test("invoke streams incremental tool calls and reasoning replay state", async () => {
|
||||
const token = jwt({ sub: "user-1" });
|
||||
const draft = await credentialDraft({
|
||||
accessToken: token,
|
||||
refreshToken: null,
|
||||
displayName: null,
|
||||
});
|
||||
const events: ModelEvent[] = [];
|
||||
const result = await grokProvider.invoke(
|
||||
{
|
||||
model: { id: "grok-4", displayName: "Grok 4" },
|
||||
resource: snapshot(draft.privateData),
|
||||
request: request(),
|
||||
},
|
||||
{ emit: (event) => events.push(event) },
|
||||
context({
|
||||
stream: () => ({
|
||||
status: 200,
|
||||
headers: {},
|
||||
lines: sse([
|
||||
'data: {"choices":[{"delta":{"reasoning_content":"thinking"}}]}',
|
||||
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call-1","function":{"name":"read_file","arguments":"{\\"path\\":"}}]}}]}',
|
||||
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\\"a.ts\\"}"}}]}}]}',
|
||||
'data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}',
|
||||
"data: [DONE]",
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
assertEquals(result, { status: "completed" });
|
||||
assertEquals(events, [
|
||||
{ type: "thinking-start" },
|
||||
{ type: "thinking-delta", text: "thinking" },
|
||||
{ type: "tool-call-start", index: 0, callId: "call-1", name: "read_file" },
|
||||
{ type: "tool-call-arguments-delta", index: 0, delta: '{"path":' },
|
||||
{ type: "tool-call-arguments-delta", index: 0, delta: '"a.ts"}' },
|
||||
{ type: "thinking-end" },
|
||||
{ type: "tool-call-end", index: 0 },
|
||||
{
|
||||
type: "replay-state",
|
||||
providerKind: "openai_chat",
|
||||
value: { reasoning_content: "thinking" },
|
||||
},
|
||||
{ type: "done", reason: "tool-use" },
|
||||
]);
|
||||
});
|
||||
|
||||
Deno.test("invoke maps quota failures to a cooling resource error", async () => {
|
||||
assert(!isQuotaError("400 invalid request"));
|
||||
assert(isQuotaError("429 credits exhausted"));
|
||||
const token = jwt({ sub: "user-1" });
|
||||
const draft = await credentialDraft({
|
||||
accessToken: token,
|
||||
refreshToken: null,
|
||||
displayName: null,
|
||||
});
|
||||
const result = await grokProvider.invoke(
|
||||
{
|
||||
model: { id: "grok-4", displayName: "Grok 4" },
|
||||
resource: snapshot(draft.privateData),
|
||||
request: request(),
|
||||
},
|
||||
{ emit: () => {} },
|
||||
context({
|
||||
stream: () => ({
|
||||
status: 429,
|
||||
headers: {},
|
||||
lines: sse(['{"error":"credits exhausted"}']),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
assert(result.status === "resource-error", `expected resource-error, received ${result.status}`);
|
||||
assert(result.patch.state?.status === "cooling", "quota failure should cool the resource");
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineProviderPlugin } from "cursor-byok:plugin";
|
||||
import { grokDeviceOAuth } from "./oauth.ts";
|
||||
import { grokProvider } from "./provider.ts";
|
||||
import { credentialImport, presentAccount, refreshAccount, RESOURCE_TYPE } from "./resources.ts";
|
||||
|
||||
export default defineProviderPlugin({
|
||||
providers: [grokProvider],
|
||||
resources: [{
|
||||
type: RESOURCE_TYPE,
|
||||
displayName: { "en-US": "Grok accounts", "zh-CN": "Grok 账号" },
|
||||
add: [grokDeviceOAuth],
|
||||
import: credentialImport,
|
||||
present: presentAccount,
|
||||
refresh: refreshAccount,
|
||||
}],
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { ModelDefinition, ModelSupport } from "cursor-byok:model";
|
||||
import { accountData } from "./resources.ts";
|
||||
|
||||
const LANGUAGE_MODELS_URL = "https://api.x.ai/v1/language-models";
|
||||
const MODELS_URL = "https://api.x.ai/v1/models";
|
||||
|
||||
/** 免费账号无权调用模型列表接口(403 spending-limit);退回已知模型。 */
|
||||
export const FALLBACK_MODELS: ModelDefinition[] = [
|
||||
{
|
||||
id: "grok-4.6",
|
||||
displayName: "Grok 4.6",
|
||||
capabilities: { thinking: false, images: true },
|
||||
},
|
||||
{
|
||||
id: "grok-4.5",
|
||||
displayName: "Grok 4.5",
|
||||
capabilities: { thinking: false, images: true },
|
||||
},
|
||||
];
|
||||
|
||||
function object(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
function text(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown): number | null {
|
||||
const parsed = typeof value === "number"
|
||||
? value
|
||||
: typeof value === "string"
|
||||
? Number(value)
|
||||
: NaN;
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : null;
|
||||
}
|
||||
|
||||
function modalities(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.flatMap((item) => (typeof item === "string" ? [item.toLowerCase()] : []))
|
||||
: [];
|
||||
}
|
||||
|
||||
/** 把模型 ID 变成可读名称,如 grok-4-fast → Grok 4 Fast。 */
|
||||
function displayName(id: string): string {
|
||||
return id
|
||||
.split("-")
|
||||
.map((part) => (/^\d/.test(part) ? part : part.charAt(0).toUpperCase() + part.slice(1)))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
/** 兼容 /v1/language-models 的 models 数组与 /v1/models 的 data 数组。 */
|
||||
export function parseGrokModels(body: unknown): ModelDefinition[] {
|
||||
const root = object(body);
|
||||
const source = root?.models ?? root?.data ?? body;
|
||||
if (!Array.isArray(source)) {
|
||||
throw new Error("Grok model discovery response does not contain a model list");
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const models: ModelDefinition[] = [];
|
||||
for (const raw of source) {
|
||||
const model = object(raw);
|
||||
const id = model ? text(model.id ?? model.name) : null;
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
const inputs = modalities(model?.input_modalities ?? model?.inputModalities);
|
||||
const contextWindowTokens = positiveInteger(
|
||||
model?.context_window ?? model?.contextWindow ?? model?.max_prompt_length,
|
||||
);
|
||||
models.push({
|
||||
id,
|
||||
displayName: displayName(id),
|
||||
...(contextWindowTokens !== null ? { contextWindowTokens } : {}),
|
||||
capabilities: {
|
||||
thinking: false,
|
||||
images: inputs.length === 0 || inputs.includes("image"),
|
||||
},
|
||||
});
|
||||
}
|
||||
return models;
|
||||
}
|
||||
|
||||
export const grokModels: ModelSupport = {
|
||||
list: async ({ resource }, context): Promise<ModelDefinition[]> => {
|
||||
if (!resource) throw new Error("add a Grok account before syncing models");
|
||||
const data = accountData(resource);
|
||||
const headers = {
|
||||
accept: "application/json",
|
||||
authorization: `Bearer ${data.accessToken}`,
|
||||
};
|
||||
// language-models 带模态与上下文元数据;不可用时回退到标准列表。
|
||||
let response = await context.network.fetch(LANGUAGE_MODELS_URL, { method: "GET", headers });
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
response = await context.network.fetch(MODELS_URL, { method: "GET", headers });
|
||||
}
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
return FALLBACK_MODELS;
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
body = JSON.parse(response.body);
|
||||
} catch {
|
||||
throw new Error("Grok model discovery returned invalid JSON");
|
||||
}
|
||||
const models = parseGrokModels(body);
|
||||
return models.length > 0 ? models : FALLBACK_MODELS;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { JsonValue, PluginContext } from "cursor-byok:plugin";
|
||||
import type { OAuth2AddMethod, OAuth2Begin, OAuth2Poll } from "cursor-byok:resource";
|
||||
import { credentialDraft } from "./resources.ts";
|
||||
|
||||
const CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828";
|
||||
const DEVICE_CODE_URL = "https://auth.x.ai/oauth2/device/code";
|
||||
const TOKEN_URL = "https://auth.x.ai/oauth2/token";
|
||||
const SCOPE = "openid profile email offline_access grok-cli:access api:access";
|
||||
|
||||
type Session = {
|
||||
deviceCode: string;
|
||||
};
|
||||
|
||||
function object(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
function text(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function number(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseBody(body: string): Record<string, unknown> {
|
||||
try {
|
||||
return object(JSON.parse(body)) ?? {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function parseSession(value: JsonValue): Session {
|
||||
const session = object(value);
|
||||
const deviceCode = text(session?.deviceCode);
|
||||
if (!deviceCode) throw new Error("Grok OAuth session is invalid");
|
||||
return { deviceCode };
|
||||
}
|
||||
|
||||
async function begin(context: PluginContext): Promise<OAuth2Begin> {
|
||||
const response = await context.network.fetch(DEVICE_CODE_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
"content-type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({ client_id: CLIENT_ID, scope: SCOPE }).toString(),
|
||||
});
|
||||
const body = parseBody(response.body);
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
throw new Error(
|
||||
`Failed to request xAI device code (HTTP ${response.status}): ${response.body}`,
|
||||
);
|
||||
}
|
||||
const deviceCode = text(body.device_code);
|
||||
const userCode = text(body.user_code);
|
||||
const verificationUrl = text(body.verification_uri);
|
||||
if (!deviceCode || !userCode || !verificationUrl) {
|
||||
throw new Error("xAI device authorization response is incomplete");
|
||||
}
|
||||
const session: Session = { deviceCode };
|
||||
return {
|
||||
session: session as unknown as JsonValue,
|
||||
userCode,
|
||||
verificationUrl,
|
||||
...(text(body.verification_uri_complete)
|
||||
? { verificationUrlComplete: text(body.verification_uri_complete)! }
|
||||
: {}),
|
||||
expiresAtMs: Date.now() + Math.max(1, number(body.expires_in) ?? 900) * 1000,
|
||||
pollIntervalMs: Math.max(1, number(body.interval) ?? 5) * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
async function poll(sessionValue: JsonValue, context: PluginContext): Promise<OAuth2Poll> {
|
||||
const session = parseSession(sessionValue);
|
||||
const response = await context.network.fetch(TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
"content-type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
client_id: CLIENT_ID,
|
||||
device_code: session.deviceCode,
|
||||
}).toString(),
|
||||
});
|
||||
const body = parseBody(response.body);
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
const accessToken = text(body.access_token);
|
||||
if (!accessToken) {
|
||||
return { status: "failed", message: "xAI token response is missing access_token" };
|
||||
}
|
||||
return {
|
||||
status: "completed",
|
||||
resources: [
|
||||
await credentialDraft({
|
||||
accessToken,
|
||||
refreshToken: text(body.refresh_token),
|
||||
displayName: null,
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
const code = text(body.error) ?? "";
|
||||
const message = text(body.error_description);
|
||||
switch (code) {
|
||||
case "authorization_pending":
|
||||
return { status: "pending" };
|
||||
case "slow_down":
|
||||
return { status: "slow-down" };
|
||||
case "expired_token":
|
||||
return { status: "failed", message: message ?? "Device authorization code expired" };
|
||||
case "access_denied":
|
||||
return { status: "denied", ...(message ? { message } : {}) };
|
||||
default:
|
||||
return {
|
||||
status: "failed",
|
||||
message: message ??
|
||||
(code
|
||||
? `OAuth error: ${code}`
|
||||
: `xAI device authorization failed (HTTP ${response.status})`),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const grokDeviceOAuth: OAuth2AddMethod = {
|
||||
type: "oauth2.0",
|
||||
id: "xai-device",
|
||||
displayName: {
|
||||
"en-US": "Sign in with xAI",
|
||||
"zh-CN": "使用 xAI 登录",
|
||||
},
|
||||
description: {
|
||||
"en-US": "Authorize this device with xAI, then add the resulting Grok account.",
|
||||
"zh-CN": "在 xAI 完成设备授权后,自动添加对应的 Grok 账号。",
|
||||
},
|
||||
begin,
|
||||
poll,
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"apiVersion": 1,
|
||||
"id": "dev.cursorbyok.examples.grok-auth",
|
||||
"name": "Grok",
|
||||
"version": "0.1.0",
|
||||
"author": "@leookun",
|
||||
"minAppVersion": "0.1.0",
|
||||
"icon": "assets/grok.svg",
|
||||
"entry": "main.ts",
|
||||
"permissions": {
|
||||
"network": [
|
||||
"auth.x.ai",
|
||||
"api.x.ai",
|
||||
"cli-chat-proxy.grok.com"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import type {
|
||||
ProviderInvokeInput,
|
||||
ProviderOutput,
|
||||
ProviderResult,
|
||||
ProviderSupport,
|
||||
} from "cursor-byok:provider";
|
||||
import type { PluginContext } from "cursor-byok:plugin";
|
||||
import { HttpError, streamOpenAiChat } from "cursor-byok:protocol/openai-chat";
|
||||
import { grokModels } from "./models.ts";
|
||||
import { type AccountData, accountData, quotaExhaustedPatch, RESOURCE_TYPE } from "./resources.ts";
|
||||
|
||||
const CHAT_URL = "https://api.x.ai/v1/chat/completions";
|
||||
|
||||
/** 流内错误只有文本可用,按积分/额度关键词分类。 */
|
||||
export function isQuotaError(error: string): boolean {
|
||||
const message = error.toLowerCase();
|
||||
return message.includes("insufficient_quota") ||
|
||||
message.includes("credits exhausted") ||
|
||||
message.includes("out of credits") ||
|
||||
message.includes("quota_exceeded") ||
|
||||
(message.includes("429") &&
|
||||
(message.includes("quota") || message.includes("credit") ||
|
||||
message.includes("insufficient")));
|
||||
}
|
||||
|
||||
/** HTTP 失败携带结构化状态码,429 一律按额度耗尽处理并冷却账号。 */
|
||||
function isQuotaHttpError(error: HttpError): boolean {
|
||||
if (error.status === 429) return true;
|
||||
const body = error.body.toLowerCase();
|
||||
return body.includes("insufficient_quota") ||
|
||||
body.includes("credits exhausted") ||
|
||||
body.includes("out of credits") ||
|
||||
// 免费账号触达消费上限时返回 403 spending-limit,属于额度而非授权问题。
|
||||
body.includes("spending-limit") ||
|
||||
body.includes("run out of credits") ||
|
||||
body.includes("quota_exceeded");
|
||||
}
|
||||
|
||||
function invalidResult(message: string, stateMessage: string): ProviderResult {
|
||||
return {
|
||||
status: "resource-error",
|
||||
message,
|
||||
patch: { state: { status: "invalid", message: stateMessage } },
|
||||
};
|
||||
}
|
||||
|
||||
async function invoke(
|
||||
input: ProviderInvokeInput,
|
||||
output: ProviderOutput,
|
||||
context: PluginContext,
|
||||
): Promise<ProviderResult> {
|
||||
if (!input.resource) {
|
||||
return { status: "request-error", message: "add a Grok account before calling Grok" };
|
||||
}
|
||||
let data: AccountData;
|
||||
try {
|
||||
data = accountData(input.resource);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return invalidResult(message, message);
|
||||
}
|
||||
try {
|
||||
await streamOpenAiChat(
|
||||
{
|
||||
url: CHAT_URL,
|
||||
model: input.model.id,
|
||||
// xAI 不接受 reasoning_effort 与 service_tier;思考由模型自身决定。
|
||||
request: {
|
||||
...input.request,
|
||||
reasoning: { enabled: false, effort: null },
|
||||
latency: "standard",
|
||||
},
|
||||
headers: { authorization: `Bearer ${data.accessToken}` },
|
||||
},
|
||||
output,
|
||||
context,
|
||||
);
|
||||
return { status: "completed" };
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError) {
|
||||
if ((error.status === 401 || error.status === 403) && !isQuotaHttpError(error)) {
|
||||
return invalidResult(error.message, "Grok authorization expired; sign in again");
|
||||
}
|
||||
if (isQuotaHttpError(error)) {
|
||||
return {
|
||||
status: "resource-error",
|
||||
message: error.message,
|
||||
patch: quotaExhaustedPatch(data),
|
||||
};
|
||||
}
|
||||
return { status: "request-error", message: error.message };
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (isQuotaError(message)) {
|
||||
return { status: "resource-error", message, patch: quotaExhaustedPatch(data) };
|
||||
}
|
||||
return { status: "request-error", message };
|
||||
}
|
||||
}
|
||||
|
||||
export const grokProvider: ProviderSupport = {
|
||||
id: "grok",
|
||||
displayName: "xAI Grok",
|
||||
description: {
|
||||
"en-US": "SuperGrok subscription access through the official Grok CLI endpoint.",
|
||||
"zh-CN": "通过官方 Grok CLI 接口使用 SuperGrok 订阅。",
|
||||
},
|
||||
providerType: "xai",
|
||||
resourceType: RESOURCE_TYPE,
|
||||
models: grokModels,
|
||||
invoke,
|
||||
};
|
||||
@@ -0,0 +1,342 @@
|
||||
import type { JsonValue, PluginContext } from "cursor-byok:plugin";
|
||||
import type {
|
||||
ResourceDraft,
|
||||
ResourceImportFile,
|
||||
ResourceImportResult,
|
||||
ResourceImportSupport,
|
||||
ResourceMetric,
|
||||
ResourcePatch,
|
||||
ResourceSnapshot,
|
||||
ResourceState,
|
||||
ResourceView,
|
||||
} from "cursor-byok:resource";
|
||||
|
||||
export const RESOURCE_TYPE = "grok-account";
|
||||
|
||||
const CREDITS_URL = "https://cli-chat-proxy.grok.com/v1/billing?format=credits";
|
||||
const ONE_HOUR_MS = 60 * 60 * 1000;
|
||||
|
||||
export type AccountQuota = {
|
||||
planLabel: string | null;
|
||||
usedPercent: number | null;
|
||||
remainingPercent: number | null;
|
||||
resetAtMs: number | null;
|
||||
limitReached: boolean;
|
||||
updatedAtMs: number;
|
||||
};
|
||||
|
||||
/** 单条 grok-account 资源的 privateData 形状。 */
|
||||
export type AccountData = {
|
||||
accessToken: string;
|
||||
refreshToken: string | null;
|
||||
displayName: string;
|
||||
quota: AccountQuota | null;
|
||||
};
|
||||
|
||||
export type CredentialCandidate = {
|
||||
accessToken: string;
|
||||
refreshToken: string | null;
|
||||
displayName: string | null;
|
||||
};
|
||||
|
||||
function object(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
function text(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function number(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
||||
const encoded = token.split(".")[1];
|
||||
if (!encoded) return null;
|
||||
try {
|
||||
const normalized = encoded.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
|
||||
const bytes = Uint8Array.from(atob(padded), (character) => character.charCodeAt(0));
|
||||
return object(JSON.parse(new TextDecoder().decode(bytes)));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function claim(payload: Record<string, unknown> | null, key: string): string | null {
|
||||
return payload ? text(payload[key]) : null;
|
||||
}
|
||||
|
||||
async function tokenFingerprint(token: string): Promise<string> {
|
||||
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
|
||||
return Array.from(
|
||||
new Uint8Array(digest).slice(0, 8),
|
||||
(byte) => byte.toString(16).padStart(2, "0"),
|
||||
).join("");
|
||||
}
|
||||
|
||||
export async function accountIdentity(
|
||||
accessToken: string,
|
||||
): Promise<{ key: string; displayName: string }> {
|
||||
const payload = decodeJwtPayload(accessToken);
|
||||
const identity = claim(payload, "sub") ??
|
||||
claim(payload, "email") ??
|
||||
await tokenFingerprint(accessToken);
|
||||
const displayName = claim(payload, "email") ??
|
||||
claim(payload, "preferred_username") ??
|
||||
claim(payload, "name") ??
|
||||
identity;
|
||||
return { key: `grok:${identity}`, displayName };
|
||||
}
|
||||
|
||||
export async function credentialDraft(credential: CredentialCandidate): Promise<ResourceDraft> {
|
||||
const identity = await accountIdentity(credential.accessToken);
|
||||
const data: AccountData = {
|
||||
accessToken: credential.accessToken,
|
||||
refreshToken: credential.refreshToken,
|
||||
displayName: credential.displayName ?? identity.displayName,
|
||||
quota: null,
|
||||
};
|
||||
return { key: identity.key, privateData: data as unknown as JsonValue };
|
||||
}
|
||||
|
||||
export function accountData(resource: ResourceSnapshot): AccountData {
|
||||
const data = object(resource.privateData);
|
||||
const accessToken = text(data?.accessToken);
|
||||
if (!accessToken) throw new Error("Grok account resource is missing its access token");
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken: text(data?.refreshToken),
|
||||
displayName: text(data?.displayName) ?? "Grok account",
|
||||
quota: (data?.quota ?? null) as AccountQuota | null,
|
||||
};
|
||||
}
|
||||
|
||||
function clampPercent(value: number): number {
|
||||
return Math.max(0, Math.min(100, value));
|
||||
}
|
||||
|
||||
function resetAtMs(value: unknown): number | null {
|
||||
const numeric = number(value);
|
||||
if (numeric !== null) return numeric > 10_000_000_000 ? numeric : numeric * 1000;
|
||||
if (typeof value === "string") {
|
||||
const parsed = Date.parse(value);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 解析 Grok CLI 计费接口的积分响应;creditUsagePercent 表示已用占比。 */
|
||||
export function parseGrokUsage(body: unknown, nowMs = Date.now()): AccountQuota {
|
||||
const root = object(body) ?? {};
|
||||
const config = object(root.config) ?? root;
|
||||
let used = number(config.creditUsagePercent ?? config.credit_usage_percent);
|
||||
if (used === null) {
|
||||
const onDemandUsed = number(config.onDemandUsed ?? config.on_demand_used);
|
||||
const onDemandCap = number(config.onDemandCap ?? config.on_demand_cap);
|
||||
if (onDemandUsed !== null && onDemandCap !== null && onDemandCap > 0) {
|
||||
used = (onDemandUsed / onDemandCap) * 100;
|
||||
}
|
||||
}
|
||||
// 存在计费周期但没有用量字段时视为未使用。
|
||||
if (used === null && (config.currentPeriod ?? config.current_period) !== undefined) {
|
||||
used = 0;
|
||||
}
|
||||
const remaining = used === null ? null : clampPercent(100 - used);
|
||||
const period = object(config.currentPeriod ?? config.current_period);
|
||||
return {
|
||||
planLabel: text(
|
||||
config.subscriptionTierDisplay ?? config.subscription_tier_display ??
|
||||
config.subscriptionTier ?? config.product,
|
||||
),
|
||||
usedPercent: used === null ? null : clampPercent(used),
|
||||
remainingPercent: remaining,
|
||||
resetAtMs: resetAtMs(period?.end ?? config.billingPeriodEnd ?? config.billing_period_end),
|
||||
limitReached: remaining !== null && remaining <= 0,
|
||||
updatedAtMs: nowMs,
|
||||
};
|
||||
}
|
||||
|
||||
export function quotaState(quota: AccountQuota | null, nowMs = Date.now()): ResourceState {
|
||||
if (!quota || !quota.limitReached) return { status: "ready" };
|
||||
if (quota.resetAtMs !== null && quota.resetAtMs <= nowMs) return { status: "ready" };
|
||||
return {
|
||||
status: "cooling",
|
||||
retryAtMs: quota.resetAtMs ?? nowMs + ONE_HOUR_MS,
|
||||
message: "Grok credits are exhausted",
|
||||
};
|
||||
}
|
||||
|
||||
/** 额度耗尽时的资源补丁:标记积分耗尽并进入冷却,重置时间未知时回退 1 小时。 */
|
||||
export function quotaExhaustedPatch(data: AccountData, nowMs = Date.now()): ResourcePatch {
|
||||
const quota: AccountQuota = {
|
||||
planLabel: data.quota?.planLabel ?? null,
|
||||
usedPercent: 100,
|
||||
remainingPercent: 0,
|
||||
resetAtMs: data.quota?.resetAtMs !== undefined && data.quota?.resetAtMs !== null &&
|
||||
data.quota.resetAtMs > nowMs
|
||||
? data.quota.resetAtMs
|
||||
: null,
|
||||
limitReached: true,
|
||||
updatedAtMs: nowMs,
|
||||
};
|
||||
return {
|
||||
privateData: { ...data, quota } as unknown as JsonValue,
|
||||
state: quotaState(quota, nowMs),
|
||||
};
|
||||
}
|
||||
|
||||
export function accountHeaders(data: AccountData): Record<string, string> {
|
||||
return {
|
||||
accept: "application/json",
|
||||
authorization: `Bearer ${data.accessToken}`,
|
||||
// Grok CLI 计费接口要求该头标识客户端来源。
|
||||
"x-xai-token-auth": "xai-grok-cli",
|
||||
};
|
||||
}
|
||||
|
||||
function jwtDisplayName(token: string | null): string | null {
|
||||
if (!token) return null;
|
||||
const payload = decodeJwtPayload(token);
|
||||
return claim(payload, "email") ?? claim(payload, "preferred_username") ??
|
||||
claim(payload, "name");
|
||||
}
|
||||
|
||||
export function presentAccount(resource: ResourceSnapshot): ResourceView {
|
||||
const data = accountData(resource);
|
||||
const metrics: ResourceMetric[] = [];
|
||||
const quota = data.quota;
|
||||
if (quota && quota.remainingPercent !== null) {
|
||||
metrics.push({
|
||||
id: "credits",
|
||||
label: { "en-US": "Credits", "zh-CN": "积分额度" },
|
||||
unit: "percent",
|
||||
value: quota.remainingPercent,
|
||||
...(quota.resetAtMs !== null ? { resetAtMs: quota.resetAtMs } : {}),
|
||||
});
|
||||
}
|
||||
return {
|
||||
// 旧记录可能存的是账号 ID;展示时优先从 token 现算邮箱。
|
||||
displayName: jwtDisplayName(data.accessToken) ?? data.displayName,
|
||||
...(quota?.planLabel ? { description: quota.planLabel } : {}),
|
||||
...(metrics.length > 0 ? { metrics } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function refreshAccount(
|
||||
resource: ResourceSnapshot,
|
||||
context: PluginContext,
|
||||
): Promise<ResourcePatch> {
|
||||
const data = accountData(resource);
|
||||
const response = await context.network.fetch(CREDITS_URL, {
|
||||
method: "GET",
|
||||
headers: accountHeaders(data),
|
||||
});
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return {
|
||||
state: { status: "invalid", message: "Grok authorization expired; sign in again" },
|
||||
};
|
||||
}
|
||||
throw new Error(`Grok usage lookup failed (HTTP ${response.status}): ${response.body}`);
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
body = JSON.parse(response.body);
|
||||
} catch {
|
||||
throw new Error("Grok usage lookup returned invalid JSON");
|
||||
}
|
||||
const quota = parseGrokUsage(body);
|
||||
return {
|
||||
privateData: { ...data, quota } as unknown as JsonValue,
|
||||
state: quotaState(quota),
|
||||
};
|
||||
}
|
||||
|
||||
function firstText(source: Record<string, unknown>, keys: string[]): string | null {
|
||||
for (const key of keys) {
|
||||
const value = text(source[key]);
|
||||
if (value) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function collectCredentials(value: unknown, output: CredentialCandidate[]): void {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) collectCredentials(item, output);
|
||||
return;
|
||||
}
|
||||
const item = object(value);
|
||||
if (!item || item.disabled === true) return;
|
||||
for (const key of ["accounts", "credentials", "items"]) {
|
||||
if (Array.isArray(item[key])) {
|
||||
collectCredentials(item[key], output);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const tokens = object(item.tokens) ?? item;
|
||||
const accessToken = firstText(tokens, ["access_token", "accessToken", "token", "key"]) ??
|
||||
firstText(item, ["access_token", "accessToken", "token", "key", "XAI_API_KEY"]);
|
||||
if (!accessToken) return;
|
||||
const refreshToken = firstText(tokens, ["refresh_token", "refreshToken"]) ??
|
||||
firstText(item, ["refresh_token", "refreshToken"]);
|
||||
const displayName = firstText(item, ["email", "display_name", "displayName", "name"]) ??
|
||||
firstText(tokens, ["email", "display_name", "displayName", "name"]);
|
||||
output.push({ accessToken, refreshToken, displayName });
|
||||
}
|
||||
|
||||
export function parseCredentialFiles(files: ResourceImportFile[]): {
|
||||
credentials: CredentialCandidate[];
|
||||
warnings: string[];
|
||||
} {
|
||||
const credentials: CredentialCandidate[] = [];
|
||||
const warnings: string[] = [];
|
||||
for (const file of files) {
|
||||
let content: unknown;
|
||||
try {
|
||||
content = JSON.parse(file.content);
|
||||
} catch {
|
||||
warnings.push(`${file.name}: not valid JSON`);
|
||||
continue;
|
||||
}
|
||||
const found: CredentialCandidate[] = [];
|
||||
collectCredentials(content, found);
|
||||
if (found.length === 0) {
|
||||
warnings.push(`${file.name}: no Grok access token found`);
|
||||
continue;
|
||||
}
|
||||
credentials.push(...found);
|
||||
}
|
||||
return { credentials, warnings };
|
||||
}
|
||||
|
||||
export const credentialImport: ResourceImportSupport = {
|
||||
displayName: {
|
||||
"en-US": "Import Grok credentials",
|
||||
"zh-CN": "导入 Grok 凭证",
|
||||
},
|
||||
description: {
|
||||
"en-US": "Import one or more Grok JSON credential files.",
|
||||
"zh-CN": "导入一个或多个 Grok JSON 凭证文件。",
|
||||
},
|
||||
accept: [".json"],
|
||||
multiple: true,
|
||||
parse: async (files: ResourceImportFile[]): Promise<ResourceImportResult> => {
|
||||
const { credentials, warnings } = parseCredentialFiles(files);
|
||||
if (credentials.length === 0) {
|
||||
throw new Error(warnings.join("; ") || "credential JSON does not contain an access token");
|
||||
}
|
||||
return {
|
||||
resources: await Promise.all(credentials.map(credentialDraft)),
|
||||
...(warnings.length > 0 ? { warnings } : {}),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -57,7 +57,59 @@ const CODEX_AUTH: &[(&str, &str)] = &[
|
||||
),
|
||||
];
|
||||
|
||||
const PLUGINS: &[(&str, &[(&str, &str)])] = &[("codex-auth", CODEX_AUTH)];
|
||||
const GROK_AUTH: &[(&str, &str)] = &[
|
||||
(
|
||||
"plugin.json",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/plugins/build-in/grok-auth/plugin.json"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"main.ts",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/plugins/build-in/grok-auth/main.ts"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"provider.ts",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/plugins/build-in/grok-auth/provider.ts"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"models.ts",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/plugins/build-in/grok-auth/models.ts"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"oauth.ts",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/plugins/build-in/grok-auth/oauth.ts"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"resources.ts",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/plugins/build-in/grok-auth/resources.ts"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"assets/grok.svg",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/plugins/build-in/grok-auth/assets/grok.svg"
|
||||
)),
|
||||
),
|
||||
];
|
||||
|
||||
const PLUGINS: &[(&str, &[(&str, &str)])] = &[("codex-auth", CODEX_AUTH), ("grok-auth", GROK_AUTH)];
|
||||
|
||||
/// 把内置插件预装到 installed 目录。manifest 的 version 是缓存键:
|
||||
/// 版本一致时零写盘;版本变化时整目录同步并清理旧版本残留文件。
|
||||
|
||||
@@ -13,7 +13,6 @@ use crate::{config, Error, Result};
|
||||
const DEFINITION_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const MAX_OUTPUT_BYTES: u64 = 2 * 1024 * 1024;
|
||||
const OUTPUT_PREFIX: &str = "CURSOR_BYOK_PLUGIN_DEFINITION:";
|
||||
const IMPORT_MAP: &str = r#"{"imports":{"cursor-byok:plugin":"./plugin.ts","cursor-byok:provider":"./provider.ts","cursor-byok:model":"./model.ts","cursor-byok:resource":"./resource.ts","cursor-byok:protocol/openai-responses":"./protocol/openai_responses.ts"}}"#;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PluginDefinitionLoader {
|
||||
@@ -43,7 +42,7 @@ impl PluginDefinitionLoader {
|
||||
let deno_dir = sdk_dir.join("cache");
|
||||
std::fs::create_dir_all(&deno_dir)?;
|
||||
let modules = [
|
||||
(&import_map, IMPORT_MAP),
|
||||
(&import_map, include_str!("sdk/import-map.json")),
|
||||
(&collector, include_str!("sdk/collect.ts")),
|
||||
(&worker, include_str!("sdk/worker.ts")),
|
||||
(&sdk_dir.join("plugin.ts"), include_str!("sdk/plugin.ts")),
|
||||
@@ -60,6 +59,10 @@ impl PluginDefinitionLoader {
|
||||
&sdk_dir.join("protocol/openai_responses.ts"),
|
||||
include_str!("sdk/protocol/openai_responses.ts"),
|
||||
),
|
||||
(
|
||||
&sdk_dir.join("protocol/openai_chat.ts"),
|
||||
include_str!("sdk/protocol/openai_chat.ts"),
|
||||
),
|
||||
];
|
||||
for (path, content) in &modules {
|
||||
write_if_changed(path, content)?;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"cursor-byok:provider": "./provider.ts",
|
||||
"cursor-byok:model": "./model.ts",
|
||||
"cursor-byok:resource": "./resource.ts",
|
||||
"cursor-byok:protocol/openai-responses": "./protocol/openai_responses.ts"
|
||||
"cursor-byok:protocol/openai-responses": "./protocol/openai_responses.ts",
|
||||
"cursor-byok:protocol/openai-chat": "./protocol/openai_chat.ts"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
import type { JsonValue, PluginContext } from "../plugin.ts";
|
||||
import type { LlmContentPart, LlmRequest, ModelEvent, ProviderOutput } from "../provider.ts";
|
||||
|
||||
/** 本协议产生的回放状态种类;与宿主内置 Chat Provider 一致,可互相回放。 */
|
||||
export const REPLAY_KIND = "openai_chat";
|
||||
|
||||
/** 上游返回非 2xx 时抛出,携带完整响应体供调用方分类。 */
|
||||
export class HttpError extends Error {
|
||||
constructor(readonly status: number, readonly body: string) {
|
||||
super(`HTTP ${status}: ${body}`);
|
||||
}
|
||||
}
|
||||
|
||||
export type OpenAiChatCall = {
|
||||
url: string;
|
||||
model: string;
|
||||
request: LlmRequest;
|
||||
headers?: Record<string, string>;
|
||||
/** 最后合并进请求体。 */
|
||||
extraBody?: Record<string, JsonValue>;
|
||||
};
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
function text(value: unknown): string | null {
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
function count(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
/** 纯文本消息保持字符串形式;混合图片时展开为分块数组。 */
|
||||
function chatContent(parts: LlmContentPart[]): JsonValue {
|
||||
if (parts.every((part) => part.type === "text")) {
|
||||
return parts.map((part) => part.type === "text" ? part.text : "").join("");
|
||||
}
|
||||
return parts.map((part): JsonValue =>
|
||||
part.type === "text" ? { type: "text", text: part.text } : {
|
||||
type: "image_url",
|
||||
image_url: { url: `data:${part.mediaType};base64,${part.dataBase64}` },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function buildChatBody(call: OpenAiChatCall): Record<string, JsonValue> {
|
||||
const messages: JsonValue[] = [];
|
||||
if (call.request.instructions) {
|
||||
messages.push({ role: "system", content: call.request.instructions });
|
||||
}
|
||||
for (const message of call.request.messages) {
|
||||
if (message.role === "assistant") {
|
||||
const reasoning = message.replayState?.providerKind === REPLAY_KIND
|
||||
? text(record(message.replayState.value)?.reasoning_content)
|
||||
: null;
|
||||
// Chat Completions 拒绝空字符串的 assistant content;完全无可见
|
||||
// 内容的 assistant 消息不需要发送。
|
||||
if (!message.text && message.toolCalls.length === 0 && !reasoning) continue;
|
||||
const value: Record<string, JsonValue> = {
|
||||
role: "assistant",
|
||||
content: message.text ? message.text : null,
|
||||
};
|
||||
if (reasoning) value.reasoning_content = reasoning;
|
||||
if (message.toolCalls.length > 0) {
|
||||
value.tool_calls = message.toolCalls.map((toolCall) => ({
|
||||
id: toolCall.callId,
|
||||
type: "function",
|
||||
function: { name: toolCall.name, arguments: JSON.stringify(toolCall.arguments) },
|
||||
}));
|
||||
}
|
||||
messages.push(value);
|
||||
} else if (message.role === "tool") {
|
||||
messages.push({
|
||||
role: "tool",
|
||||
content: message.parts.length === 0 ? message.content : chatContent(message.parts),
|
||||
tool_call_id: message.callId,
|
||||
});
|
||||
} else {
|
||||
messages.push({ role: message.role, content: chatContent(message.content) });
|
||||
}
|
||||
}
|
||||
const body: Record<string, JsonValue> = {
|
||||
model: call.model,
|
||||
messages,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
};
|
||||
if (call.request.tools.length > 0) {
|
||||
body.tools = call.request.tools.map((tool) => ({
|
||||
type: "function",
|
||||
function: { name: tool.name, description: tool.description, parameters: tool.parameters },
|
||||
}));
|
||||
}
|
||||
if (call.request.maxOutputTokens !== null) {
|
||||
body.max_completion_tokens = call.request.maxOutputTokens;
|
||||
}
|
||||
if (call.request.reasoning.effort !== null) {
|
||||
body.reasoning_effort = call.request.reasoning.effort;
|
||||
}
|
||||
if (call.request.latency === "fast") body.service_tier = "fast";
|
||||
if (call.request.cacheKey !== null) body.prompt_cache_key = call.request.cacheKey;
|
||||
return { ...body, ...call.extraBody };
|
||||
}
|
||||
|
||||
type ToolState = {
|
||||
callId: string;
|
||||
name: string;
|
||||
arguments: string;
|
||||
emitted: number;
|
||||
started: boolean;
|
||||
};
|
||||
|
||||
/** 部分上游会重发完整片段而不是增量;去重后再拼接。 */
|
||||
function mergeFragment(target: string, fragment: string): string {
|
||||
if (target === fragment || target.endsWith(fragment)) return target;
|
||||
if (fragment.startsWith(target)) return fragment;
|
||||
return target + fragment;
|
||||
}
|
||||
|
||||
function updateTool(
|
||||
index: number,
|
||||
callId: string | null,
|
||||
name: string | null,
|
||||
argumentsDelta: string | null,
|
||||
tools: Map<number, ToolState>,
|
||||
): ModelEvent[] {
|
||||
let tool = tools.get(index);
|
||||
if (!tool) {
|
||||
tool = { callId: "", name: "", arguments: "", emitted: 0, started: false };
|
||||
tools.set(index, tool);
|
||||
}
|
||||
if (callId !== null) tool.callId = mergeFragment(tool.callId, callId);
|
||||
if (name !== null) tool.name = mergeFragment(tool.name, name);
|
||||
if (argumentsDelta !== null) tool.arguments += argumentsDelta;
|
||||
|
||||
const events: ModelEvent[] = [];
|
||||
if (!tool.started && tool.callId && tool.name) {
|
||||
tool.started = true;
|
||||
events.push({ type: "tool-call-start", index, callId: tool.callId, name: tool.name });
|
||||
}
|
||||
if (tool.started && tool.emitted < tool.arguments.length) {
|
||||
events.push({
|
||||
type: "tool-call-arguments-delta",
|
||||
index,
|
||||
delta: tool.arguments.slice(tool.emitted),
|
||||
});
|
||||
tool.emitted = tool.arguments.length;
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
function eventError(value: Record<string, unknown>): string | null {
|
||||
const error = value.error;
|
||||
if (error === undefined || error === null) return null;
|
||||
if (typeof error === "string") return error;
|
||||
return text(record(error)?.message) ?? JSON.stringify(error);
|
||||
}
|
||||
|
||||
function usageEvent(value: unknown): ModelEvent {
|
||||
const usage = record(value) ?? {};
|
||||
return {
|
||||
type: "usage",
|
||||
usage: {
|
||||
inputTokens: count(usage.prompt_tokens),
|
||||
outputTokens: count(usage.completion_tokens),
|
||||
totalTokens: count(usage.total_tokens),
|
||||
cacheReadTokens: count(record(usage.prompt_tokens_details)?.cached_tokens),
|
||||
cacheWriteTokens: null,
|
||||
reasoningTokens: count(record(usage.completion_tokens_details)?.reasoning_tokens),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mapFinish(reason: string, hasTools: boolean): "stop" | "length" | "tool-use" {
|
||||
if (reason === "tool_calls" || reason === "function_call") return "tool-use";
|
||||
if (reason === "length") return "length";
|
||||
if (reason === "stop" || reason === "content_filter") return "stop";
|
||||
return hasTools ? "tool-use" : "stop";
|
||||
}
|
||||
|
||||
async function readBody(lines: AsyncIterable<string>): Promise<string> {
|
||||
const collected: string[] = [];
|
||||
for await (const line of lines) collected.push(line);
|
||||
return collected.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行一次 Chat Completions 流式调用,发出与宿主统一事件集一致的标准化事件,
|
||||
* 包括文本/思考边界与工具参数增量。非 2xx 响应抛出 `HttpError`,
|
||||
* 流内失败抛出 `Error`,由调用方分类额度与授权问题。
|
||||
*/
|
||||
export async function streamOpenAiChat(
|
||||
call: OpenAiChatCall,
|
||||
output: ProviderOutput,
|
||||
context: PluginContext,
|
||||
): Promise<void> {
|
||||
const response = await context.network.stream(call.url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "text/event-stream",
|
||||
"content-type": "application/json",
|
||||
...call.headers,
|
||||
},
|
||||
body: JSON.stringify(buildChatBody(call)),
|
||||
});
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
throw new HttpError(response.status, await readBody(response.lines));
|
||||
}
|
||||
|
||||
let textOpen = false;
|
||||
let thinkingOpen = false;
|
||||
let reasoning = "";
|
||||
const tools = new Map<number, ToolState>();
|
||||
let finalUsage: ModelEvent | null = null;
|
||||
let finish: "stop" | "length" | "tool-use" | null = null;
|
||||
let sawDoneMarker = false;
|
||||
|
||||
for await (const line of response.lines) {
|
||||
if (!line.startsWith("data:")) continue;
|
||||
const payload = line.slice(5).trim();
|
||||
if (!payload) continue;
|
||||
if (payload === "[DONE]") {
|
||||
sawDoneMarker = true;
|
||||
break;
|
||||
}
|
||||
let value: Record<string, unknown>;
|
||||
try {
|
||||
value = record(JSON.parse(payload)) ?? {};
|
||||
} catch {
|
||||
throw new Error("OpenAI Chat SSE returned invalid JSON");
|
||||
}
|
||||
const error = eventError(value);
|
||||
if (error !== null) throw new Error(`OpenAI Chat error: ${error}`);
|
||||
if (value.usage !== undefined && value.usage !== null) {
|
||||
finalUsage = usageEvent(value.usage);
|
||||
}
|
||||
const choice = Array.isArray(value.choices) ? record(value.choices[0]) : null;
|
||||
if (!choice) continue;
|
||||
const delta = record(choice.delta) ?? {};
|
||||
const reasoningDelta = text(delta.reasoning_content) ?? text(delta.reasoning);
|
||||
if (reasoningDelta) {
|
||||
if (!thinkingOpen) {
|
||||
thinkingOpen = true;
|
||||
output.emit({ type: "thinking-start" });
|
||||
}
|
||||
reasoning += reasoningDelta;
|
||||
output.emit({ type: "thinking-delta", text: reasoningDelta });
|
||||
}
|
||||
const content = text(delta.content);
|
||||
if (content) {
|
||||
if (thinkingOpen) {
|
||||
thinkingOpen = false;
|
||||
output.emit({ type: "thinking-end" });
|
||||
}
|
||||
if (!textOpen) {
|
||||
textOpen = true;
|
||||
output.emit({ type: "text-start" });
|
||||
}
|
||||
output.emit({ type: "text-delta", text: content });
|
||||
}
|
||||
if (Array.isArray(delta.tool_calls)) {
|
||||
for (const [position, rawTool] of delta.tool_calls.entries()) {
|
||||
const toolDelta = record(rawTool);
|
||||
if (!toolDelta) continue;
|
||||
const index = count(toolDelta.index) ?? position;
|
||||
const fn = record(toolDelta.function) ?? {};
|
||||
for (
|
||||
const event of updateTool(
|
||||
index,
|
||||
text(toolDelta.id),
|
||||
text(fn.name),
|
||||
text(fn.arguments),
|
||||
tools,
|
||||
)
|
||||
) {
|
||||
output.emit(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
const finishReason = text(choice.finish_reason);
|
||||
if (finishReason !== null) finish = mapFinish(finishReason, tools.size > 0);
|
||||
}
|
||||
|
||||
if (thinkingOpen) output.emit({ type: "thinking-end" });
|
||||
if (textOpen) output.emit({ type: "text-end" });
|
||||
for (const [index, tool] of tools) {
|
||||
if (!tool.started) {
|
||||
if (!tool.name) throw new Error("OpenAI Chat tool call is missing name");
|
||||
if (!tool.callId) tool.callId = `call-${index}`;
|
||||
tool.started = true;
|
||||
output.emit({ type: "tool-call-start", index, callId: tool.callId, name: tool.name });
|
||||
if (tool.arguments) {
|
||||
tool.emitted = tool.arguments.length;
|
||||
output.emit({ type: "tool-call-arguments-delta", index, delta: tool.arguments });
|
||||
}
|
||||
}
|
||||
output.emit({ type: "tool-call-end", index });
|
||||
}
|
||||
if (finalUsage !== null) output.emit(finalUsage);
|
||||
if (reasoning) {
|
||||
output.emit({
|
||||
type: "replay-state",
|
||||
providerKind: REPLAY_KIND,
|
||||
value: { reasoning_content: reasoning },
|
||||
});
|
||||
}
|
||||
const reason = finish ??
|
||||
(sawDoneMarker ? (tools.size > 0 ? "tool-use" : "stop") : null);
|
||||
if (reason === null) {
|
||||
throw new Error("OpenAI Chat stream ended without finish_reason");
|
||||
}
|
||||
output.emit({ type: "done", reason });
|
||||
}
|
||||
Reference in New Issue
Block a user