diff --git a/server/plugins/build-in/grok-auth/assets/grok.svg b/server/plugins/build-in/grok-auth/assets/grok.svg
new file mode 100644
index 0000000..9ed3d3d
--- /dev/null
+++ b/server/plugins/build-in/grok-auth/assets/grok.svg
@@ -0,0 +1,10 @@
+
+
diff --git a/server/plugins/build-in/grok-auth/deno.json b/server/plugins/build-in/grok-auth/deno.json
new file mode 100644
index 0000000..625b8d8
--- /dev/null
+++ b/server/plugins/build-in/grok-auth/deno.json
@@ -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"]
+ }
+}
diff --git a/server/plugins/build-in/grok-auth/grok_test.ts b/server/plugins/build-in/grok-auth/grok_test.ts
new file mode 100644
index 0000000..13f0fc7
--- /dev/null
+++ b/server/plugins/build-in/grok-auth/grok_test.ts
@@ -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 {
+ const encoded = btoa(JSON.stringify(payload)).replace(/=/g, "").replace(/\+/g, "-").replace(
+ /\//g,
+ "_",
+ );
+ return `header.${encoded}.signature`;
+}
+
+type RequestInit = { body?: string; headers?: Record };
+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 {
+ 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 = {};
+ 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;
+ 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");
+});
diff --git a/server/plugins/build-in/grok-auth/main.ts b/server/plugins/build-in/grok-auth/main.ts
new file mode 100644
index 0000000..7b53a55
--- /dev/null
+++ b/server/plugins/build-in/grok-auth/main.ts
@@ -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,
+ }],
+});
diff --git a/server/plugins/build-in/grok-auth/models.ts b/server/plugins/build-in/grok-auth/models.ts
new file mode 100644
index 0000000..a83a145
--- /dev/null
+++ b/server/plugins/build-in/grok-auth/models.ts
@@ -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 | null {
+ return value !== null && typeof value === "object" && !Array.isArray(value)
+ ? value as Record
+ : 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();
+ 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 => {
+ 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;
+ },
+};
diff --git a/server/plugins/build-in/grok-auth/oauth.ts b/server/plugins/build-in/grok-auth/oauth.ts
new file mode 100644
index 0000000..4335808
--- /dev/null
+++ b/server/plugins/build-in/grok-auth/oauth.ts
@@ -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 | null {
+ return value !== null && typeof value === "object" && !Array.isArray(value)
+ ? value as Record
+ : 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 {
+ 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 {
+ 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 {
+ 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,
+};
diff --git a/server/plugins/build-in/grok-auth/plugin.json b/server/plugins/build-in/grok-auth/plugin.json
new file mode 100644
index 0000000..711b8ea
--- /dev/null
+++ b/server/plugins/build-in/grok-auth/plugin.json
@@ -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"
+ ]
+ }
+}
diff --git a/server/plugins/build-in/grok-auth/provider.ts b/server/plugins/build-in/grok-auth/provider.ts
new file mode 100644
index 0000000..a7698ed
--- /dev/null
+++ b/server/plugins/build-in/grok-auth/provider.ts
@@ -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 {
+ 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,
+};
diff --git a/server/plugins/build-in/grok-auth/resources.ts b/server/plugins/build-in/grok-auth/resources.ts
new file mode 100644
index 0000000..296fefd
--- /dev/null
+++ b/server/plugins/build-in/grok-auth/resources.ts
@@ -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 | null {
+ return value !== null && typeof value === "object" && !Array.isArray(value)
+ ? value as Record
+ : 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 | 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 | null, key: string): string | null {
+ return payload ? text(payload[key]) : null;
+}
+
+async function tokenFingerprint(token: string): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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, 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 => {
+ 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 } : {}),
+ };
+ },
+};
diff --git a/server/src/plugin/builtin.rs b/server/src/plugin/builtin.rs
index 10bcca9..9ddd5ff 100644
--- a/server/src/plugin/builtin.rs
+++ b/server/src/plugin/builtin.rs
@@ -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 是缓存键:
/// 版本一致时零写盘;版本变化时整目录同步并清理旧版本残留文件。
diff --git a/server/src/plugin/definition.rs b/server/src/plugin/definition.rs
index c5a0182..23faa38 100644
--- a/server/src/plugin/definition.rs
+++ b/server/src/plugin/definition.rs
@@ -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)?;
diff --git a/server/src/plugin/sdk/import-map.json b/server/src/plugin/sdk/import-map.json
index f39ca46..813a81f 100644
--- a/server/src/plugin/sdk/import-map.json
+++ b/server/src/plugin/sdk/import-map.json
@@ -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"
}
}
diff --git a/server/src/plugin/sdk/protocol/openai_chat.ts b/server/src/plugin/sdk/protocol/openai_chat.ts
new file mode 100644
index 0000000..cd36bb9
--- /dev/null
+++ b/server/src/plugin/sdk/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;
+ /** 最后合并进请求体。 */
+ extraBody?: Record;
+};
+
+function record(value: unknown): Record | null {
+ return value !== null && typeof value === "object" && !Array.isArray(value)
+ ? value as Record
+ : 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 {
+ 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 = {
+ 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 = {
+ 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,
+): 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 | 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): Promise {
+ 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 {
+ 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();
+ 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;
+ 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 });
+}