LFModel
Global:LFModel · Enable:// @require model
Calls the PluginX AI gateway. Every call is attributed to the current app (and its budget) and carries the PluginX login via the background; you cannot act as another app.
Methods
LFModel.listModels({ force?, page?, pageSize?, q?, ids? }?): Promise<{ models, page, pageSize, total, count, hasMore, ... }>Lists models available to the current account
LFModel.getModel(modelId): Promise<ModelDescriptor>Reads a single model descriptor
LFModel.chat(payload): Promise<ChatCompletionsResponse>Chat completion (non-streaming), OpenAI-style payload
LFModel.chatStream(payload): AsyncIterable<StreamChunk>Streaming chat; iterate { kind: "delta", data } chunks
LFModel.image(payload): Promise<ImageGenerationsResponse>Image generation
LFModel.getBalance(): Promise<BalanceInfo>Account balance
LFModel.getBudget(): Promise<BudgetInfo>This app's budget configuration
LFModel.getSpendable(): Promise<SpendableInfo>One-call spendable snapshot: min(balance, budget remaining) + which side ran out
LFModel.getUsage(options?): Promise<UsageInfo>Usage stats (range: 1d / 7d / 30d / all)
LFModel.getPlansUrl(): Promise<string>Official-site deep link to the subscription plans page (guide users when the period's credits run out)
LFModel.getBudgetUrl(): Promise<string>Official-site deep link that opens this app's budget panel (guide users when the budget runs out)
Parameter details
chat(payload) / chatStream(payload)
The payload is an OpenAI Chat Completions request body; unknown fields are forwarded upstream. app_scope is force-injected by the background as the current app — anything you pass is stripped:
| Field | Type | Required | Description |
|---|---|---|---|
model | string | yes | Model id from listModels() (models[].id) |
messages | ChatMessage[] | yes | Each { role: 'system' / 'user' / 'assistant' / 'tool', content }; multimodal content supports [{ type: 'text', text }, { type: 'image_url', image_url: { url } }] |
tools / tool_choice | OpenAITool[] / 'auto' … | no | Function-calling declarations and choice policy, forwarded as-is |
temperature / top_p / max_tokens | number | no | Sampling and length controls, forwarded as-is; when the upstream model explicitly rejects a sampling parameter (e.g. some newer Claude models only accept the default temperature), the gateway drops it and retries automatically. Thinking-capable models spend part of max_tokens on reasoning — leave headroom or the answer may be cut off with finish_reason: 'length' |
reasoning_effort | 'low' / 'medium' / 'high' / 'xhigh' | no | Unified thinking-effort level (camelCase reasoningEffort is accepted as an alias; max is also accepted, none / minimal are treated as low, and any other value is silently ignored). Use these values for every model and never pass vendor-specific parameters (thinking, enable_thinking, thinking_budget, …; the gateway strips thinking / reasoning fields). The gateway maps the level per upstream protocol: OpenAI-compatible upstreams receive low/medium/high, with xhigh / max clamped to high; GLM maps to low/high/max (medium→high, xhigh→max); newer Anthropic models (Claude 5 family, Opus 4.5+, Sonnet 4.6+) receive it as effort (all five levels, also with tools; temperature / top_p / top_k are dropped), while older ones get a thinking budget that is not enabled when the request has tools or when max_tokens cannot fit the 1024-token minimum budget, and temperature is dropped when thinking is enabled; Gemini converts it to thinkingBudget. Models without thinking capability have the field removed. Omit to use the upstream default |
response_format | { type, json_schema? } | no | type is 'text' / 'json_object' / 'json_schema'; json_schema requires { name, schema, strict? } |
stop / n / seed / frequency_penalty / presence_penalty | — | no | Forwarded as-is (most upstreams cap n at 1) |
stream | boolean | no | Forced false for chat(); use chatStream() for streaming |
chat() returns the standard OpenAI response: choices[0].message.content is the text (it may be null when the model calls tools), each message.tool_calls[] entry is { id, type: 'function', function: { name, arguments } } (arguments is a JSON string), and thinking models add message.reasoning_content. Canonical billing fields are usage.cost_points, usage.cost_unit === 'points', and lingfeng_billing.charge_points (1 CNY = 200 points). Old CNY/cents fields are deprecated compatibility only; currency-agnostic charge_cents may be USD and must never be treated as CNY points.
chatStream() returns an async iterable; each element is either { kind: 'delta', data } (data is a parsed OpenAI stream chunk object, not a string; the incremental text is data.choices[0].delta.content, which may be empty; it may contain usage.cost_points) or { kind: 'billing', billing } (the final billing event; canonical billing.charge_points). Breaking out aborts the upstream request. Errors are thrown from the for await loop as LFModelError (see Error type below).
image(payload)
| Field | Type | Required | Description |
|---|---|---|---|
model | string | yes | Image model id (kind === 'image') |
prompt | string | yes | Text prompt |
size | string | no | E.g. '1024x1024'; must be in model.supported_sizes |
response_format | 'url' / 'b64_json' | no | Must be in model.capabilities.supported_response_formats |
n / quality / style / image / mode | — | no | Count (some providers composite n>1 into one grid — call image() K times for K distinct outputs) / quality / style / image-to-image reference / generation mode |
Returns { created, data: [{ url?, b64_json?, revised_prompt? }], usage?, lingfeng_billing }; image billing uses the same canonical cost_points / charge_points fields.
listModels pagination and filters / other query return shapes
The listModels() catalog is the same source the IDE agent model picker uses: it prefers the curated catalog configured on the server (visibility and ordering are decided server-side, chat models first) and falls back to the full paginated catalog when nothing is curated. Whatever you see in the agent model picker is what your app can discover — never hard-code model ids. Note the curated catalog constrains discovery only: chat() / image() hit the inference endpoints directly, so whether an id outside the curated set works is up to the server gateway.
| Method | Key return fields |
|---|---|
listModels({ force?, page?, pageSize?, q?, ids? }) | page defaults to 1 and pageSize to 40; q searches and ids filters exact ids. Returns models[], page, pageSize, total, count, hasMore; each query/page has its own memory cache, concurrent identical requests are deduplicated, and force: true refreshes that entry. Pricing parameters priceWindowK / priceMarkupRatio are included too. Model fields include id, name / display_name, context_length, kind ('chat' / 'image'), capabilities, pricing_cny_per_1m, and isFree. There is no kind filter — chat and image models share one list, so filter yourself with models.filter((m) => m.kind === 'chat') |
getModel(modelId) | Reads one model by id (sharing the listModels cache); throws MODEL_NOT_FOUND when absent. Without a curated catalog it uses an exact ids query instead of downloading a full page; with a curated catalog it looks the id up in the curated list, so only curated models can be found — an id outside it throws MODEL_NOT_FOUND from getModel() even if chat() accepts it |
getBalance() | balance_points is canonical. Legacy balance_cents / balance_micro fields are deprecated compatibility only; the wallet is user-level, not per-app |
getBudget() | Canonical budget_points (null = unlimited), spent_points, and remaining_points. Legacy budget_cents / spent_cents / spent_micro / remaining_cents are deprecated compatibility only; always queries the current app |
getSpendable() | Canonical spendable_points = min(balance, budget remaining), balance_points, and finite-budget budget_remaining_points, plus exhausted, exhausted_reason, and limiting_factor. spendable_cents / spendable_micro are deprecated compatibility only |
getUsage({ range? }) | range is '1d' / '7d' (default) / '30d' / 'all'; canonical aggregates are total.charge_points and by_model[].charge_points. Legacy charge_cents / charge_micro are deprecated compatibility only |
getPlansUrl / getBudgetUrl payment deep links
Both return official-site deep-link strings built by the extension background for the current environment (never hardcode URLs): getPlansUrl() opens the official-site subscription plans page — use it for INSUFFICIENT_BALANCE (credits reset every 31 days with the plan; pay-as-you-go top-ups are discontinued, so subscribing or upgrading is how users get more); getBudgetUrl() opens the AI panel with this app's budget panel auto-popped — use it for APP_BUDGET_EXHAUSTED (the account still has credits; the user just needs to raise this app's cap). Render them as clickable links or window.open(url, '_blank') buttons in your app UI.
Error type
All methods throw LFModelError on failure (check with instanceof LFModel.LFModelError): status (HTTP status), errorCode (stable enum: INSUFFICIENT_BALANCE, APP_BUDGET_EXHAUSTED, MODEL_REQUIRES_PAID_PLAN, MODEL_NOT_FOUND, NO_AVAILABLE_PROVIDER, UPSTREAM_ERROR, …), payload (raw upstream error body). Branch on errorCode, never parse the message text; pair INSUFFICIENT_BALANCE with getPlansUrl() and APP_BUDGET_EXHAUSTED with getBudgetUrl() for one-click guidance; MODEL_REQUIRES_PAID_PLAN means the model is subscriber-only — point to getPlansUrl() or switch to an unlocked model.
A few exceptions:
chatStream()errors carry onlyerrorCode;statusis always 0 andpayloadis null, so branch onerrorCodealone.- Failures that never reach the gateway (network faults, a broken extension channel) have an empty
errorCodeandstatus0. If the background connection drops without an error message, thechatStream()loop stops as if it had finished normally; when completeness matters, check whether abillingelement arrived. - Without a messaging channel (USER_SCRIPT messaging not enabled and not running in a terminal run), a plain
Erroris thrown.
Example
// @require model
const { models } = await LFModel.listModels();
// The list mixes chat and image models; pick by kind
const chatModel = models.find((m) => m.kind === "chat");
// Check spendable credits (= min(account balance, remaining app budget)) before calling
const spendable = await LFModel.getSpendable();
if (spendable.exhausted) {
const url = spendable.exhausted_reason === "APP_BUDGET_EXHAUSTED"
? await LFModel.getBudgetUrl() // Budget exhausted: guide the user to increase this app’s budget
: await LFModel.getPlansUrl(); // Credits exhausted: guide the user to subscribe or upgrade
window.open(url, "_blank");
} else {
console.log(`Spendable credits: ${spendable.spendable_points} points`);
}
// Non-streaming
const reply = await LFModel.chat({
model: chatModel.id,
messages: [{ role: "user", content: "Hello" }],
});
console.log(reply.choices[0].message.content);
// Streaming: a delta's data is an OpenAI chunk object; the text is in choices[0].delta.content
let text = "";
for await (const chunk of LFModel.chatStream({
model: chatModel.id,
messages: [{ role: "user", content: "Tell me a joke" }],
})) {
if (chunk.kind === "delta") text += chunk.data.choices?.[0]?.delta?.content ?? "";
}
console.log(text);