@wengine-ai/ccr-provider-qoder 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,7 +18,7 @@ Everything below exists once per site; the two sites are independent (own accoun
18
18
  - **Transformer** (`qoder` / `qoder-cn`): unified → chat-completions request conversion (leading system message, thinking → `reasoning_content`, stable session id → `prompt_cache_key`, reasoning effort passthrough) and strict SSE/JSON response validation (error-frame classification, `[DONE]` integrity, usage mapping). The conversion is site-independent; each managed provider locks its own transformer name.
19
19
  - **Managed runtime** (`qoder` / `qoder-cn`): pins that site's fixed chat upstream, routes through the host's subscription dispatcher, owns abort/deadline wiring and the error taxonomy (`qoder_reauth_required`, `qoder_rate_limited` with `retry_after`-derived cooldown, `qoder_upstream_error`). A runtime claims only its own managed provider.
20
20
  - **Auth service**: PAT sign-in with the job-token engine, per-site credential file (0600, atomic, size-capped), model sync/visibility, usage reads with a 60s cache, cooling circuit breaker and reconnect.
21
- - **Admin routes** (loopback-only, per site): status, connect, sync, usage, model visibility, disconnect.
21
+ - **Admin routes** (loopback-only, per site): status, connect, sync, usage, checkin, model visibility, disconnect.
22
22
  - **One config protector**: re-locks BOTH managed providers in a single pass and re-adds any of them that vanish from the config.
23
23
  - **UI card + locales** (en/zh): one card with two independent sections (Qoder / Qoder CN) — PAT connect form, status, quota windows, model management.
24
24
 
@@ -38,9 +38,29 @@ Then, in the CCR settings page, open the Qoder card and use the matching section
38
38
 
39
39
  Both sections can be connected at the same time; disconnecting one never touches the other.
40
40
 
41
+ ## Manual daily check-in
42
+
43
+ The Qoder CN card has a **Check in** button. It lists campaigns, claims the first
44
+ `CLAIM_BENEFIT` marked `CLAIMABLE`, and shows the awarded credits. Already claimed
45
+ and unavailable campaigns are ordinary results, with no automatic retry or timer.
46
+
47
+ Loopback-only `POST /api/qoder/cn/checkin` returns
48
+ `{ outcome, credit?, message? }`, where outcome is `claimed`, `done`, `inactive`,
49
+ `ineligible`, or `failed`. Credits are a positive finite number when supplied.
50
+ `POST /api/qoder/checkin` returns `ineligible`: international campaigns require
51
+ Qoder's signed desktop identity, which this PAT-only plugin does not read. The
52
+ international card directs users to claim in Qoder desktop.
53
+
54
+ CN PAT requests use the exchanged job token and the upstream's supported empty
55
+ native-identity fallback. No machine token or machine type is fabricated.
56
+ Requests use the subscription dispatcher (direct fallback), identity encoding,
57
+ and reject redirects. Upstream response bodies and transport errors are never
58
+ returned in failure messages. Admin authorization failures retain HTTP 403;
59
+ check-in outcomes return HTTP 200.
60
+
41
61
  ## Scope limits
42
62
 
43
- - Remote origins are fixed per site: `openapi.qoder.sh` / `api3.qoder.sh` for qoder.com and `openapi.qoder.com.cn` / `gateway.qoder.com.cn` for qoder.cn. No device flow, no campaigns/check-in, no VPC, no QwenWork.
63
+ - Remote origins are fixed per site: `openapi.qoder.sh` / `api3.qoder.sh` for qoder.com and `openapi.qoder.com.cn` / `gateway.qoder.com.cn` for qoder.cn. No device flow, no VPC, no QwenWork.
44
64
  - Sign-in is PAT-only; the job token is refreshed from its refresh token when present, and a lapsed/absent refresh falls back to PAT re-exchange (a refused PAT marks that site's account expired until you reconnect). The PAT is kept in the plugin's private 0600 credential file.
45
65
 
46
66
  ## Development
package/dist/README.md ADDED
@@ -0,0 +1,75 @@
1
+ # @wengine-ai/ccr-provider-qoder
2
+
3
+ Qoder subscription provider plugin for [claude-code-router](https://github.com/wengine-ai/claude-code-router-next), covering both Qoder sites: **qoder.com** (international) and **qoder.cn** (Qoder CN).
4
+
5
+ ## What it provides
6
+
7
+ Everything below exists once per site; the two sites are independent (own account, own credentials, own models, own quota):
8
+
9
+ | | international | Qoder CN |
10
+ |---|---|---|
11
+ | managed provider | `qoder-subscription` | `qoder-cn-subscription` |
12
+ | marker credential | `ccr-managed-qoder` | `ccr-managed-qoder-cn` |
13
+ | credential file | `qoder-credentials.json` | `qoder-cn-credentials.json` |
14
+ | fixed chat upstream | `api3.qoder.sh` | `gateway.qoder.com.cn` |
15
+ | admin routes | `/api/qoder/*` | `/api/qoder/cn/*` |
16
+ | transformer | `qoder` | `qoder-cn` |
17
+
18
+ - **Transformer** (`qoder` / `qoder-cn`): unified → chat-completions request conversion (leading system message, thinking → `reasoning_content`, stable session id → `prompt_cache_key`, reasoning effort passthrough) and strict SSE/JSON response validation (error-frame classification, `[DONE]` integrity, usage mapping). The conversion is site-independent; each managed provider locks its own transformer name.
19
+ - **Managed runtime** (`qoder` / `qoder-cn`): pins that site's fixed chat upstream, routes through the host's subscription dispatcher, owns abort/deadline wiring and the error taxonomy (`qoder_reauth_required`, `qoder_rate_limited` with `retry_after`-derived cooldown, `qoder_upstream_error`). A runtime claims only its own managed provider.
20
+ - **Auth service**: PAT sign-in with the job-token engine, per-site credential file (0600, atomic, size-capped), model sync/visibility, usage reads with a 60s cache, cooling circuit breaker and reconnect.
21
+ - **Admin routes** (loopback-only, per site): status, connect, sync, usage, checkin, model visibility, disconnect.
22
+ - **One config protector**: re-locks BOTH managed providers in a single pass and re-adds any of them that vanish from the config.
23
+ - **UI card + locales** (en/zh): one card with two independent sections (Qoder / Qoder CN) — PAT connect form, status, quota windows, model management.
24
+
25
+ ## PAT setup
26
+
27
+ For the international site:
28
+
29
+ 1. Sign in at [qoder.com](https://qoder.com) and open **Account → Integrations** (`qoder.com/account/integrations`).
30
+ 2. Create a personal access token (starts with `pt-`).
31
+
32
+ For Qoder CN, do the same on [qoder.cn](https://qoder.cn) (the token exchange runs against `openapi.qoder.com.cn`).
33
+
34
+ Then, in the CCR settings page, open the Qoder card and use the matching section (**Qoder** or **Qoder CN**):
35
+
36
+ 3. Paste the token and press **Connect**. The token is kept only in that site's private credential file and is cleared from the form immediately.
37
+ 4. Press **Refresh models** to sync the model list.
38
+
39
+ Both sections can be connected at the same time; disconnecting one never touches the other.
40
+
41
+ ## Manual daily check-in
42
+
43
+ The Qoder CN card has a **Check in** button. It lists campaigns, claims the first
44
+ `CLAIM_BENEFIT` marked `CLAIMABLE`, and shows the awarded credits. Already claimed
45
+ and unavailable campaigns are ordinary results, with no automatic retry or timer.
46
+
47
+ Loopback-only `POST /api/qoder/cn/checkin` returns
48
+ `{ outcome, credit?, message? }`, where outcome is `claimed`, `done`, `inactive`,
49
+ `ineligible`, or `failed`. Credits are a positive finite number when supplied.
50
+ `POST /api/qoder/checkin` returns `ineligible`: international campaigns require
51
+ Qoder's signed desktop identity, which this PAT-only plugin does not read. The
52
+ international card directs users to claim in Qoder desktop.
53
+
54
+ CN PAT requests use the exchanged job token and the upstream's supported empty
55
+ native-identity fallback. No machine token or machine type is fabricated.
56
+ Requests use the subscription dispatcher (direct fallback), identity encoding,
57
+ and reject redirects. Upstream response bodies and transport errors are never
58
+ returned in failure messages. Admin authorization failures retain HTTP 403;
59
+ check-in outcomes return HTTP 200.
60
+
61
+ ## Scope limits
62
+
63
+ - Remote origins are fixed per site: `openapi.qoder.sh` / `api3.qoder.sh` for qoder.com and `openapi.qoder.com.cn` / `gateway.qoder.com.cn` for qoder.cn. No device flow, no VPC, no QwenWork.
64
+ - Sign-in is PAT-only; the job token is refreshed from its refresh token when present, and a lapsed/absent refresh falls back to PAT re-exchange (a refused PAT marks that site's account expired until you reconnect). The PAT is kept in the plugin's private 0600 credential file.
65
+
66
+ ## Development
67
+
68
+ ```sh
69
+ pnpm install # standalone package deps
70
+ pnpm vitest run # fully offline tests (all fetch mocked/injected)
71
+ pnpm tsc --noEmit
72
+ pnpm build # produces dist/ (self-contained plugin + UI card bundles)
73
+ ```
74
+
75
+ Tests never touch the network: every engine is injected via `options.engine`, every fetch is mocked.
@@ -0,0 +1,63 @@
1
+ # Third-party notices
2
+
3
+ `src/vendor/engine.mjs` is a locally modified extracted subset of
4
+ [magpie-community/plugins](https://github.com/magpie-community/plugins),
5
+ `packages/qoder/index.mjs` at commit
6
+ `bace91473b4f113a719a49b7d8ce81fe1f5b5760`, published as
7
+ `@magpie-community/opencode-qoder-auth` version `0.2.40`.
8
+ Only the qoder.com international protocol and PAT authentication subset is
9
+ included. Local modifications provide an injected transport and clock,
10
+ per-engine caches, cancellation, credential renewal notification, and CCR
11
+ error envelopes. Device sign-in, campaigns, VPC and other sites are excluded.
12
+
13
+ The upstream protocol implementation credits CLIProxyAPI's MIT-licensed
14
+ Qoder support (https://github.com/ufec/CLIProxyAPI), via magpie's internal/qoder.
15
+
16
+ ## magpie-community/plugins license
17
+
18
+ MIT License
19
+
20
+ Copyright (c) 2026 magpie-community contributors
21
+
22
+ Permission is hereby granted, free of charge, to any person obtaining a copy
23
+ of this software and associated documentation files (the "Software"), to deal
24
+ in the Software without restriction, including without limitation the rights
25
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
26
+ copies of the Software, and to permit persons to whom the Software is
27
+ furnished to do so, subject to the following conditions:
28
+
29
+ The above copyright notice and this permission notice shall be included in all
30
+ copies or substantial portions of the Software.
31
+
32
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
33
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
34
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
35
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
36
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
37
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
38
+ SOFTWARE.
39
+
40
+ ## CLIProxyAPI license
41
+
42
+ MIT License
43
+
44
+ Copyright (c) 2025-2005.9 Luis Pater
45
+ Copyright (c) 2025.9-present Router-For.ME
46
+
47
+ Permission is hereby granted, free of charge, to any person obtaining a copy
48
+ of this software and associated documentation files (the "Software"), to deal
49
+ in the Software without restriction, including without limitation the rights
50
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
51
+ copies of the Software, and to permit persons to whom the Software is
52
+ furnished to do so, subject to the following conditions:
53
+
54
+ The above copyright notice and this permission notice shall be included in all
55
+ copies or substantial portions of the Software.
56
+
57
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
58
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
59
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
60
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
61
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
62
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
63
+ SOFTWARE.
package/dist/card.js ADDED
@@ -0,0 +1,341 @@
1
+ // src/locales.ts
2
+ var qoderLocales = {
3
+ en: { qoder: {
4
+ title: "Qoder Subscription",
5
+ description: "Connects a qoder.com personal access token (PAT). Tokens stay on this machine, are never logged and are cleared from the form after connecting.",
6
+ cnTitle: "Qoder CN Subscription",
7
+ cnDescription: "Connects a qoder.cn (Qoder China) personal access token (PAT). Tokens stay on this machine, are never logged and are cleared from the form after connecting.",
8
+ loading: "Loading\u2026",
9
+ not_connected: "Not connected",
10
+ connected: "Connected",
11
+ expired: "Sign-in lapsed \u2014 reconnect with a fresh PAT",
12
+ error: "Management request failed",
13
+ account: "Account",
14
+ models: "Models",
15
+ pat: "Personal access token",
16
+ patPlaceholder: "pt-\u2026",
17
+ connect: "Connect",
18
+ checkin: "Check in",
19
+ checkin_claimed: "Credits claimed",
20
+ checkin_done: "Already claimed today",
21
+ checkin_inactive: "No campaign today",
22
+ checkin_ineligible: "Unavailable",
23
+ checkin_failed: "Check-in failed",
24
+ checkinDesktop: "International check-in requires Qoder desktop device identity; claim in Qoder desktop.",
25
+ sync: "Refresh models",
26
+ usage: "Refresh quota",
27
+ unknown: "Quota unknown",
28
+ stale: "Last known quota (stale)",
29
+ used: "Allowance",
30
+ disconnect: "Disconnect",
31
+ hide: "Hide",
32
+ restore: "Restore",
33
+ cooling: "Rate limited until",
34
+ noModels: "Refresh models after connecting",
35
+ free: "free",
36
+ refresh: "Refresh status"
37
+ } },
38
+ zh: { qoder: {
39
+ title: "Qoder \u8BA2\u9605",
40
+ description: "\u4F7F\u7528 qoder.com \u4E2A\u4EBA\u8BBF\u95EE\u4EE4\u724C\uFF08PAT\uFF09\u8FDE\u63A5\u3002\u4EE4\u724C\u4EC5\u4FDD\u5B58\u5728\u672C\u673A\uFF0C\u4E0D\u4F1A\u88AB\u8BB0\u5F55\uFF0C\u8FDE\u63A5\u6210\u529F\u540E\u5373\u4ECE\u8868\u5355\u6E05\u9664\u3002",
41
+ cnTitle: "Qoder \u56FD\u5185\u7248\u8BA2\u9605",
42
+ cnDescription: "\u4F7F\u7528 qoder.cn\uFF08Qoder \u56FD\u5185\u7248\uFF09\u4E2A\u4EBA\u8BBF\u95EE\u4EE4\u724C\uFF08PAT\uFF09\u8FDE\u63A5\u3002\u4EE4\u724C\u4EC5\u4FDD\u5B58\u5728\u672C\u673A\uFF0C\u4E0D\u4F1A\u88AB\u8BB0\u5F55\uFF0C\u8FDE\u63A5\u6210\u529F\u540E\u5373\u4ECE\u8868\u5355\u6E05\u9664\u3002",
43
+ loading: "\u52A0\u8F7D\u4E2D\u2026",
44
+ not_connected: "\u672A\u8FDE\u63A5",
45
+ connected: "\u5DF2\u8FDE\u63A5",
46
+ expired: "\u767B\u5F55\u5DF2\u5931\u6548 \u2014 \u8BF7\u4F7F\u7528\u65B0\u7684 PAT \u91CD\u65B0\u8FDE\u63A5",
47
+ error: "\u7BA1\u7406\u64CD\u4F5C\u5931\u8D25",
48
+ account: "\u8D26\u53F7",
49
+ models: "\u6A21\u578B",
50
+ pat: "\u4E2A\u4EBA\u8BBF\u95EE\u4EE4\u724C",
51
+ patPlaceholder: "pt-\u2026",
52
+ connect: "\u8FDE\u63A5",
53
+ checkin: "\u7B7E\u5230",
54
+ checkin_claimed: "\u9886\u53D6\u6210\u529F",
55
+ checkin_done: "\u4ECA\u65E5\u5DF2\u9886",
56
+ checkin_inactive: "\u4ECA\u65E5\u65E0\u6D3B\u52A8",
57
+ checkin_ineligible: "\u4E0D\u53EF\u7528",
58
+ checkin_failed: "\u7B7E\u5230\u5931\u8D25",
59
+ checkinDesktop: "\u56FD\u9645\u7AD9\u9700\u8981\u5728 Qoder \u684C\u9762\u7AEF\u5185\u7B7E\u5230\u3002",
60
+ sync: "\u5237\u65B0\u6A21\u578B",
61
+ usage: "\u5237\u65B0\u989D\u5EA6",
62
+ unknown: "\u989D\u5EA6\u672A\u77E5",
63
+ stale: "\u4E0A\u6B21\u989D\u5EA6\uFF08\u5DF2\u8FC7\u671F\uFF09",
64
+ used: "\u5DF2\u7528\u989D\u5EA6",
65
+ disconnect: "\u65AD\u5F00\u8FDE\u63A5",
66
+ hide: "\u9690\u85CF",
67
+ restore: "\u6062\u590D",
68
+ cooling: "\u9650\u6D41\u81F3",
69
+ noModels: "\u8FDE\u63A5\u540E\u8BF7\u5237\u65B0\u6A21\u578B",
70
+ free: "\u514D\u8D39",
71
+ refresh: "\u5237\u65B0\u72B6\u6001"
72
+ } }
73
+ };
74
+
75
+ // src/protocol.ts
76
+ var CHAT_PATH = "/algo/api/v2/service/pro/sse/agent_chat_generation?FetchKeys=llm_model_result&AgentId=agent_common&Encode=1";
77
+ var QODER_SITES = Object.freeze([
78
+ {
79
+ id: "qoder",
80
+ label: "Qoder",
81
+ openapi: "https://openapi.qoder.sh",
82
+ api: "https://api3.qoder.sh",
83
+ web: "https://qoder.com",
84
+ chatUrl: `https://api3.qoder.sh${CHAT_PATH}`,
85
+ provider: "qoder-subscription",
86
+ marker: "ccr-managed-qoder",
87
+ credentialFile: "qoder-credentials.json",
88
+ routeBase: "/api/qoder"
89
+ },
90
+ {
91
+ id: "qoder-cn",
92
+ label: "Qoder CN",
93
+ openapi: "https://openapi.qoder.com.cn",
94
+ api: "https://gateway.qoder.com.cn",
95
+ web: "https://qoder.cn",
96
+ chatUrl: `https://gateway.qoder.com.cn${CHAT_PATH}`,
97
+ provider: "qoder-cn-subscription",
98
+ marker: "ccr-managed-qoder-cn",
99
+ credentialFile: "qoder-cn-credentials.json",
100
+ routeBase: "/api/qoder/cn"
101
+ }
102
+ ]);
103
+ var QODER_CHAT = QODER_SITES[0].chatUrl;
104
+ var QODER_PROVIDER = QODER_SITES[0].provider;
105
+ var QODER_MARKER = QODER_SITES[0].marker;
106
+ function qoderSite(site) {
107
+ return QODER_SITES.find((s) => s.id === site) ?? QODER_SITES[0];
108
+ }
109
+ function qoderCardPrefix(site) {
110
+ const base = QODER_SITES[0].routeBase;
111
+ return qoderSite(site).routeBase.slice(base.length);
112
+ }
113
+ function isQoderManaged(p, site = "qoder") {
114
+ const managedBy = qoderSite(site);
115
+ const name = p?.name;
116
+ const owner = typeof name === "string" ? QODER_SITES.find((s) => s.provider === name) : void 0;
117
+ if (owner) {
118
+ return owner.id === managedBy.id && (p?.qoder_managed === true || p?.qoderManaged === true || (p?.api_key ?? p?.apiKey) === managedBy.marker);
119
+ }
120
+ if (managedBy.id !== QODER_SITES[0].id) return false;
121
+ return p?.qoder_managed === true || p?.qoderManaged === true;
122
+ }
123
+ function mergeQoderProvider(current, server, site = "qoder") {
124
+ const managedBy = qoderSite(site);
125
+ const next = { ...current };
126
+ const key = Array.isArray(current?.providers) && !Array.isArray(current?.Providers) ? "providers" : "Providers";
127
+ const managed = (server?.Providers ?? server?.providers ?? []).find((p) => isQoderManaged(p, managedBy.id));
128
+ const list = [...current?.[key] ?? []];
129
+ const at = list.findIndex((p) => isQoderManaged(p, managedBy.id));
130
+ if (at >= 0) {
131
+ if (managed) list[at] = managed;
132
+ else list.splice(at, 1);
133
+ } else if (managed && !list.some((p) => p?.name === managedBy.provider)) list.push(managed);
134
+ next[key] = list;
135
+ return next;
136
+ }
137
+ var RETRY_AFTER_MAX_MS = 30 * 6e4;
138
+
139
+ // src/card-entry.ts
140
+ var mounted = /* @__PURE__ */ new WeakMap();
141
+ function mount(el, props) {
142
+ unmount(el);
143
+ const lang = props.locale.toLowerCase().startsWith("zh") ? "zh" : "en";
144
+ const words = { ...qoderLocales[lang].qoder, ...props.locales?.[lang]?.qoder ?? {} };
145
+ const sections = QODER_SITES.map((site) => ({ site, prefix: qoderCardPrefix(site.id), error: false, busy: false, draft: "" }));
146
+ const panels = /* @__PURE__ */ new Map();
147
+ let disposed = false;
148
+ const abort = new AbortController();
149
+ const call = (section, path, body) => props.callApi(`${section.prefix}${path}`, {
150
+ method: body === void 0 ? "GET" : "POST",
151
+ ...body === void 0 ? {} : { body: JSON.stringify(body) },
152
+ signal: abort.signal,
153
+ timeoutMs: 45e3
154
+ });
155
+ const node = (tag, text, cls = "") => {
156
+ const n = document.createElement(tag);
157
+ n.textContent = text;
158
+ n.className = cls;
159
+ return n;
160
+ };
161
+ const sectionWords = (section) => section.site.id === "qoder" ? { title: words.title, description: words.description } : { title: words.cnTitle, description: words.cnDescription };
162
+ const finish = (section) => {
163
+ section.busy = false;
164
+ section.release?.();
165
+ section.release = void 0;
166
+ props.onBusy?.(false);
167
+ if (!disposed) render();
168
+ };
169
+ const refresh = async (section) => {
170
+ section.status = await call(section, "/status");
171
+ if (!disposed) render();
172
+ };
173
+ const reconcile = (section) => props.refreshConfig?.((current, server) => mergeQoderProvider(current, server, section.site.id));
174
+ const run = async (section, fn) => {
175
+ if (disposed || section.busy || props.disabled || !props.acquireManagement) return;
176
+ const lease = props.acquireManagement();
177
+ if (!lease) return;
178
+ section.release = lease;
179
+ section.busy = true;
180
+ section.error = false;
181
+ props.onBusy?.(true);
182
+ render();
183
+ try {
184
+ await fn();
185
+ } catch {
186
+ section.error = true;
187
+ } finally {
188
+ finish(section);
189
+ }
190
+ };
191
+ const connect = (section, input) => void run(section, async () => {
192
+ const pat = input.value;
193
+ try {
194
+ await call(section, "/connect", { pat });
195
+ input.value = "";
196
+ section.draft = "";
197
+ await reconcile(section);
198
+ await refresh(section);
199
+ } catch (e) {
200
+ input.value = "";
201
+ section.draft = "";
202
+ throw e;
203
+ }
204
+ });
205
+ const action = (section, label, fn) => {
206
+ const button = node("button", words[label], "px-3 py-2 rounded-md bg-secondary text-secondary-foreground hover:bg-secondary/80 disabled:opacity-50");
207
+ button.disabled = disposed || props.disabled || sections.some((s) => s.busy);
208
+ button.onclick = fn;
209
+ return button;
210
+ };
211
+ const renderSection = (section) => {
212
+ const panel = node("div", "", "space-y-3 border-t border-border pt-4 first:border-t-0 first:pt-0");
213
+ panel.append(
214
+ node("h3", sectionWords(section).title, "font-semibold text-lg"),
215
+ node("p", sectionWords(section).description, "text-sm text-muted-foreground")
216
+ );
217
+ panel.append(node("p", section.status ? words[section.status.status] ?? words.error : words.loading));
218
+ if (section.status?.expired) panel.append(node("p", words.expired, "text-destructive"));
219
+ if (section.status?.account) panel.append(node("p", `${words.account}: ${section.status.account}`));
220
+ if (section.error) panel.append(node("p", words.error, "text-destructive"));
221
+ const buttons = node("div", "", "flex flex-wrap gap-2");
222
+ if (section.status?.status === "not_connected") {
223
+ const input = node("input", "", "w-full rounded-md border border-input bg-background px-3 py-2 text-sm");
224
+ input.type = "password";
225
+ input.placeholder = words.patPlaceholder;
226
+ input.autocomplete = "off";
227
+ input.setAttribute("aria-label", words.pat);
228
+ input.value = section.draft;
229
+ input.oninput = () => {
230
+ section.draft = input.value;
231
+ };
232
+ const connectButton = action(section, "connect", () => connect(section, input));
233
+ input.onkeydown = (e) => {
234
+ if (e.key === "Enter") connect(section, input);
235
+ };
236
+ panel.append(node("p", words.pat, "text-sm font-medium"), input);
237
+ buttons.append(connectButton);
238
+ } else if (section.status) {
239
+ buttons.append(action(section, "sync", () => void run(section, async () => {
240
+ await call(section, "/sync", {});
241
+ await reconcile(section);
242
+ await refresh(section);
243
+ })));
244
+ if (section.site.id === "qoder-cn") buttons.append(action(section, "checkin", () => void run(section, async () => {
245
+ section.checkin = void 0;
246
+ try {
247
+ section.checkin = await call(section, "/checkin", {});
248
+ } catch {
249
+ section.checkin = { outcome: "failed" };
250
+ }
251
+ })));
252
+ buttons.append(action(section, "usage", () => void run(section, async () => {
253
+ section.usage = await call(section, "/usage", {});
254
+ })));
255
+ buttons.append(action(section, "disconnect", () => void run(section, async () => {
256
+ await call(section, "/disconnect", {});
257
+ section.usage = void 0;
258
+ section.checkin = void 0;
259
+ await reconcile(section);
260
+ await refresh(section);
261
+ })));
262
+ }
263
+ buttons.append(action(section, "refresh", () => void run(section, () => refresh(section))));
264
+ panel.append(buttons);
265
+ if (section.status?.status === "connected" && section.site.id === "qoder") panel.append(node("p", words.checkinDesktop, "text-sm text-muted-foreground"));
266
+ if (section.checkin) {
267
+ const result = section.checkin;
268
+ const amount = result.credit;
269
+ const detail = result.outcome === "claimed" && typeof amount === "number" && Number.isFinite(amount) && amount > 0 ? lang === "zh" ? `+${amount} \u79EF\u5206` : `+${amount} credits` : words[`checkin_${result.outcome}`] ?? words.checkin_failed;
270
+ panel.append(node("p", detail + (result.outcome === "failed" && result.message ? `: ${result.message}` : ""), result.outcome === "failed" ? "text-destructive" : "text-sm"));
271
+ }
272
+ const usageLine = node("p", "", "text-sm text-muted-foreground");
273
+ if (section.usage?.known && Array.isArray(section.usage.windows) && section.usage.windows.length) {
274
+ usageLine.textContent = `${section.usage.stale ? words.stale : words.used}: ` + section.usage.windows.map((w) => {
275
+ const name = typeof w?.name === "string" ? w.name.slice(0, 100) : "?";
276
+ const detail = typeof w?.display === "string" && w.display ? w.display : Number.isFinite(w?.used) ? `${Math.round(w.used)}%` : "?";
277
+ return `${name}: ${detail}`;
278
+ }).join(" \xB7 ");
279
+ } else usageLine.textContent = words.unknown;
280
+ panel.append(usageLine);
281
+ if (section.status?.coolingUntil > Date.now()) panel.append(node("p", `${words.cooling}: ${new Date(section.status.coolingUntil).toLocaleString()}`, "text-sm text-muted-foreground"));
282
+ panel.append(node("h4", words.models, "font-medium"));
283
+ if (!section.status?.models?.length) panel.append(node("p", words.noModels, "text-sm text-muted-foreground"));
284
+ for (const model of section.status?.models ?? []) {
285
+ const row = node("div", "", "flex items-center justify-between gap-2");
286
+ const label = `${model.id}${model.context ? ` \xB7 ${model.context}` : ""}${model.free ? ` \xB7 ${words.free}` : ""}`;
287
+ row.append(node("span", label, "text-sm"));
288
+ row.append(action(section, model.hidden ? "restore" : "hide", () => void run(section, async () => {
289
+ await call(section, "/models/visibility", { model: model.id, hidden: !model.hidden });
290
+ await reconcile(section);
291
+ await refresh(section);
292
+ })));
293
+ panel.append(row);
294
+ }
295
+ return panel;
296
+ };
297
+ const render = () => {
298
+ if (disposed) return;
299
+ el.replaceChildren();
300
+ panels.clear();
301
+ const card = node("section", "", "glass-card rounded-xl p-5 space-y-4");
302
+ for (const section of sections) {
303
+ const panel = renderSection(section);
304
+ panels.set(section.site.id, panel);
305
+ card.append(panel);
306
+ }
307
+ el.append(card);
308
+ };
309
+ const focus = () => {
310
+ const section = sections.find((s) => props.focusKey === s.site.provider || props.focusKey === s.site.id);
311
+ const panel = section && panels.get(section.site.id);
312
+ if (!panel) return;
313
+ panel.tabIndex = -1;
314
+ panel.focus();
315
+ panel.scrollIntoView?.({ block: "nearest" });
316
+ };
317
+ const load = (section) => void refresh(section).catch(() => {
318
+ section.error = true;
319
+ if (!disposed) render();
320
+ });
321
+ const off = props.onHostChange?.(render);
322
+ mounted.set(el, () => {
323
+ disposed = true;
324
+ off?.();
325
+ abort.abort();
326
+ });
327
+ render();
328
+ focus();
329
+ for (const section of sections) load(section);
330
+ }
331
+ function unmount(el) {
332
+ mounted.get(el)?.();
333
+ mounted.delete(el);
334
+ el.replaceChildren();
335
+ }
336
+ var card_entry_default = { mount, unmount };
337
+ export {
338
+ card_entry_default as default,
339
+ mount,
340
+ unmount
341
+ };
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "module"
3
+ }