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 @@ + + + grok + + + + + + + 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 }); +}