Skip to content

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 ​

ts
LFModel.listModels({ force?, page?, pageSize?, q?, ids? }?): Promise<{ models, page, pageSize, total, count, hasMore, ... }>

Lists models available to the current account

ts
LFModel.getModel(modelId): Promise<ModelDescriptor>

Reads a single model descriptor

ts
LFModel.chat(payload): Promise<ChatCompletionsResponse>

Chat completion (non-streaming), OpenAI-style payload

ts
LFModel.chatStream(payload): AsyncIterable<StreamChunk>

Streaming chat; iterate { kind: "delta", data } chunks

ts
LFModel.image(payload): Promise<ImageGenerationsResponse>

Image generation

ts
LFModel.getBalance(): Promise<BalanceInfo>

Account balance

ts
LFModel.getBudget(): Promise<BudgetInfo>

This app's budget configuration

ts
LFModel.getSpendable(): Promise<SpendableInfo>

One-call spendable snapshot: min(balance, budget remaining) + which side ran out

ts
LFModel.getUsage(options?): Promise<UsageInfo>

Usage stats (range: 1d / 7d / 30d / all)

ts
LFModel.getPlansUrl(): Promise<string>

Official-site deep link to the subscription plans page (guide users when the period's credits run out)

ts
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:

FieldTypeRequiredDescription
modelstringyesModel id from listModels() (models[].id)
messagesChatMessage[]yesEach { role: 'system' / 'user' / 'assistant' / 'tool', content }; multimodal content supports [{ type: 'text', text }, { type: 'image_url', image_url: { url } }]
tools / tool_choiceOpenAITool[] / 'auto' …noFunction-calling declarations and choice policy, forwarded as-is
temperature / top_p / max_tokensnumbernoSampling 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'noUnified 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? }notype is 'text' / 'json_object' / 'json_schema'; json_schema requires { name, schema, strict? }
stop / n / seed / frequency_penalty / presence_penalty—noForwarded as-is (most upstreams cap n at 1)
streambooleannoForced 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) ​

FieldTypeRequiredDescription
modelstringyesImage model id (kind === 'image')
promptstringyesText prompt
sizestringnoE.g. '1024x1024'; must be in model.supported_sizes
response_format'url' / 'b64_json'noMust be in model.capabilities.supported_response_formats
n / quality / style / image / mode—noCount (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.

MethodKey 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 only errorCode; status is always 0 and payload is null, so branch on errorCode alone.
  • Failures that never reach the gateway (network faults, a broken extension channel) have an empty errorCode and status 0. If the background connection drops without an error message, the chatStream() loop stops as if it had finished normally; when completeness matters, check whether a billing element arrived.
  • Without a messaging channel (USER_SCRIPT messaging not enabled and not running in a terminal run), a plain Error is thrown.

Example

js
// @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);