mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-10-04 02:52:55 +08:00
feat: plugin system
This commit is contained in:
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 43 KiB |
@@ -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 { codexDeviceOAuth } from "./oauth.ts";
|
||||
import { parseOfficialModels } from "./models.ts";
|
||||
import { codexProvider, isQuotaError } from "./provider.ts";
|
||||
import {
|
||||
accountIdentity,
|
||||
credentialDraft,
|
||||
parseCodexUsage,
|
||||
parseCredentialFiles,
|
||||
presentAccount,
|
||||
quotaState,
|
||||
RESOURCE_TYPE,
|
||||
} from "./resources.ts";
|
||||
|
||||
function assert(condition: unknown, message = "assertion failed"): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function assertEquals(actual: unknown, expected: unknown): void {
|
||||
const left = JSON.stringify(actual);
|
||||
const right = JSON.stringify(expected);
|
||||
if (left !== right) throw new Error(`expected ${right}, received ${left}`);
|
||||
}
|
||||
|
||||
function jwt(payload: Record<string, unknown>): string {
|
||||
const encoded = btoa(JSON.stringify(payload)).replace(/=/g, "").replace(/\+/g, "-").replace(
|
||||
/\//g,
|
||||
"_",
|
||||
);
|
||||
return `header.${encoded}.signature`;
|
||||
}
|
||||
|
||||
type RequestInit = { body?: string; headers?: Record<string, string> };
|
||||
type FetchHandler = (url: string, init?: RequestInit) => NetworkResponse;
|
||||
type StreamHandler = (url: string, init?: RequestInit) => NetworkEventStream;
|
||||
|
||||
function context(handlers: { fetch?: FetchHandler; stream?: StreamHandler }): PluginContext {
|
||||
return {
|
||||
network: {
|
||||
fetch: (url, init) => {
|
||||
if (!handlers.fetch) throw new Error("fetch was not expected");
|
||||
return Promise.resolve(handlers.fetch(url, init));
|
||||
},
|
||||
stream: (url, init) => {
|
||||
if (!handlers.stream) throw new Error("stream was not expected");
|
||||
return Promise.resolve(handlers.stream(url, init));
|
||||
},
|
||||
},
|
||||
signal: new AbortController().signal,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshot(privateData: JsonValue): ResourceSnapshot {
|
||||
return {
|
||||
id: "resource-1",
|
||||
type: RESOURCE_TYPE,
|
||||
key: "codex:acct-1",
|
||||
privateData,
|
||||
state: { status: "ready" },
|
||||
};
|
||||
}
|
||||
|
||||
async function* sse(lines: string[]): AsyncGenerator<string> {
|
||||
for (const line of lines) yield line;
|
||||
}
|
||||
|
||||
function request(): LlmRequest {
|
||||
return {
|
||||
instructions: "You are a coding assistant.",
|
||||
messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }],
|
||||
tools: [],
|
||||
reasoning: { enabled: true, effort: "medium" },
|
||||
latency: "fast",
|
||||
maxOutputTokens: 128_000,
|
||||
cacheKey: "conversation-1",
|
||||
};
|
||||
}
|
||||
|
||||
Deno.test("account identity prioritizes ChatGPT account ID and drafts keep tokens private-side", async () => {
|
||||
const token = jwt({
|
||||
"https://api.openai.com/auth": { chatgpt_account_id: "acct-1" },
|
||||
sub: "subject-1",
|
||||
email: "person@example.com",
|
||||
});
|
||||
assertEquals(await accountIdentity(token), {
|
||||
key: "codex:acct-1",
|
||||
displayName: "person@example.com",
|
||||
});
|
||||
const draft = await credentialDraft({
|
||||
accessToken: token,
|
||||
refreshToken: null,
|
||||
displayName: null,
|
||||
});
|
||||
assertEquals(draft.key, "codex:acct-1");
|
||||
const view = presentAccount(snapshot(draft.privateData));
|
||||
assert(!JSON.stringify(view).includes(token), "resource view exposed an access token");
|
||||
assertEquals(view.displayName, "person@example.com");
|
||||
});
|
||||
|
||||
Deno.test("credential import accepts Codex auth JSON files", () => {
|
||||
const { credentials, warnings } = parseCredentialFiles([
|
||||
{
|
||||
name: "auth.json",
|
||||
content: JSON.stringify({
|
||||
tokens: {
|
||||
access_token: "access-secret",
|
||||
refresh_token: "refresh-secret",
|
||||
id_token: jwt({ email: "person@example.com" }),
|
||||
},
|
||||
}),
|
||||
},
|
||||
{ name: "broken.json", content: "{not json" },
|
||||
]);
|
||||
assertEquals(credentials, [{
|
||||
accessToken: "access-secret",
|
||||
refreshToken: "refresh-secret",
|
||||
displayName: "person@example.com",
|
||||
}]);
|
||||
assertEquals(warnings, ["broken.json: not valid JSON"]);
|
||||
});
|
||||
|
||||
Deno.test("usage maps secondary to weekly and primary to five-hour quota", () => {
|
||||
const quota = parseCodexUsage({
|
||||
plan_type: "plus",
|
||||
rate_limit: {
|
||||
primary_window: { used_percent: 80, reset_at: 1_800_000_000 },
|
||||
secondary_window: { used_percent: 25, reset_at: 1_900_000_000 },
|
||||
},
|
||||
}, 1_700_000_000_000);
|
||||
assertEquals(quota.planLabel, "ChatGPT Plus");
|
||||
assertEquals(quota.weekly?.remainingPercent, 75);
|
||||
assertEquals(quota.fiveHour?.remainingPercent, 20);
|
||||
assertEquals(quota.weekly?.resetAtMs, 1_900_000_000_000);
|
||||
assertEquals(quotaState(quota, 1_700_000_000_000), { status: "ready" });
|
||||
});
|
||||
|
||||
Deno.test("exhausted quota projects a cooling state until the latest reset", () => {
|
||||
const quota = parseCodexUsage({
|
||||
rate_limit: {
|
||||
primary_window: { used_percent: 100, reset_at: 1_800_000_000 },
|
||||
secondary_window: { used_percent: 100, reset_at: 1_900_000_000 },
|
||||
},
|
||||
}, 1_700_000_000_000);
|
||||
assertEquals(quotaState(quota, 1_700_000_000_000), {
|
||||
status: "cooling",
|
||||
retryAtMs: 1_900_000_000_000,
|
||||
message: "ChatGPT quota is exhausted",
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test("official model discovery excludes hidden models and puts the default first", () => {
|
||||
const models = parseOfficialModels({
|
||||
default_model: "gpt-second",
|
||||
models: [
|
||||
{
|
||||
slug: "gpt-first",
|
||||
display_name: "GPT First",
|
||||
supported_in_api: true,
|
||||
visibility: "list",
|
||||
supported_reasoning_efforts: ["low", "medium"],
|
||||
},
|
||||
{ slug: "gpt-second", supported_in_api: true, visibility: "list" },
|
||||
{ slug: "gpt-hidden", supported_in_api: true, visibility: "hidden" },
|
||||
{ slug: "gpt-internal", supported_in_api: false, visibility: "list" },
|
||||
],
|
||||
});
|
||||
assertEquals(models.map((model) => model.id), ["gpt-second", "gpt-first"]);
|
||||
assertEquals(models[1].capabilities, { thinking: true, images: true });
|
||||
assertEquals(models[1].privateData, { reasoningEfforts: ["low", "medium"] });
|
||||
});
|
||||
|
||||
Deno.test("device OAuth begins with a host-held session and completes with a resource draft", async () => {
|
||||
const accessToken = jwt({
|
||||
"https://api.openai.com/auth": { chatgpt_account_id: "acct-oauth" },
|
||||
email: "oauth@example.com",
|
||||
});
|
||||
let requestNumber = 0;
|
||||
const flowContext = context({
|
||||
fetch: (url, init) => {
|
||||
requestNumber += 1;
|
||||
if (requestNumber === 1) {
|
||||
assertEquals(url, "https://auth.openai.com/api/accounts/deviceauth/usercode");
|
||||
return {
|
||||
status: 200,
|
||||
headers: {},
|
||||
body: JSON.stringify({
|
||||
device_auth_id: "private-device-id",
|
||||
user_code: "ABCD-EFGH",
|
||||
expires_in: 900,
|
||||
interval: 5,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (requestNumber === 2) {
|
||||
assertEquals(url, "https://auth.openai.com/api/accounts/deviceauth/token");
|
||||
return {
|
||||
status: 200,
|
||||
headers: {},
|
||||
body: JSON.stringify({
|
||||
authorization_code: "authorization-code",
|
||||
code_verifier: "pkce-verifier",
|
||||
}),
|
||||
};
|
||||
}
|
||||
assertEquals(url, "https://auth.openai.com/oauth/token");
|
||||
assert(init?.body?.includes("grant_type=authorization_code"));
|
||||
assert(init?.body?.includes("code_verifier=pkce-verifier"));
|
||||
return {
|
||||
status: 200,
|
||||
headers: {},
|
||||
body: JSON.stringify({ access_token: accessToken, refresh_token: "refresh-secret" }),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const begun = await codexDeviceOAuth.begin(flowContext);
|
||||
assertEquals(begun.userCode, "ABCD-EFGH");
|
||||
assertEquals(begun.pollIntervalMs, 5000);
|
||||
|
||||
const polled = await codexDeviceOAuth.poll(begun.session, flowContext);
|
||||
assert(polled.status === "completed", `expected completed, received ${polled.status}`);
|
||||
assertEquals(polled.resources[0].key, "codex:acct-oauth");
|
||||
assertEquals(requestNumber, 3);
|
||||
});
|
||||
|
||||
Deno.test("invoke streams normalized events from the Codex Responses API", async () => {
|
||||
const token = jwt({ "https://api.openai.com/auth": { chatgpt_account_id: "acct-1" } });
|
||||
const draft = await credentialDraft({
|
||||
accessToken: token,
|
||||
refreshToken: null,
|
||||
displayName: null,
|
||||
});
|
||||
let requestBody = "";
|
||||
let requestHeaders: Record<string, string> = {};
|
||||
const events: ModelEvent[] = [];
|
||||
const result = await codexProvider.invoke(
|
||||
{
|
||||
model: {
|
||||
id: "gpt-test",
|
||||
displayName: "GPT Test",
|
||||
privateData: { reasoningEfforts: ["medium"] },
|
||||
},
|
||||
resource: snapshot(draft.privateData),
|
||||
request: request(),
|
||||
},
|
||||
{ emit: (event) => events.push(event) },
|
||||
context({
|
||||
stream: (url, init) => {
|
||||
assertEquals(url, "https://chatgpt.com/backend-api/codex/responses");
|
||||
requestBody = init?.body ?? "";
|
||||
requestHeaders = init?.headers ?? {};
|
||||
return {
|
||||
status: 200,
|
||||
headers: {},
|
||||
lines: sse([
|
||||
'data: {"type":"response.output_text.delta","delta":"Hel"}',
|
||||
'data: {"type":"response.output_text.delta","delta":"lo"}',
|
||||
'data: {"type":"response.completed","response":{"usage":{"input_tokens":10,"output_tokens":2,"input_tokens_details":{"cached_tokens":4}}}}',
|
||||
]),
|
||||
};
|
||||
},
|
||||
}),
|
||||
);
|
||||
assertEquals(result, { status: "completed" });
|
||||
const body = JSON.parse(requestBody) as Record<string, unknown>;
|
||||
assertEquals(body.model, "gpt-test");
|
||||
assertEquals(body.store, false);
|
||||
assertEquals(body.reasoning, { summary: "auto", effort: "medium" });
|
||||
assertEquals(body.instructions, "You are a coding assistant.");
|
||||
assertEquals(body.include, ["reasoning.encrypted_content"]);
|
||||
assert(!("max_output_tokens" in body), "Codex endpoint rejects max_output_tokens");
|
||||
assertEquals(body.service_tier, "priority");
|
||||
assertEquals(body.prompt_cache_key, "conversation-1");
|
||||
// 缓存亲和头与 prompt_cache_key 同源。
|
||||
assertEquals(requestHeaders["session-id"], "conversation-1");
|
||||
assertEquals(requestHeaders["thread-id"], "conversation-1");
|
||||
assertEquals(requestHeaders["x-client-request-id"], "conversation-1");
|
||||
assertEquals(events, [
|
||||
{ type: "text-start" },
|
||||
{ type: "text-delta", text: "Hel" },
|
||||
{ type: "text-delta", text: "lo" },
|
||||
{
|
||||
type: "usage",
|
||||
usage: {
|
||||
inputTokens: 10,
|
||||
outputTokens: 2,
|
||||
totalTokens: null,
|
||||
cacheReadTokens: 4,
|
||||
cacheWriteTokens: null,
|
||||
reasoningTokens: null,
|
||||
},
|
||||
},
|
||||
{ type: "text-end" },
|
||||
{ type: "done", reason: "stop" },
|
||||
]);
|
||||
});
|
||||
|
||||
Deno.test("invoke streams incremental tool calls and replays reasoning items", async () => {
|
||||
const token = jwt({ "https://api.openai.com/auth": { chatgpt_account_id: "acct-1" } });
|
||||
const draft = await credentialDraft({
|
||||
accessToken: token,
|
||||
refreshToken: null,
|
||||
displayName: null,
|
||||
});
|
||||
const events: ModelEvent[] = [];
|
||||
const result = await codexProvider.invoke(
|
||||
{
|
||||
model: { id: "gpt-test", displayName: "GPT Test" },
|
||||
resource: snapshot(draft.privateData),
|
||||
request: request(),
|
||||
},
|
||||
{ emit: (event) => events.push(event) },
|
||||
context({
|
||||
stream: () => ({
|
||||
status: 200,
|
||||
headers: {},
|
||||
lines: sse([
|
||||
'data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call-1","name":"read_file"}}',
|
||||
'data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"{\\"path\\":"}',
|
||||
'data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"\\"a.ts\\"}"}',
|
||||
'data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call-1","name":"read_file","arguments":"{\\"path\\":\\"a.ts\\"}"}}',
|
||||
'data: {"type":"response.output_item.done","output_index":1,"item":{"type":"reasoning","encrypted_content":"opaque"}}',
|
||||
'data: {"type":"response.completed","response":{}}',
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
assertEquals(result, { status: "completed" });
|
||||
assertEquals(events, [
|
||||
{ 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: "tool-call-end", index: 0 },
|
||||
{
|
||||
type: "replay-state",
|
||||
providerKind: "openai_responses",
|
||||
value: { items: [{ type: "reasoning", encrypted_content: "opaque" }] },
|
||||
},
|
||||
{ type: "done", reason: "tool-use" },
|
||||
]);
|
||||
});
|
||||
|
||||
Deno.test("invoke maps quota failures to a cooling resource error", async () => {
|
||||
assert(!isQuotaError("429 rate_limit_reached"));
|
||||
assert(isQuotaError("429 usage_limit_reached: 5-hour limit"));
|
||||
const token = jwt({ "https://api.openai.com/auth": { chatgpt_account_id: "acct-1" } });
|
||||
const draft = await credentialDraft({
|
||||
accessToken: token,
|
||||
refreshToken: null,
|
||||
displayName: null,
|
||||
});
|
||||
const result = await codexProvider.invoke(
|
||||
{
|
||||
model: { id: "gpt-test", displayName: "GPT Test" },
|
||||
resource: snapshot(draft.privateData),
|
||||
request: request(),
|
||||
},
|
||||
{ emit: () => {} },
|
||||
context({
|
||||
stream: () => ({
|
||||
status: 429,
|
||||
headers: {},
|
||||
lines: sse(['{"detail":"usage_limit_reached","reset_after_seconds":600}']),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
assert(result.status === "resource-error", `expected resource-error, received ${result.status}`);
|
||||
assert(result.patch.state?.status === "cooling", "quota failure should cool the resource");
|
||||
assert(
|
||||
result.patch.state.retryAtMs !== undefined && result.patch.state.retryAtMs > Date.now(),
|
||||
"cooling should carry the parsed reset time",
|
||||
);
|
||||
});
|
||||
@@ -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-responses": "../../../src/plugin/sdk/protocol/openai_responses.ts"
|
||||
},
|
||||
"fmt": {
|
||||
"lineWidth": 100,
|
||||
"exclude": ["assets"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineProviderPlugin } from "cursor-byok:plugin";
|
||||
import { codexDeviceOAuth } from "./oauth.ts";
|
||||
import { codexProvider } from "./provider.ts";
|
||||
import { credentialImport, presentAccount, refreshAccount, RESOURCE_TYPE } from "./resources.ts";
|
||||
|
||||
export default defineProviderPlugin({
|
||||
providers: [codexProvider],
|
||||
resources: [{
|
||||
type: RESOURCE_TYPE,
|
||||
displayName: { "en-US": "ChatGPT accounts", "zh-CN": "ChatGPT 账号" },
|
||||
add: [codexDeviceOAuth],
|
||||
import: credentialImport,
|
||||
present: presentAccount,
|
||||
refresh: refreshAccount,
|
||||
}],
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { JsonValue } from "cursor-byok:plugin";
|
||||
import type { ModelDefinition, ModelSnapshot, ModelSupport } from "cursor-byok:model";
|
||||
import { accountData, accountHeaders } from "./resources.ts";
|
||||
|
||||
const MODELS_URL = "https://chatgpt.com/backend-api/codex/models?client_version=1.0.0";
|
||||
|
||||
function object(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
function text(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown): number | null {
|
||||
const parsed = typeof value === "number"
|
||||
? value
|
||||
: typeof value === "string"
|
||||
? Number(value)
|
||||
: NaN;
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : null;
|
||||
}
|
||||
|
||||
function parseReasoningEfforts(model: Record<string, unknown>): string[] {
|
||||
const source = model.supported_reasoning_efforts ??
|
||||
model.supportedReasoningEfforts ??
|
||||
model.reasoning_efforts ??
|
||||
model.reasoningEfforts;
|
||||
if (!Array.isArray(source)) return [];
|
||||
const values = source.flatMap((item) => {
|
||||
if (typeof item === "string") return [item.trim()];
|
||||
const entry = object(item);
|
||||
const value = text(entry?.effort ?? entry?.id ?? entry?.value ?? entry?.name);
|
||||
return value ? [value] : [];
|
||||
}).filter(Boolean);
|
||||
return [...new Set(values)];
|
||||
}
|
||||
|
||||
function modelId(value: unknown): string | null {
|
||||
if (typeof value === "string") return text(value);
|
||||
const model = object(value);
|
||||
return model ? text(model.slug ?? model.id ?? model.model ?? model.name) : null;
|
||||
}
|
||||
|
||||
export function parseOfficialModels(body: unknown): ModelDefinition[] {
|
||||
const root = object(body);
|
||||
const source = root?.models ?? root?.data ?? body;
|
||||
if (!Array.isArray(source)) {
|
||||
throw new Error("Codex model discovery response does not contain a model list");
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const models: ModelDefinition[] = [];
|
||||
for (const raw of source) {
|
||||
const model = object(raw);
|
||||
if (!model || model.supported_in_api === false || model.supportedInApi === false) continue;
|
||||
if (text(model.visibility)?.toLowerCase() === "hidden") continue;
|
||||
const id = modelId(model);
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
const efforts = parseReasoningEfforts(model);
|
||||
const description = text(model.description);
|
||||
const contextWindowTokens = positiveInteger(
|
||||
model.context_window_tokens ?? model.contextWindowTokens ?? model.context_window ??
|
||||
model.contextWindow,
|
||||
);
|
||||
const maxOutputTokens = positiveInteger(
|
||||
model.max_output_tokens ?? model.maxOutputTokens ?? model.max_completion_tokens ??
|
||||
model.maxCompletionTokens,
|
||||
);
|
||||
models.push({
|
||||
id,
|
||||
displayName: text(model.display_name ?? model.displayName ?? model.title ?? model.name) ??
|
||||
id,
|
||||
...(description ? { description } : {}),
|
||||
...(contextWindowTokens !== null ? { contextWindowTokens } : {}),
|
||||
...(maxOutputTokens !== null ? { maxOutputTokens } : {}),
|
||||
capabilities: { thinking: efforts.length > 0, images: true },
|
||||
privateData: { reasoningEfforts: efforts },
|
||||
});
|
||||
}
|
||||
const defaultModel = modelId(
|
||||
root?.default_model ??
|
||||
root?.defaultModel ??
|
||||
root?.default_model_slug ??
|
||||
root?.defaultModelSlug ??
|
||||
root?.primary_model ??
|
||||
root?.primaryModel,
|
||||
);
|
||||
// 把上游默认模型排在最前,让宿主自然选中它。
|
||||
if (defaultModel) {
|
||||
models.sort((left, right) =>
|
||||
Number(right.id === defaultModel) - Number(left.id === defaultModel)
|
||||
);
|
||||
}
|
||||
return models;
|
||||
}
|
||||
|
||||
export function reasoningEfforts(model: ModelSnapshot): string[] {
|
||||
const data = object(model.privateData);
|
||||
const efforts = data?.reasoningEfforts;
|
||||
return Array.isArray(efforts) ? efforts.filter((item) => typeof item === "string") : [];
|
||||
}
|
||||
|
||||
export const codexModels: ModelSupport = {
|
||||
list: async ({ resource }, context): Promise<ModelDefinition[]> => {
|
||||
if (!resource) throw new Error("add a ChatGPT account before syncing Codex models");
|
||||
const data = accountData(resource);
|
||||
const response = await context.network.fetch(MODELS_URL, {
|
||||
method: "GET",
|
||||
headers: accountHeaders(data),
|
||||
});
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
throw new Error(`Codex model discovery failed (HTTP ${response.status}): ${response.body}`);
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
body = JSON.parse(response.body) as JsonValue;
|
||||
} catch {
|
||||
throw new Error("Codex model discovery returned invalid JSON");
|
||||
}
|
||||
const models = parseOfficialModels(body);
|
||||
if (models.length === 0) {
|
||||
throw new Error("Codex model discovery returned no supported models");
|
||||
}
|
||||
return models;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,210 @@
|
||||
import type { JsonValue, PluginContext } from "cursor-byok:plugin";
|
||||
import type { OAuth2AddMethod, OAuth2Begin, OAuth2Poll } from "cursor-byok:resource";
|
||||
import { type CredentialCandidate, credentialDraft } from "./resources.ts";
|
||||
|
||||
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
||||
const DEVICE_CODE_URL = "https://auth.openai.com/api/accounts/deviceauth/usercode";
|
||||
const DEVICE_TOKEN_URL = "https://auth.openai.com/api/accounts/deviceauth/token";
|
||||
const OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token";
|
||||
const REDIRECT_URI = "https://auth.openai.com/deviceauth/callback";
|
||||
const VERIFICATION_URI = "https://auth.openai.com/codex/device";
|
||||
|
||||
type Session = {
|
||||
deviceAuthId: string;
|
||||
userCode: string;
|
||||
};
|
||||
|
||||
function object(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
function text(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function number(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseBody(body: string): Record<string, unknown> {
|
||||
try {
|
||||
return object(JSON.parse(body)) ?? {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function parseSession(value: JsonValue): Session {
|
||||
const session = object(value);
|
||||
const deviceAuthId = text(session?.deviceAuthId);
|
||||
const userCode = text(session?.userCode);
|
||||
if (!deviceAuthId || !userCode) throw new Error("Codex OAuth session is invalid");
|
||||
return { deviceAuthId, userCode };
|
||||
}
|
||||
|
||||
function errorCode(body: Record<string, unknown>): string {
|
||||
const error = body.error;
|
||||
if (typeof error === "string") return error;
|
||||
const nested = object(error);
|
||||
return text(nested?.code ?? nested?.type ?? body.status ?? body.state) ?? "";
|
||||
}
|
||||
|
||||
function errorMessage(body: Record<string, unknown>): string | null {
|
||||
const error = object(body.error);
|
||||
return text(body.error_description ?? body.message ?? error?.message);
|
||||
}
|
||||
|
||||
function pendingMessage(message: string): boolean {
|
||||
const lower = message.toLowerCase();
|
||||
return lower.includes("authorization is pending") ||
|
||||
lower.includes("authorization_pending") ||
|
||||
lower.includes("device authorization is pending");
|
||||
}
|
||||
|
||||
async function begin(context: PluginContext): Promise<OAuth2Begin> {
|
||||
const response = await context.network.fetch(DEVICE_CODE_URL, {
|
||||
method: "POST",
|
||||
headers: { accept: "application/json", "content-type": "application/json" },
|
||||
body: JSON.stringify({ client_id: CLIENT_ID }),
|
||||
});
|
||||
const body = parseBody(response.body);
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
throw new Error(
|
||||
`Failed to request OpenAI Codex device code (HTTP ${response.status}): ${response.body}`,
|
||||
);
|
||||
}
|
||||
const deviceAuthId = text(body.device_auth_id ?? body.device_code);
|
||||
const userCode = text(body.user_code ?? body.usercode);
|
||||
if (!deviceAuthId || !userCode) {
|
||||
throw new Error("OpenAI Codex device authorization response is incomplete");
|
||||
}
|
||||
const session: Session = { deviceAuthId, userCode };
|
||||
return {
|
||||
session: session as unknown as JsonValue,
|
||||
userCode,
|
||||
verificationUrl: VERIFICATION_URI,
|
||||
verificationUrlComplete: VERIFICATION_URI,
|
||||
expiresAtMs: Date.now() + Math.max(1, number(body.expires_in) ?? 900) * 1000,
|
||||
pollIntervalMs: Math.max(1, number(body.interval) ?? 5) * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
async function exchangeAuthorizationCode(
|
||||
context: PluginContext,
|
||||
authorizationCode: string,
|
||||
codeVerifier: string,
|
||||
): Promise<CredentialCandidate> {
|
||||
const response = await context.network.fetch(OAUTH_TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
"content-type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code: authorizationCode,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
client_id: CLIENT_ID,
|
||||
code_verifier: codeVerifier,
|
||||
}).toString(),
|
||||
});
|
||||
const body = parseBody(response.body);
|
||||
const accessToken = text(body.access_token);
|
||||
if (!accessToken) {
|
||||
throw new Error(
|
||||
errorMessage(body) ?? `Failed to exchange Codex authorization code (HTTP ${response.status})`,
|
||||
);
|
||||
}
|
||||
return { accessToken, refreshToken: text(body.refresh_token), displayName: null };
|
||||
}
|
||||
|
||||
async function completed(credential: CredentialCandidate): Promise<OAuth2Poll> {
|
||||
return { status: "completed", resources: [await credentialDraft(credential)] };
|
||||
}
|
||||
|
||||
async function poll(sessionValue: JsonValue, context: PluginContext): Promise<OAuth2Poll> {
|
||||
const session = parseSession(sessionValue);
|
||||
const response = await context.network.fetch(DEVICE_TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { accept: "application/json", "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
device_auth_id: session.deviceAuthId,
|
||||
user_code: session.userCode,
|
||||
}),
|
||||
});
|
||||
const body = parseBody(response.body);
|
||||
// 该端点用 403/404 表示"尚未完成授权"。
|
||||
if (response.status === 403 || response.status === 404) return { status: "pending" };
|
||||
|
||||
const code = errorCode(body);
|
||||
const message = errorMessage(body);
|
||||
if (
|
||||
["authorization_pending", "pending", "waiting", "in_progress", "device_authorization_pending"]
|
||||
.includes(code) ||
|
||||
(message !== null && pendingMessage(message))
|
||||
) {
|
||||
return { status: "pending" };
|
||||
}
|
||||
if (code === "slow_down") return { status: "slow-down" };
|
||||
if (code === "expired_token" || code === "expired") {
|
||||
return { status: "failed", message: message ?? "Device authorization code expired" };
|
||||
}
|
||||
if (code === "access_denied" || code === "denied") {
|
||||
return { status: "denied", message: message ?? undefined };
|
||||
}
|
||||
|
||||
const directToken = text(body.access_token);
|
||||
if (directToken) {
|
||||
return await completed({
|
||||
accessToken: directToken,
|
||||
refreshToken: text(body.refresh_token),
|
||||
displayName: null,
|
||||
});
|
||||
}
|
||||
|
||||
const authorizationCode = text(body.authorization_code);
|
||||
const codeVerifier = text(body.code_verifier);
|
||||
if (response.status >= 200 && response.status < 300 && authorizationCode && codeVerifier) {
|
||||
try {
|
||||
return await completed(
|
||||
await exchangeAuthorizationCode(context, authorizationCode, codeVerifier),
|
||||
);
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "failed",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!code && body.error === undefined && response.status >= 400) return { status: "pending" };
|
||||
return {
|
||||
status: "failed",
|
||||
message: message ??
|
||||
(code
|
||||
? `OAuth error: ${code}`
|
||||
: `Codex device authorization failed (HTTP ${response.status})`),
|
||||
};
|
||||
}
|
||||
|
||||
export const codexDeviceOAuth: OAuth2AddMethod = {
|
||||
type: "oauth2.0",
|
||||
id: "chatgpt-device",
|
||||
displayName: {
|
||||
"en-US": "Sign in with ChatGPT",
|
||||
"zh-CN": "使用 ChatGPT 登录",
|
||||
},
|
||||
description: {
|
||||
"en-US": "Authorize this device with OpenAI, then add the resulting ChatGPT account.",
|
||||
"zh-CN": "在 OpenAI 完成设备授权后,自动添加对应的 ChatGPT 账号。",
|
||||
},
|
||||
begin,
|
||||
poll,
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"apiVersion": 1,
|
||||
"id": "dev.cursorbyok.examples.codex-auth",
|
||||
"name": "Codex",
|
||||
"author": "@leookun",
|
||||
"icon": "assets/codex.svg",
|
||||
"entry": "main.ts",
|
||||
"permissions": {
|
||||
"network": [
|
||||
"auth.openai.com",
|
||||
"chatgpt.com"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import type {
|
||||
ProviderInvokeInput,
|
||||
ProviderOutput,
|
||||
ProviderResult,
|
||||
ProviderSupport,
|
||||
} from "cursor-byok:provider";
|
||||
import type { PluginContext } from "cursor-byok:plugin";
|
||||
import { HttpError, streamOpenAiResponses } from "cursor-byok:protocol/openai-responses";
|
||||
import { codexModels, reasoningEfforts } from "./models.ts";
|
||||
import {
|
||||
type AccountData,
|
||||
accountData,
|
||||
chatGptAccountId,
|
||||
quotaExhaustedPatch,
|
||||
RESOURCE_TYPE,
|
||||
} from "./resources.ts";
|
||||
|
||||
const RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses";
|
||||
|
||||
/** 流内错误只有文本可用,按额度关键词分类。 */
|
||||
export function isQuotaError(error: string): boolean {
|
||||
const message = error.toLowerCase();
|
||||
return message.includes("insufficient_quota") ||
|
||||
message.includes("usage_limit_reached") ||
|
||||
message.includes("exceeded your current quota") ||
|
||||
message.includes("quota_exceeded") ||
|
||||
message.includes("5-hour") ||
|
||||
message.includes("5 hour") ||
|
||||
(message.includes("429") &&
|
||||
(message.includes("quota") || message.includes("usage_limit") ||
|
||||
message.includes("insufficient")));
|
||||
}
|
||||
|
||||
/** HTTP 失败携带结构化状态码,429 时放宽响应体的匹配条件。 */
|
||||
function isQuotaHttpError(error: HttpError): boolean {
|
||||
const body = error.body.toLowerCase();
|
||||
return body.includes("insufficient_quota") ||
|
||||
body.includes("usage_limit_reached") ||
|
||||
body.includes("exceeded your current quota") ||
|
||||
body.includes("quota_exceeded") ||
|
||||
body.includes("5-hour") ||
|
||||
body.includes("5 hour") ||
|
||||
(error.status === 429 &&
|
||||
(body.includes("quota") || body.includes("usage_limit") || body.includes("insufficient")));
|
||||
}
|
||||
|
||||
function invalidResult(message: string, stateMessage: string): ProviderResult {
|
||||
return {
|
||||
status: "resource-error",
|
||||
message,
|
||||
patch: { state: { status: "invalid", message: stateMessage } },
|
||||
};
|
||||
}
|
||||
|
||||
function headers(data: AccountData, cacheKey: string | null): Record<string, string> {
|
||||
const result: Record<string, string> = {
|
||||
authorization: `Bearer ${data.accessToken}`,
|
||||
originator: "codex_cli_rs",
|
||||
};
|
||||
const accountId = chatGptAccountId(data.accessToken);
|
||||
if (accountId) result["ChatGPT-Account-Id"] = accountId;
|
||||
// Codex 后端的缓存亲和契约:session-id / thread-id / prompt_cache_key
|
||||
// 三者同源(见 codex-rs client.rs);缺头会导致请求落在随机分片上。
|
||||
if (cacheKey !== null) {
|
||||
result["session-id"] = cacheKey;
|
||||
result["thread-id"] = cacheKey;
|
||||
result["x-client-request-id"] = cacheKey;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function invoke(
|
||||
input: ProviderInvokeInput,
|
||||
output: ProviderOutput,
|
||||
context: PluginContext,
|
||||
): Promise<ProviderResult> {
|
||||
if (!input.resource) {
|
||||
return { status: "request-error", message: "add a ChatGPT account before calling Codex" };
|
||||
}
|
||||
let data: AccountData;
|
||||
try {
|
||||
data = accountData(input.resource);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return invalidResult(message, message);
|
||||
}
|
||||
const efforts = reasoningEfforts(input.model);
|
||||
const reasoning = input.request.reasoning;
|
||||
const effort = reasoning.effort !== null && efforts.includes(reasoning.effort)
|
||||
? reasoning.effort
|
||||
: null;
|
||||
try {
|
||||
await streamOpenAiResponses(
|
||||
{
|
||||
url: RESPONSES_URL,
|
||||
model: input.model.id,
|
||||
// Codex 订阅端点不接受 max_output_tokens;fast 档位经协议库映射为
|
||||
// service_tier: "priority" 后透传。
|
||||
request: {
|
||||
...input.request,
|
||||
reasoning: { enabled: reasoning.enabled, effort },
|
||||
maxOutputTokens: null,
|
||||
},
|
||||
headers: headers(data, input.request.cacheKey),
|
||||
extraBody: { store: false },
|
||||
},
|
||||
output,
|
||||
context,
|
||||
);
|
||||
return { status: "completed" };
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError) {
|
||||
if (error.status === 401) {
|
||||
return invalidResult(error.message, "ChatGPT authorization expired; sign in again");
|
||||
}
|
||||
if (isQuotaHttpError(error)) {
|
||||
return {
|
||||
status: "resource-error",
|
||||
message: error.message,
|
||||
patch: quotaExhaustedPatch(data, error.body),
|
||||
};
|
||||
}
|
||||
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, message) };
|
||||
}
|
||||
return { status: "request-error", message };
|
||||
}
|
||||
}
|
||||
|
||||
export const codexProvider: ProviderSupport = {
|
||||
id: "codex",
|
||||
displayName: "OpenAI Codex",
|
||||
description: {
|
||||
"en-US": "ChatGPT subscription access through the official Codex Responses API.",
|
||||
"zh-CN": "通过官方 Codex Responses API 使用 ChatGPT 订阅。",
|
||||
},
|
||||
providerType: "openai",
|
||||
resourceType: RESOURCE_TYPE,
|
||||
models: codexModels,
|
||||
invoke,
|
||||
};
|
||||
@@ -0,0 +1,432 @@
|
||||
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 = "chatgpt-account";
|
||||
|
||||
const USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
|
||||
const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
|
||||
|
||||
export type QuotaWindow = {
|
||||
usedPercent: number | null;
|
||||
remainingPercent: number | null;
|
||||
resetAtMs: number | null;
|
||||
};
|
||||
|
||||
export type AccountQuota = {
|
||||
planLabel: string | null;
|
||||
weekly: QuotaWindow | null;
|
||||
fiveHour: QuotaWindow | null;
|
||||
limitReached: boolean;
|
||||
updatedAtMs: number;
|
||||
};
|
||||
|
||||
/** 单条 chatgpt-account 资源的 privateData 形状。 */
|
||||
export type AccountData = {
|
||||
accessToken: string;
|
||||
refreshToken: string | null;
|
||||
displayName: string;
|
||||
quota: AccountQuota | null;
|
||||
};
|
||||
|
||||
export type CredentialCandidate = {
|
||||
accessToken: string;
|
||||
refreshToken: string | null;
|
||||
displayName: string | null;
|
||||
};
|
||||
|
||||
function object(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
function text(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function number(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
||||
const encoded = token.split(".")[1];
|
||||
if (!encoded) return null;
|
||||
try {
|
||||
const normalized = encoded.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
|
||||
const bytes = Uint8Array.from(atob(padded), (character) => character.charCodeAt(0));
|
||||
return object(JSON.parse(new TextDecoder().decode(bytes)));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function claim(payload: Record<string, unknown> | null, key: string): string | null {
|
||||
return payload ? text(payload[key]) : null;
|
||||
}
|
||||
|
||||
export function chatGptAccountId(accessToken: string): string | null {
|
||||
const payload = decodeJwtPayload(accessToken);
|
||||
const auth = object(payload?.["https://api.openai.com/auth"]);
|
||||
return text(auth?.chatgpt_account_id) ?? claim(payload, "chatgpt_account_id");
|
||||
}
|
||||
|
||||
async function tokenFingerprint(token: string): Promise<string> {
|
||||
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
|
||||
return Array.from(
|
||||
new Uint8Array(digest).slice(0, 8),
|
||||
(byte) => byte.toString(16).padStart(2, "0"),
|
||||
).join("");
|
||||
}
|
||||
|
||||
/** ChatGPT access token 的邮箱通常在 OpenAI 的 profile 声明里,而不是顶层 email。 */
|
||||
function profileEmail(payload: Record<string, unknown> | null): string | null {
|
||||
const profile = object(payload?.["https://api.openai.com/profile"]);
|
||||
return text(profile?.email);
|
||||
}
|
||||
|
||||
export async function accountIdentity(
|
||||
accessToken: string,
|
||||
): Promise<{ key: string; displayName: string }> {
|
||||
const payload = decodeJwtPayload(accessToken);
|
||||
const identity = chatGptAccountId(accessToken) ??
|
||||
claim(payload, "sub") ??
|
||||
claim(payload, "email") ??
|
||||
await tokenFingerprint(accessToken);
|
||||
const displayName = claim(payload, "email") ??
|
||||
profileEmail(payload) ??
|
||||
claim(payload, "preferred_username") ??
|
||||
claim(payload, "name") ??
|
||||
identity;
|
||||
return { key: `codex:${identity}`, displayName };
|
||||
}
|
||||
|
||||
export async function credentialDraft(credential: CredentialCandidate): Promise<ResourceDraft> {
|
||||
const identity = await accountIdentity(credential.accessToken);
|
||||
const data: AccountData = {
|
||||
accessToken: credential.accessToken,
|
||||
refreshToken: credential.refreshToken,
|
||||
displayName: credential.displayName ?? identity.displayName,
|
||||
quota: null,
|
||||
};
|
||||
return { key: identity.key, privateData: data as unknown as JsonValue };
|
||||
}
|
||||
|
||||
export function accountData(resource: ResourceSnapshot): AccountData {
|
||||
const data = object(resource.privateData);
|
||||
const accessToken = text(data?.accessToken);
|
||||
if (!accessToken) throw new Error("ChatGPT account resource is missing its access token");
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken: text(data?.refreshToken),
|
||||
displayName: text(data?.displayName) ?? "ChatGPT account",
|
||||
quota: (data?.quota ?? null) as AccountQuota | null,
|
||||
};
|
||||
}
|
||||
|
||||
export function accountHeaders(data: AccountData): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
accept: "application/json",
|
||||
originator: "codex_cli_rs",
|
||||
authorization: `Bearer ${data.accessToken}`,
|
||||
};
|
||||
const accountId = chatGptAccountId(data.accessToken);
|
||||
if (accountId) headers["ChatGPT-Account-Id"] = accountId;
|
||||
return headers;
|
||||
}
|
||||
|
||||
function clampPercent(value: number): number {
|
||||
return Math.max(0, Math.min(100, value));
|
||||
}
|
||||
|
||||
function resetAtMs(window: Record<string, unknown>, nowMs: number): number | null {
|
||||
const resetAt = window.reset_at ?? window.resetAt;
|
||||
const numeric = number(resetAt);
|
||||
if (numeric !== null) return numeric > 10_000_000_000 ? numeric : numeric * 1000;
|
||||
if (typeof resetAt === "string") {
|
||||
const parsed = Date.parse(resetAt);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
const afterSeconds = number(window.reset_after_seconds ?? window.resetAfterSeconds);
|
||||
return afterSeconds === null ? null : nowMs + afterSeconds * 1000;
|
||||
}
|
||||
|
||||
function quotaWindow(value: unknown, nowMs: number): QuotaWindow | null {
|
||||
const window = object(value);
|
||||
if (!window) return null;
|
||||
const used = number(window.used_percent ?? window.usedPercent);
|
||||
const remaining = used === null
|
||||
? number(window.remaining_percent ?? window.remainingPercent)
|
||||
: clampPercent(100 - used);
|
||||
return {
|
||||
usedPercent: used === null
|
||||
? (remaining === null ? null : clampPercent(100 - remaining))
|
||||
: clampPercent(used),
|
||||
remainingPercent: remaining === null ? null : clampPercent(remaining),
|
||||
resetAtMs: resetAtMs(window, nowMs),
|
||||
};
|
||||
}
|
||||
|
||||
function planLabel(value: unknown): string | null {
|
||||
const plan = text(value);
|
||||
if (!plan) return null;
|
||||
const labels: Record<string, string> = {
|
||||
plus: "ChatGPT Plus",
|
||||
pro: "ChatGPT Pro",
|
||||
team: "ChatGPT Team",
|
||||
business: "ChatGPT Business",
|
||||
enterprise: "ChatGPT Enterprise",
|
||||
free: "ChatGPT Free",
|
||||
go: "ChatGPT Go",
|
||||
};
|
||||
return labels[plan.toLowerCase()] ?? plan;
|
||||
}
|
||||
|
||||
export function parseCodexUsage(body: unknown, nowMs = Date.now()): AccountQuota {
|
||||
const root = object(body) ?? {};
|
||||
const rateLimit = object(root.rate_limit ?? root.rateLimit) ?? root;
|
||||
const primary = rateLimit.primary_window ?? rateLimit.primaryWindow;
|
||||
const secondary = rateLimit.secondary_window ?? rateLimit.secondaryWindow;
|
||||
const weekly = quotaWindow(secondary ?? primary, nowMs);
|
||||
const fiveHour = secondary === undefined || secondary === null
|
||||
? null
|
||||
: quotaWindow(primary, nowMs);
|
||||
const explicitLimit = rateLimit.limit_reached ?? rateLimit.limitReached;
|
||||
return {
|
||||
planLabel: planLabel(root.plan_type ?? root.planType),
|
||||
weekly,
|
||||
fiveHour,
|
||||
limitReached: typeof explicitLimit === "boolean" ? explicitLimit : [weekly, fiveHour].some(
|
||||
(window) => window?.remainingPercent !== null && window?.remainingPercent === 0,
|
||||
),
|
||||
updatedAtMs: nowMs,
|
||||
};
|
||||
}
|
||||
|
||||
function windowCoolingUntil(window: QuotaWindow | null, nowMs: number): number | null {
|
||||
if (!window || window.remainingPercent === null || window.remainingPercent > 0) return null;
|
||||
if (window.resetAtMs !== null && window.resetAtMs <= nowMs) return null;
|
||||
return window.resetAtMs ?? nowMs + FIVE_HOURS_MS;
|
||||
}
|
||||
|
||||
export function quotaCoolingUntil(quota: AccountQuota, nowMs = Date.now()): number | null {
|
||||
const resets = [
|
||||
windowCoolingUntil(quota.weekly, nowMs),
|
||||
windowCoolingUntil(quota.fiveHour, nowMs),
|
||||
].filter((value): value is number => value !== null);
|
||||
if (resets.length > 0) return Math.max(...resets);
|
||||
return quota.limitReached ? nowMs + FIVE_HOURS_MS : null;
|
||||
}
|
||||
|
||||
export function quotaState(quota: AccountQuota | null, nowMs = Date.now()): ResourceState {
|
||||
if (!quota) return { status: "ready" };
|
||||
const coolingUntil = quotaCoolingUntil(quota, nowMs);
|
||||
return coolingUntil === null
|
||||
? { status: "ready" }
|
||||
: { status: "cooling", retryAtMs: coolingUntil, message: "ChatGPT quota is exhausted" };
|
||||
}
|
||||
|
||||
/** 从上游错误文本中提取重置时间;拿不到时回退 5 小时。 */
|
||||
function resetFromError(error: string, nowMs: number): number {
|
||||
const resetAt = error.match(/["']?reset_at["']?\s*[:=]\s*["']?(\d+(?:\.\d+)?)/i)?.[1];
|
||||
if (resetAt) {
|
||||
const value = Number(resetAt);
|
||||
if (Number.isFinite(value)) return value > 10_000_000_000 ? value : value * 1000;
|
||||
}
|
||||
const resetAfter = error.match(/["']?reset_after_seconds["']?\s*[:=]\s*["']?(\d+(?:\.\d+)?)/i)
|
||||
?.[1];
|
||||
if (resetAfter) {
|
||||
const value = Number(resetAfter);
|
||||
if (Number.isFinite(value)) return nowMs + value * 1000;
|
||||
}
|
||||
return nowMs + FIVE_HOURS_MS;
|
||||
}
|
||||
|
||||
/** 额度耗尽时的资源补丁:标记 5 小时窗口耗尽并按重置时间进入冷却。 */
|
||||
export function quotaExhaustedPatch(
|
||||
data: AccountData,
|
||||
error: string,
|
||||
nowMs = Date.now(),
|
||||
): ResourcePatch {
|
||||
const quota: AccountQuota = {
|
||||
planLabel: data.quota?.planLabel ?? null,
|
||||
weekly: data.quota?.weekly ?? null,
|
||||
fiveHour: {
|
||||
usedPercent: 100,
|
||||
remainingPercent: 0,
|
||||
resetAtMs: resetFromError(error, nowMs),
|
||||
},
|
||||
limitReached: true,
|
||||
updatedAtMs: nowMs,
|
||||
};
|
||||
return {
|
||||
privateData: { ...data, quota } as unknown as JsonValue,
|
||||
state: quotaState(quota, nowMs),
|
||||
};
|
||||
}
|
||||
|
||||
export function presentAccount(resource: ResourceSnapshot): ResourceView {
|
||||
const data = accountData(resource);
|
||||
const metrics: ResourceMetric[] = [];
|
||||
const weekly = data.quota?.weekly;
|
||||
if (weekly && weekly.remainingPercent !== null) {
|
||||
metrics.push({
|
||||
id: "weekly",
|
||||
label: { "en-US": "Weekly quota", "zh-CN": "周额度" },
|
||||
unit: "percent",
|
||||
value: weekly.remainingPercent,
|
||||
...(weekly.resetAtMs !== null ? { resetAtMs: weekly.resetAtMs } : {}),
|
||||
});
|
||||
}
|
||||
const fiveHour = data.quota?.fiveHour;
|
||||
if (fiveHour && fiveHour.remainingPercent !== null) {
|
||||
metrics.push({
|
||||
id: "five-hour",
|
||||
label: { "en-US": "5-hour window", "zh-CN": "5 小时窗口" },
|
||||
unit: "percent",
|
||||
value: fiveHour.remainingPercent,
|
||||
...(fiveHour.resetAtMs !== null ? { resetAtMs: fiveHour.resetAtMs } : {}),
|
||||
});
|
||||
}
|
||||
return {
|
||||
// 旧记录可能存的是账号 ID;展示时优先从 token 现算邮箱。
|
||||
displayName: jwtDisplayName(data.accessToken) ?? data.displayName,
|
||||
...(data.quota?.planLabel ? { description: data.quota.planLabel } : {}),
|
||||
...(metrics.length > 0 ? { metrics } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function refreshAccount(
|
||||
resource: ResourceSnapshot,
|
||||
context: PluginContext,
|
||||
): Promise<ResourcePatch> {
|
||||
const data = accountData(resource);
|
||||
const response = await context.network.fetch(USAGE_URL, {
|
||||
method: "GET",
|
||||
headers: accountHeaders(data),
|
||||
});
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
if (response.status === 401) {
|
||||
return {
|
||||
state: { status: "invalid", message: "ChatGPT authorization expired; sign in again" },
|
||||
};
|
||||
}
|
||||
throw new Error(`Codex usage lookup failed (HTTP ${response.status}): ${response.body}`);
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
body = JSON.parse(response.body);
|
||||
} catch {
|
||||
throw new Error("Codex usage lookup returned invalid JSON");
|
||||
}
|
||||
const quota = parseCodexUsage(body);
|
||||
return {
|
||||
privateData: { ...data, quota } as unknown as JsonValue,
|
||||
state: quotaState(quota),
|
||||
};
|
||||
}
|
||||
|
||||
function firstText(source: Record<string, unknown>, keys: string[]): string | null {
|
||||
for (const key of keys) {
|
||||
const value = text(source[key]);
|
||||
if (value) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function jwtDisplayName(token: string | null): string | null {
|
||||
if (!token) return null;
|
||||
const payload = decodeJwtPayload(token);
|
||||
return claim(payload, "email") ?? profileEmail(payload) ??
|
||||
claim(payload, "preferred_username") ?? claim(payload, "name");
|
||||
}
|
||||
|
||||
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", "OPENAI_API_KEY"]);
|
||||
if (!accessToken) return;
|
||||
const refreshToken = firstText(tokens, ["refresh_token", "refreshToken"]) ??
|
||||
firstText(item, ["refresh_token", "refreshToken"]);
|
||||
const idToken = firstText(tokens, ["id_token", "idToken"]) ??
|
||||
firstText(item, ["id_token", "idToken"]);
|
||||
const displayName = firstText(item, ["email", "display_name", "displayName", "name"]) ??
|
||||
firstText(tokens, ["email", "display_name", "displayName", "name"]) ??
|
||||
jwtDisplayName(idToken);
|
||||
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 ChatGPT access token found`);
|
||||
continue;
|
||||
}
|
||||
credentials.push(...found);
|
||||
}
|
||||
return { credentials, warnings };
|
||||
}
|
||||
|
||||
export const credentialImport: ResourceImportSupport = {
|
||||
displayName: {
|
||||
"en-US": "Import Codex credentials",
|
||||
"zh-CN": "导入 Codex 凭证",
|
||||
},
|
||||
description: {
|
||||
"en-US": "Import one or more Codex JSON credential files.",
|
||||
"zh-CN": "导入一个或多个 Codex JSON 凭证文件。",
|
||||
},
|
||||
accept: [".json"],
|
||||
multiple: true,
|
||||
parse: async (files: ResourceImportFile[]): Promise<ResourceImportResult> => {
|
||||
const { credentials, warnings } = parseCredentialFiles(files);
|
||||
if (credentials.length === 0) {
|
||||
throw new Error(warnings.join("; ") || "credential JSON does not contain an access token");
|
||||
}
|
||||
return {
|
||||
resources: await Promise.all(credentials.map(credentialDraft)),
|
||||
...(warnings.length > 0 ? { warnings } : {}),
|
||||
};
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user