---
document_id: agent.oauth
schema_version: 2
parent_document_id: agent.guide
section: querying
---

<!-- Generated by scripts.build_agent_guide; do not edit. -->

# Programmatic access (OAuth) / 程序接入（OAuth）

## Choose API Key or OAuth / 选择 API Key 或 OAuth

业务 REST 接口使用相同地址和 Authorization: Bearer 请求头，凭据决定授权范围。API Key 在 Dashboard 创建，受 Key 自身配置及账户权限、额度限制，无需定时刷新；OAuth 使用用户授权的账户权限，access token 到期后刷新，用户可在 Dashboard 撤销连接。

Business REST endpoints use the same URLs and Authorization: Bearer header. API keys are created in Dashboard, follow key settings and account access/limits, and need no periodic refresh. OAuth follows the authorizing account; refresh expired access tokens, and revoke connections in Dashboard.

同一个 GET /v1/usage：OAuth 返回账户余额与消费汇总；API Key 仅返回自身配额、日 Credits 与已记录调用，不返回账户钱包。余额程序应使用 OAuth Access Token，不要使用 Key 的 daily_remaining_credits 代替账户余额。

The same GET /v1/usage returns account balances and consumption for OAuth, but only own quotas, daily Credits and recorded calls for API keys. Use an OAuth access token for account balances; a key daily_remaining_credits value is not the account balance.

首次获取 token 必须由用户在浏览器登录并授权，后续程序可以自动刷新。当前仅支持 authorization_code 和 refresh_token，不支持 client_credentials 或 Device Flow。注册客户端并不等于获得账户授权；本教程不会新增完全无人值守的首次登录能力。

The first token requires browser login and user consent. Programs can refresh tokens afterward. Supported grants are authorization_code and refresh_token, not client_credentials or Device Flow. Client registration alone does not authorize an account; fully unattended first authorization is not supported.

| Comparison / 对比项 | API Key | OAuth |
| --- | --- | --- |
| Identity / 身份 | API Key; restricted key for servers/CI / API Key；受限密钥，适合服务器/CI | User-authorized connection; browser login first / 用户授权 connection；首次需浏览器登录 |
| Header and endpoints / 请求头与接口 | Bearer <API_KEY>; same business paths/parameters / Bearer <API_KEY>；业务路径/参数相同 | Bearer <ACCESS_TOKEN>; same business paths/parameters / Bearer <ACCESS_TOKEN>；业务路径/参数相同 |
| Data scope / 数据范围 | Account + key scope / 账户权限 + Key 范围 | Account subscription + connection scope / 账户订阅 + connection 范围 |
| Request limits / 请求限额 | Account + key limits / 账户 + Key 限额 | Account + connection limits / 账户 + connection 限额 |
| Credits and approval threshold / Credits 与审批阈值 | Daily Credits cap + key threshold / 每日 Credits 上限 + Key 阈值 | Account balance, budget + default threshold / 账户余额、预算 + 默认阈值 |
| GET /v1/usage / GET /v1/usage | Key quota/usage; no account balance / Key 配额/消耗；无账户余额 | Account balance, wallet, reserved amounts and usage / 账户余额、钱包、预留和用量 |
| Billing / 付费扣款 | Account wallet; daily Credits is a budget / 账户钱包扣费；每日 Credits 是预算 | Account wallet; balance is available Credits / 账户钱包扣费；balance 是可用 Credits |
| Credential maintenance / 凭据维护 | No refresh; usable while valid and active / 无需 refresh；有效且未撤销即可用 | Refresh with the refresh token after expiry / 到期后用 refresh token 自动刷新 |
| Task continuity / 任务连续性 | Same key for create, poll and read / 创建、轮询、读取用同一 Key | Same connection for create, poll and read / 创建、轮询、读取用同一 connection |

## Authorization flow and endpoints / 授权流程与端点

以下示例固定使用中国区生产 https://api.asklear.cn。先绑定 127.0.0.1 的随机端口，再 POST /register 注册公共客户端（token_endpoint_auth_method=none），生成随机 state 与 PKCE verifier，将 SHA-256 后的 Base64URL challenge 随 S256 请求送往 /authorize。不要申请 client_secret。

These examples target CN production at https://api.asklear.cn. Bind a random 127.0.0.1 port first, then POST /register for a public client with token_endpoint_auth_method=none. Generate random state and a PKCE verifier; send the Base64URL SHA-256 challenge with S256 to /authorize. No client_secret is used.

浏览器完成登录授权后回到 /callback。程序校验回调路径、state 和单个 code，再以 application/x-www-form-urlencoded 向 POST /token 交换 token。scope=use_asklear；resource 固定为同一区域的 https://api.asklear.cn/mcp，即使随后调用 REST，也不能把 resource 改成 /v1/usage。换 token 使用原样的 redirect_uri。

After browser login and consent, validate the /callback path, state and single code, then POST form-encoded data to /token. Use scope=use_asklear and resource=https://api.asklear.cn/mcp in the same region, including for REST calls; do not change resource to /v1/usage. Exchange the code with the exact redirect_uri used for authorization.

端点及支持项可从 GET /.well-known/oauth-authorization-server 读取。示例固定已公布的 /register、/authorize、/token；不要把中国区凭据发送到 Global 或测试环境，切换环境应重新注册并授权。

Discover endpoints and supported options at GET /.well-known/oauth-authorization-server. Examples use the published /register, /authorize and /token paths. Never send CN credentials to Global or test deployments; register and authorize separately for another environment.

## Read account balances / 读取账户余额

获取 access_token 后，向 GET https://api.asklear.cn/v1/usage 发送 Authorization: Bearer <access_token>，无需 tenant_id 或 api_key_id。响应 balance 是当前可用 Credits；wallet_balance 是钱包总额；reserved_balance 是预留；refund_holds 是退款冻结。total_credits 是所选期间的消费，不是余额。

After obtaining an access_token, call GET https://api.asklear.cn/v1/usage with Authorization: Bearer <access_token>. No tenant_id or api_key_id is needed. balance is available Credits; wallet_balance is the wallet total; reserved_balance is reserved Credits; refund_holds is the refund hold. total_credits is period consumption, not the balance.

四种完整示例查询并输出这四个余额字段，不输出 token。OAuth 支持 from、to、group_by、tool、detail 等筛选；API Key 的 group_by=endpoint 只统计已记录调用，coverage=partial。

Each complete example queries the four balance fields without printing tokens. OAuth supports from, to, group_by, tool and detail filters; API Key group_by=endpoint only counts recorded calls and has coverage=partial.

## Run the examples / 运行准备

在有浏览器的 macOS/Linux 本机私有目录保存完整文件并执行。Python 尝试打开浏览器，其他示例点击终端链接；回调仅监听环回地址，等待 180 秒，请求超时 15 秒。远程服务器先由用户授权，再安全保存 refresh token。

Save a complete example in a private macOS/Linux directory on the browser machine and run it. Python opens the browser; open the terminal link for other languages. Callbacks use loopback for 180 seconds and requests time out after 15 seconds. Authorize first, then store the refresh token securely on a remote server.

示例将 client_id、access_token、refresh_token、expires_at 原子保存到当前目录 asklear-oauth.json（0600），再次运行会复用并按需刷新。文件是账户凭据，不要上传或提交。每个文件只允许一个进程使用；多个实例应使用加锁的凭据存储，串行刷新。

Examples atomically save client_id, access_token, refresh_token and expires_at to asklear-oauth.json in the current directory with mode 0600. Later runs reuse and refresh them. This file is an account credential: do not upload or commit it. Run one process per credential file; multiple instances need locked credential storage and serialized refreshes.

```sh
mkdir -m 700 asklear-oauth-demo
cd asklear-oauth-demo
```

## Python / Python

需要 Python 3.10+ 和 requests。保存为 asklear_oauth.py，执行 python3 asklear_oauth.py。

Requires Python 3.10+ and requests. Save as asklear_oauth.py and run python3 asklear_oauth.py.

```python
import requests

ACCESS_TOKEN = "<access_token>"
response = requests.get(
    "https://api.asklear.cn/v1/usage",
    headers={"Authorization": f"Bearer {ACCESS_TOKEN}"},
    timeout=15,
)
response.raise_for_status()
usage = response.json()
print({key: usage.get(key) for key in (
    "balance", "wallet_balance", "reserved_balance", "refund_holds"
)})
```

```python
import base64
import hashlib
import json
import os
import requests
import secrets
import sys
import time
import urllib.parse
import webbrowser
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path

BASE = "https://api.asklear.cn"
TOKEN_FILE = Path("asklear-oauth.json")


def request(path, *, data=None, token=None, form=False):
    headers = {"Accept": "application/json"}
    if token:
        headers["Authorization"] = "Bearer " + token
    response = requests.request(
        "POST" if data is not None else "GET", BASE + path,
        data=data if form else None, json=None if form else data,
        headers=headers, timeout=15, allow_redirects=False,
    )
    response.raise_for_status()
    return response.json()


def save_token(client_id, data):
    if (data.get("token_type", "").lower() != "bearer"
            or not all(isinstance(data.get(k), str) and data[k] for k in ("access_token", "refresh_token"))
            or type(data.get("expires_in")) is not int or data["expires_in"] <= 0):
        raise ValueError("Invalid token response")
    record = {"client_id": client_id, "access_token": data["access_token"],
              "refresh_token": data["refresh_token"], "expires_at": time.time() + data["expires_in"]}
    # Replace the complete token pair before making another API request.
    temporary = TOKEN_FILE.with_suffix(".tmp")
    fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
    with os.fdopen(fd, "w") as stream:
        os.fchmod(stream.fileno(), 0o600)
        json.dump(record, stream)
    os.replace(temporary, TOKEN_FILE)
    return record


def login():
    state, verifier = secrets.token_urlsafe(32), secrets.token_urlsafe(64)
    challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
    result = {}

    class Callback(BaseHTTPRequestHandler):
        def setup(self):
            self.request.settimeout(5)
            super().setup()

        def log_message(self, *args):
            pass  # Callback URLs contain authorization codes.

        def do_GET(self):
            parsed = urllib.parse.urlsplit(self.path)
            query = urllib.parse.parse_qs(parsed.query, keep_blank_values=True)
            returned = query.get("state", [])
            valid = (parsed.path == "/callback" and len(returned) == 1
                     and secrets.compare_digest(returned[0].encode(), state.encode()))
            if not valid or (not query.get("error") and (len(query.get("code", [])) != 1 or not query["code"][0])):
                self.send_response(400)
                self.end_headers()
                return
            result.update(error=bool(query.get("error")), code=query.get("code", [""])[0])
            self.send_response(200)
            self.end_headers()
            self.wfile.write(b"You can close this window and return to the program.")

    with HTTPServer(("127.0.0.1", 0), Callback) as server:
        redirect_uri = f"http://127.0.0.1:{server.server_port}/callback"
        client_id = request("/register", data={
            "client_name": "Asklear Python example", "redirect_uris": [redirect_uri],
            "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"],
            "token_endpoint_auth_method": "none", "scope": "use_asklear",
        })["client_id"]
        url = BASE + "/authorize?" + urllib.parse.urlencode({
            "response_type": "code", "client_id": client_id, "redirect_uri": redirect_uri,
            "scope": "use_asklear", "resource": BASE + "/mcp", "state": state,
            "code_challenge": challenge, "code_challenge_method": "S256",
        })
        print("Open this URL in your browser:\n" + url, flush=True)
        webbrowser.open(url)
        deadline = time.monotonic() + 180
        while not result and time.monotonic() < deadline:
            server.timeout = min(5, deadline - time.monotonic())
            server.handle_request()
    if not result:
        raise TimeoutError("Authorization callback timed out")
    if result["error"]:
        raise ValueError("Authorization was denied")
    return save_token(client_id, request("/token", form=True, data={
        "grant_type": "authorization_code", "client_id": client_id, "code": result["code"],
        "redirect_uri": redirect_uri, "code_verifier": verifier, "resource": BASE + "/mcp",
    }))


def refresh(record):
    return save_token(record["client_id"], request("/token", form=True, data={
        "grant_type": "refresh_token", "client_id": record["client_id"],
        "refresh_token": record["refresh_token"], "resource": BASE + "/mcp",
    }))


def main():
    record = json.loads(TOKEN_FILE.read_text()) if TOKEN_FILE.exists() else login()
    if record["expires_at"] <= time.time() + 60:
        record = refresh(record)
    try:
        usage = request("/v1/usage", token=record["access_token"])
    except requests.HTTPError as error:
        if error.response is None or error.response.status_code != 401:
            raise
        record = refresh(record)
        usage = request("/v1/usage", token=record["access_token"])
    print(json.dumps({k: usage[k] for k in ("balance", "wallet_balance", "reserved_balance", "refund_holds")}))


if __name__ == "__main__":
    try:
        main()
    except requests.HTTPError as error:
        status = error.response.status_code if error.response is not None else "unknown"
        print(f"HTTP {status}; see the tutorial's error guidance. Tokens were not logged.", file=sys.stderr)
        sys.exit(1)
    except (OSError, ValueError, KeyError, requests.RequestException, TimeoutError):
        print("Network, callback or credential error; check the tutorial before retrying.", file=sys.stderr)
        sys.exit(1)
```

## Node.js (JavaScript / TypeScript) / Node.js（JavaScript / TypeScript）

需要 Node.js 22+，无需 npm 依赖。保存为 asklear_oauth.mjs，执行 node asklear_oauth.mjs。TypeScript 使用相同的 Node.js API、请求字段和授权流程；这个可直接运行的示例使用 JavaScript。

Requires Node.js 22+ with no npm dependencies. Save as asklear_oauth.mjs and run node asklear_oauth.mjs. TypeScript uses the same Node.js APIs, request fields and flow; this directly runnable example is JavaScript.

```javascript
const ACCESS_TOKEN = "<access_token>";
const response = await fetch("https://api.asklear.cn/v1/usage", {
  headers: { Authorization: `Bearer ${ACCESS_TOKEN}` },
  signal: AbortSignal.timeout(15000),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const usage = await response.json();
console.log(Object.fromEntries(
  ["balance", "wallet_balance", "reserved_balance", "refund_holds"]
    .map((key) => [key, usage[key]]),
));
```

```javascript
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
import { readFile, open, rename } from "node:fs/promises";

const BASE = "https://api.asklear.cn";
const TOKEN_FILE = "asklear-oauth.json";

class HttpError extends Error {
  constructor(status) { super(`HTTP ${status}`); this.status = status; }
}
async function request(path, { data, token, form = false } = {}) {
  const headers = { Accept: "application/json" };
  if (data) headers["Content-Type"] = form ? "application/x-www-form-urlencoded" : "application/json";
  if (token) headers.Authorization = `Bearer ${token}`;
  const response = await fetch(BASE + path, {
    method: data ? "POST" : "GET", headers, redirect: "error", signal: AbortSignal.timeout(15000),
    body: data ? (form ? new URLSearchParams(data) : JSON.stringify(data)) : undefined,
  });
  if (!response.ok) throw new HttpError(response.status);
  return response.json();
}
async function saveToken(clientId, data) {
  if (data.token_type?.toLowerCase() !== "bearer"
      || ![data.access_token, data.refresh_token].every((value) => typeof value === "string" && value.length > 0)
      || !Number.isInteger(data.expires_in) || data.expires_in <= 0) throw new Error("Invalid token response");
  const record = { client_id: clientId, access_token: data.access_token,
    refresh_token: data.refresh_token, expires_at: Date.now() / 1000 + data.expires_in };
  const file = await open(TOKEN_FILE + ".tmp", "w", 0o600);
  try { await file.chmod(0o600); await file.writeFile(JSON.stringify(record)); }
  finally { await file.close(); }
  await rename(TOKEN_FILE + ".tmp", TOKEN_FILE);
  return record;
}
async function login() {
  const state = randomBytes(32).toString("base64url");
  const verifier = randomBytes(64).toString("base64url");
  const challenge = createHash("sha256").update(verifier).digest("base64url");
  let finish;
  const callback = new Promise((resolve) => { finish = resolve; });
  const server = createServer((req, res) => {
    const url = new URL(req.url, "http://127.0.0.1");
    const values = url.searchParams;
    const returned = Buffer.from(values.get("state") ?? "");
    const expected = Buffer.from(state);
    if (url.pathname !== "/callback" || values.getAll("state").length !== 1
        || returned.length !== expected.length || !timingSafeEqual(returned, expected)
        || (!values.has("error") && (values.getAll("code").length !== 1 || !values.get("code")))) {
      res.writeHead(400).end(); return;
    }
    res.writeHead(200).end("You can close this window and return to the program.");
    finish({ code: values.get("code"), error: values.has("error") });
  });
  server.requestTimeout = 5000;
  server.headersTimeout = 5000;
  await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); });
  const timer = setTimeout(() => finish({ error: true }), 180000);
  let result, clientId, redirectUri;
  try {
    redirectUri = `http://127.0.0.1:${server.address().port}/callback`;
    clientId = (await request("/register", { data: {
      client_name: "Asklear Node.js example", redirect_uris: [redirectUri],
      grant_types: ["authorization_code", "refresh_token"], response_types: ["code"],
      token_endpoint_auth_method: "none", scope: "use_asklear",
    } })).client_id;
    const url = BASE + "/authorize?" + new URLSearchParams({
      response_type: "code", client_id: clientId, redirect_uri: redirectUri,
      scope: "use_asklear", resource: BASE + "/mcp", state,
      code_challenge: challenge, code_challenge_method: "S256",
    });
    console.log("Open this URL in your browser:\n" + url);
    result = await callback;
  } finally {
    clearTimeout(timer);
    server.closeAllConnections();
    await new Promise((resolve) => server.close(resolve));
  }
  if (result.error) throw new Error("Authorization denied or timed out");
  return saveToken(clientId, await request("/token", { form: true, data: {
    grant_type: "authorization_code", client_id: clientId, code: result.code,
    redirect_uri: redirectUri, code_verifier: verifier, resource: BASE + "/mcp",
  } }));
}
async function refresh(record) {
  return saveToken(record.client_id, await request("/token", { form: true, data: {
    grant_type: "refresh_token", client_id: record.client_id,
    refresh_token: record.refresh_token, resource: BASE + "/mcp",
  } }));
}
async function main() {
  let record;
  try { record = JSON.parse(await readFile(TOKEN_FILE, "utf8")); }
  catch (error) { if (error.code !== "ENOENT") throw error; record = await login(); }
  if (record.expires_at <= Date.now() / 1000 + 60) record = await refresh(record);
  let usage;
  try { usage = await request("/v1/usage", { token: record.access_token }); }
  catch (error) {
    if (!(error instanceof HttpError) || error.status !== 401) throw error;
    record = await refresh(record);
    usage = await request("/v1/usage", { token: record.access_token });
  }
  console.log(JSON.stringify(Object.fromEntries(
    ["balance", "wallet_balance", "reserved_balance", "refund_holds"].map((key) => [key, usage[key]]),
  )));
}
main().catch((error) => {
  console.error(error instanceof HttpError ? `HTTP ${error.status}; see the tutorial's error guidance.`
    : "Network, callback or credential error; check the tutorial before retrying.");
  process.exitCode = 1;
});
```

## Go / Go

需要 Go 1.22+，仅使用标准库。保存为 asklear_oauth.go，执行 go run asklear_oauth.go。

Requires Go 1.22+ and the standard library only. Save as asklear_oauth.go and run go run asklear_oauth.go.

```go
req, _ := http.NewRequest("GET", "https://api.asklear.cn/v1/usage", nil)
req.Header.Set("Authorization", "Bearer "+accessToken)
client := &http.Client{Timeout: 15 * time.Second}
response, err := client.Do(req)
if err != nil { log.Fatal(err) }
defer response.Body.Close()
if response.StatusCode/100 != 2 { log.Fatal(response.Status) }
var usage map[string]any
json.NewDecoder(response.Body).Decode(&usage)
fmt.Println(usage["balance"], usage["wallet_balance"])
```

```go
package main

import (
    "bytes"
    "crypto/rand"
    "crypto/sha256"
    "crypto/subtle"
    "encoding/base64"
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "net"
    "net/http"
    "net/url"
    "os"
    "strings"
    "time"
)

const base = "https://api.asklear.cn"
const tokenFile = "asklear-oauth.json"

type credential struct {
    ClientID     string  `json:"client_id"`
    AccessToken  string  `json:"access_token"`
    RefreshToken string  `json:"refresh_token"`
    ExpiresAt    float64 `json:"expires_at"`
}
type tokenResponse struct {
    AccessToken  string `json:"access_token"`
    RefreshToken string `json:"refresh_token"`
    TokenType    string `json:"token_type"`
    ExpiresIn    int    `json:"expires_in"`
}
type httpError struct{ status int }

func (e *httpError) Error() string { return fmt.Sprintf("HTTP %d", e.status) }

var client = &http.Client{Timeout: 15 * time.Second,
    CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}

func request(method, path string, body io.Reader, contentType, token string, output any) error {
    req, err := http.NewRequest(method, base+path, body)
    if err != nil {
        return err
    }
    req.Header.Set("Accept", "application/json")
    if contentType != "" {
        req.Header.Set("Content-Type", contentType)
    }
    if token != "" {
        req.Header.Set("Authorization", "Bearer "+token)
    }
    res, err := client.Do(req)
    if err != nil {
        return err
    }
    defer res.Body.Close()
    if res.StatusCode < 200 || res.StatusCode >= 300 {
        return &httpError{res.StatusCode}
    }
    return json.NewDecoder(res.Body).Decode(output)
}

func issue(clientID string, form url.Values) (credential, error) {
    form.Set("client_id", clientID)
    form.Set("resource", base+"/mcp")
    var token tokenResponse
    err := request("POST", "/token", strings.NewReader(form.Encode()), "application/x-www-form-urlencoded", "", &token)
    if err != nil {
        return credential{}, err
    }
    if !strings.EqualFold(token.TokenType, "Bearer") || token.AccessToken == "" || token.RefreshToken == "" || token.ExpiresIn <= 0 {
        return credential{}, errors.New("invalid token response")
    }
    record := credential{clientID, token.AccessToken, token.RefreshToken, float64(time.Now().Unix() + int64(token.ExpiresIn))}
    data, err := json.Marshal(record)
    if err != nil {
        return credential{}, err
    }
    file, err := os.OpenFile(tokenFile+".tmp", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
    if err != nil {
        return credential{}, err
    }
    if err = file.Chmod(0600); err == nil {
        _, err = file.Write(data)
    }
    closeErr := file.Close()
    if err != nil {
        return credential{}, err
    }
    if closeErr != nil {
        return credential{}, closeErr
    }
    err = os.Rename(tokenFile+".tmp", tokenFile)
    return record, err
}

func randomValue() (string, error) {
    data := make([]byte, 32)
    if _, err := rand.Read(data); err != nil {
        return "", err
    }
    return base64.RawURLEncoding.EncodeToString(data), nil
}

func login() (credential, error) {
    state, err := randomValue()
    if err != nil {
        return credential{}, err
    }
    verifier, err := randomValue()
    if err != nil {
        return credential{}, err
    }
    digest := sha256.Sum256([]byte(verifier))
    challenge := base64.RawURLEncoding.EncodeToString(digest[:])
    listener, err := net.Listen("tcp4", "127.0.0.1:0")
    if err != nil {
        return credential{}, err
    }
    redirectURI := "http://" + listener.Addr().String() + "/callback"
    type callback struct {
        code   string
        denied bool
    }
    callbacks := make(chan callback, 1)
    server := &http.Server{ReadHeaderTimeout: 5 * time.Second, WriteTimeout: 5 * time.Second,
        Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            q := r.URL.Query()
            if r.Method != "GET" || r.URL.Path != "/callback" || len(q["state"]) != 1 ||
                subtle.ConstantTimeCompare([]byte(q.Get("state")), []byte(state)) != 1 ||
                (!q.Has("error") && (len(q["code"]) != 1 || q.Get("code") == "")) {
                w.WriteHeader(400)
                return
            }
            fmt.Fprintln(w, "You can close this window and return to the program.")
            w.(http.Flusher).Flush()
            select {
            case callbacks <- callback{q.Get("code"), q.Has("error")}:
            default:
            }
        })}
    stopped := make(chan error, 1)
    go func() { stopped <- server.Serve(listener) }()
    defer func() { server.Close(); <-stopped }()
    body, err := json.Marshal(map[string]any{
        "client_name": "Asklear Go example", "redirect_uris": []string{redirectURI},
        "grant_types": []string{"authorization_code", "refresh_token"}, "response_types": []string{"code"},
        "token_endpoint_auth_method": "none", "scope": "use_asklear",
    })
    if err != nil {
        return credential{}, err
    }
    var registration struct {
        ClientID string `json:"client_id"`
    }
    if err = request("POST", "/register", bytes.NewReader(body), "application/json", "", &registration); err != nil {
        return credential{}, err
    }
    query := url.Values{
        "response_type": {"code"}, "client_id": {registration.ClientID}, "redirect_uri": {redirectURI},
        "scope": {"use_asklear"}, "resource": {base + "/mcp"}, "state": {state},
        "code_challenge": {challenge}, "code_challenge_method": {"S256"},
    }
    fmt.Println("Open this URL in your browser:\n" + base + "/authorize?" + query.Encode())
    timer := time.NewTimer(180 * time.Second)
    defer timer.Stop()
    select {
    case result := <-callbacks:
        if result.denied {
            return credential{}, errors.New("authorization denied")
        }
        return issue(registration.ClientID, url.Values{"grant_type": {"authorization_code"},
            "code": {result.code}, "redirect_uri": {redirectURI}, "code_verifier": {verifier}})
    case <-timer.C:
        return credential{}, errors.New("authorization callback timed out")
    }
}

func refresh(record credential) (credential, error) {
    return issue(record.ClientID, url.Values{"grant_type": {"refresh_token"}, "refresh_token": {record.RefreshToken}})
}

func run() error {
    var record credential
    data, err := os.ReadFile(tokenFile)
    if errors.Is(err, os.ErrNotExist) {
        record, err = login()
    } else if err == nil {
        err = json.Unmarshal(data, &record)
    }
    if err != nil {
        return err
    }
    if record.ExpiresAt <= float64(time.Now().Unix()+60) {
        record, err = refresh(record)
        if err != nil {
            return err
        }
    }
    var usage map[string]any
    err = request("GET", "/v1/usage", nil, "", record.AccessToken, &usage)
    var failure *httpError
    if errors.As(err, &failure) && failure.status == 401 {
        record, err = refresh(record)
        if err != nil {
            return err
        }
        err = request("GET", "/v1/usage", nil, "", record.AccessToken, &usage)
    }
    if err != nil {
        return err
    }
    balance := map[string]any{}
    for _, key := range []string{"balance", "wallet_balance", "reserved_balance", "refund_holds"} {
        balance[key] = usage[key]
    }
    return json.NewEncoder(os.Stdout).Encode(balance)
}

func main() {
    if err := run(); err != nil {
        var failure *httpError
        if errors.As(err, &failure) {
            fmt.Fprintln(os.Stderr, failure.Error()+"; see the tutorial's error guidance.")
        } else {
            fmt.Fprintln(os.Stderr, "Network, callback or credential error; check the tutorial before retrying.")
        }
        os.Exit(1)
    }
}
```

## Java / Java

需要 JDK 17+ 和 Gson 2.13.2（com.google.code.gson:gson:2.13.2，仅用于解析 JSON）。保存为 AsklearOAuth.java，把 Gson jar 放为 gson.jar；执行 javac -cp gson.jar AsklearOAuth.java，再执行 java -cp .:gson.jar AsklearOAuth。项目已有 Maven/Gradle 时直接使用该依赖坐标，不必更换 HTTP 客户端。

Requires JDK 17+ and Gson 2.13.2 (com.google.code.gson:gson:2.13.2, for JSON). Save as AsklearOAuth.java and place the Gson jar at gson.jar. Run javac -cp gson.jar AsklearOAuth.java, then java -cp .:gson.jar AsklearOAuth. Existing Maven/Gradle projects can use the coordinate directly; the HTTP client is part of the JDK.

```java
var request = HttpRequest.newBuilder(URI.create("https://api.asklear.cn/v1/usage"))
    .timeout(Duration.ofSeconds(15))
    .header("Authorization", "Bearer " + accessToken)
    .GET().build();
var response = HttpClient.newHttpClient().send(
    request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() / 100 != 2) throw new IOException(response.statusCode() + "");
var usage = new Gson().fromJson(response.body(), JsonObject.class);
System.out.println(usage.get("balance") + " " + usage.get("wallet_balance"));
```

```java
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.PosixFilePermissions;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors;
import static java.nio.charset.StandardCharsets.UTF_8;

public class AsklearOAuth {
    static final String BASE = "https://api.asklear.cn";
    static final Path TOKEN_FILE = Path.of("asklear-oauth.json");
    static final Gson JSON = new Gson();
    static final HttpClient HTTP = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(15))
        .followRedirects(HttpClient.Redirect.NEVER).build();

    static class HttpError extends IOException {
        final int status;
        HttpError(int status) { super("HTTP " + status); this.status = status; }
    }
    static JsonObject request(String path, String body, String contentType, String token) throws Exception {
        var builder = HttpRequest.newBuilder(URI.create(BASE + path)).timeout(Duration.ofSeconds(15))
            .header("Accept", "application/json");
        if (body != null) builder.header("Content-Type", contentType).POST(HttpRequest.BodyPublishers.ofString(body));
        if (token != null) builder.header("Authorization", "Bearer " + token);
        var response = HTTP.send(builder.build(), HttpResponse.BodyHandlers.ofString());
        if (response.statusCode() < 200 || response.statusCode() >= 300) throw new HttpError(response.statusCode());
        return JSON.fromJson(response.body(), JsonObject.class);
    }
    static String form(Map<String, String> values) {
        return values.entrySet().stream().map(e -> URLEncoder.encode(e.getKey(), UTF_8) + "="
            + URLEncoder.encode(e.getValue(), UTF_8)).collect(Collectors.joining("&"));
    }
    static String randomValue() {
        byte[] bytes = new byte[32];
        new SecureRandom().nextBytes(bytes);
        return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
    }
    static JsonObject issue(String clientId, Map<String, String> fields) throws Exception {
        var values = new HashMap<>(fields);
        values.put("client_id", clientId);
        values.put("resource", BASE + "/mcp");
        var token = request("/token", form(values), "application/x-www-form-urlencoded", null);
        if (!token.get("token_type").getAsString().equalsIgnoreCase("Bearer")
                || token.get("access_token").getAsString().isEmpty()
                || token.get("refresh_token").getAsString().isEmpty() || token.get("expires_in").getAsLong() <= 0) {
            throw new IOException("Invalid token response");
        }
        var record = new JsonObject();
        record.addProperty("client_id", clientId);
        record.add("access_token", token.get("access_token"));
        record.add("refresh_token", token.get("refresh_token"));
        record.addProperty("expires_at", Instant.now().getEpochSecond() + token.get("expires_in").getAsLong());
        var temporary = Files.createTempFile(Path.of("."), "asklear-oauth-", ".tmp",
            PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")));
        Files.writeString(temporary, JSON.toJson(record));
        Files.move(temporary, TOKEN_FILE, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
        return record;
    }
    static JsonObject login() throws Exception {
        String state = randomValue(), verifier = randomValue();
        String challenge = Base64.getUrlEncoder().withoutPadding().encodeToString(
            MessageDigest.getInstance("SHA-256").digest(verifier.getBytes(UTF_8)));
        var ready = new CountDownLatch(1);
        var code = new AtomicReference<String>();
        var denied = new AtomicReference<Boolean>(false);
        var server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
        server.createContext("/callback", exchange -> {
            var query = new HashMap<String, String>();
            boolean valid = exchange.getRequestMethod().equals("GET")
                && exchange.getRequestURI().getPath().equals("/callback");
            try {
                for (String pair : String.valueOf(exchange.getRequestURI().getRawQuery()).split("&")) {
                    String[] parts = pair.split("=", 2);
                    String key = URLDecoder.decode(parts[0], UTF_8);
                    String value = parts.length == 2 ? URLDecoder.decode(parts[1], UTF_8) : "";
                    if (query.put(key, value) != null) valid = false;
                }
                valid = valid && MessageDigest.isEqual(state.getBytes(UTF_8), query.getOrDefault("state", "").getBytes(UTF_8))
                    && (query.containsKey("error") || !query.getOrDefault("code", "").isEmpty());
            } catch (IllegalArgumentException error) { valid = false; }
            byte[] response = "Return to the program after completing authorization.".getBytes(UTF_8);
            try {
                exchange.sendResponseHeaders(valid ? 200 : 400, response.length);
                exchange.getResponseBody().write(response);
            } finally { exchange.close(); }
            if (valid) {
                code.set(query.get("code"));
                denied.set(query.containsKey("error"));
                ready.countDown();
            }
        });
        server.start();
        String redirectURI = "http://127.0.0.1:" + server.getAddress().getPort() + "/callback";
        String clientId;
        try {
            clientId = request("/register", JSON.toJson(Map.of(
                "client_name", "Asklear Java example", "redirect_uris", List.of(redirectURI),
                "grant_types", List.of("authorization_code", "refresh_token"), "response_types", List.of("code"),
                "token_endpoint_auth_method", "none", "scope", "use_asklear"
            )), "application/json", null).get("client_id").getAsString();
            String url = BASE + "/authorize?" + form(Map.of(
                "response_type", "code", "client_id", clientId, "redirect_uri", redirectURI,
                "scope", "use_asklear", "resource", BASE + "/mcp", "state", state,
                "code_challenge", challenge, "code_challenge_method", "S256"));
            System.out.println("Open this URL in your browser:\n" + url);
            if (!ready.await(180, TimeUnit.SECONDS)) throw new IOException("Authorization callback timed out");
            if (denied.get()) throw new IOException("Authorization denied");
        } finally { server.stop(0); }
        return issue(clientId, Map.of("grant_type", "authorization_code", "code", code.get(),
            "redirect_uri", redirectURI, "code_verifier", verifier));
    }
    static JsonObject refresh(JsonObject record) throws Exception {
        return issue(record.get("client_id").getAsString(), Map.of("grant_type", "refresh_token",
            "refresh_token", record.get("refresh_token").getAsString()));
    }
    static void run() throws Exception {
        var record = Files.exists(TOKEN_FILE) ? JSON.fromJson(Files.readString(TOKEN_FILE), JsonObject.class) : login();
        if (record.get("expires_at").getAsDouble() <= Instant.now().getEpochSecond() + 60) record = refresh(record);
        JsonObject usage;
        try { usage = request("/v1/usage", null, null, record.get("access_token").getAsString()); }
        catch (HttpError error) {
            if (error.status != 401) throw error;
            record = refresh(record);
            usage = request("/v1/usage", null, null, record.get("access_token").getAsString());
        }
        var balance = new JsonObject();
        for (String key : List.of("balance", "wallet_balance", "reserved_balance", "refund_holds")) balance.add(key, usage.get(key));
        System.out.println(JSON.toJson(balance));
    }
    public static void main(String[] args) {
        try { run(); }
        catch (HttpError error) {
            System.err.println("HTTP " + error.status + "; see the tutorial's error guidance.");
            System.exit(1);
        } catch (Exception error) {
            System.err.println("Network, callback or credential error; check the tutorial before retrying.");
            System.exit(1);
        }
    }
}
```

## Refresh, errors and production use / 刷新、错误与生产使用

当前 access token 有效期为 1 小时，刷新凭据有效期为 30 天；以响应 expires_in 为准。示例提前 60 秒刷新；GET /v1/usage 返回 401 时最多刷新并重试一次。POST /token 刷新会轮换 refresh token，必须先将新的一对 token 一起保存，再发下一次业务请求；不要并发复用旧 refresh token。

Access tokens currently last one hour and refresh credentials last 30 days; use the returned expires_in. Examples refresh 60 seconds early and refresh/retry GET /v1/usage at most once after 401. POST /token rotates the refresh token: save the new token pair together before the next business request. Never refresh concurrently with the old refresh token.

回调 state 不匹配、路径错误或 code 重复时返回 400 并继续等待；用户拒绝授权则终止。授权码只能使用一次。invalid_grant（400，令牌过期、已使用或连接撤销）应重新由用户授权；invalid_client 或 invalid_request（400/401）应检查 client_id、redirect_uri、resource、PKCE 及表单格式，不能靠无限重试解决。示例仅输出状态，不记录完整响应、回调或凭据。

Callbacks with the wrong state/path or duplicate code are rejected with 400 while waiting continues; user denial stops the flow. Codes are single-use. invalid_grant (400: expired/used credentials or revoked connection) requires user authorization again. For invalid_client or invalid_request (400/401), check client_id, redirect_uri, resource, PKCE and form encoding rather than retrying indefinitely. Examples log status only, not complete responses, callback URLs or credentials.

429 按 Retry-After 等待；余额查询的临时网络故障或 5xx 可采用有上限的退避。授权码交换或刷新请求超时后，服务端可能已消费原凭据，不能盲目自动重试；先核对已保存的凭据，必要时重新授权。保存新 token 失败时停止请求，修复存储或重新授权，不要退回旧 refresh token。

For 429, respect Retry-After. Balance reads may use bounded backoff for temporary network errors or 5xx. A timed-out code exchange or refresh may already have consumed its credential: do not blindly retry it. Check saved credentials and reauthorize when necessary. Stop if new tokens cannot be saved; repair storage or authorize again rather than reverting to an old refresh token.

这些是本机单进程接入示例。生产 Web 服务应使用注册的 HTTPS 回调，将 state 和 PKCE verifier 绑定到用户会话，使用凭据存储并串行刷新。账户余额只供获授权的账户所有者读取，不要把 OAuth token 作为公开网页中的共享密钥。

These are local, single-process examples. Production web services should use a registered HTTPS callback, bind state and the PKCE verifier to the user session, store credentials securely and serialize refreshes. Account balances belong to the authorized account; never expose an OAuth token as a shared public website credential.
