@wengine-ai/ccr-provider-qoder 0.0.0-stage → 0.2.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 +54 -2
- package/dist/README.md +55 -0
- package/dist/THIRD_PARTY_NOTICES.md +63 -0
- package/dist/card.js +425 -0
- package/dist/package.json +3 -0
- package/dist/plugin.js +2534 -0
- package/dist/plugin.json +6 -0
- package/package.json +29 -3
package/README.md
CHANGED
|
@@ -1,3 +1,55 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @wengine-ai/ccr-provider-qoder
|
|
2
2
|
|
|
3
|
-
|
|
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, 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
|
+
## Scope limits
|
|
42
|
+
|
|
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.
|
|
44
|
+
- 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
|
+
|
|
46
|
+
## Development
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
pnpm install # standalone package deps
|
|
50
|
+
pnpm vitest run # fully offline tests (all fetch mocked/injected)
|
|
51
|
+
pnpm tsc --noEmit
|
|
52
|
+
pnpm build # produces dist/ (self-contained plugin + UI card bundles)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Tests never touch the network: every engine is injected via `options.engine`, every fetch is mocked.
|
package/dist/README.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
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, 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
|
+
## Scope limits
|
|
42
|
+
|
|
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.
|
|
44
|
+
- 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
|
+
|
|
46
|
+
## Development
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
pnpm install # standalone package deps
|
|
50
|
+
pnpm vitest run # fully offline tests (all fetch mocked/injected)
|
|
51
|
+
pnpm tsc --noEmit
|
|
52
|
+
pnpm build # produces dist/ (self-contained plugin + UI card bundles)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
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,425 @@
|
|
|
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
|
+
models_count: "{{count}} models",
|
|
16
|
+
pat: "Personal access token",
|
|
17
|
+
patPlaceholder: "pt-\u2026",
|
|
18
|
+
connect: "Connect",
|
|
19
|
+
sync: "Refresh models",
|
|
20
|
+
usage: "Refresh quota",
|
|
21
|
+
unknown: "Quota unknown",
|
|
22
|
+
stale: "Last known quota (stale)",
|
|
23
|
+
used: "Allowance",
|
|
24
|
+
restore_partial: "Partially completed: restored {{count}} of {{total}} models. Management request failed.",
|
|
25
|
+
hide_partial: "Partially completed: hid {{count}} of {{total}} models. Management request failed.",
|
|
26
|
+
restore_model: "Restore {{model}}",
|
|
27
|
+
disconnect: "Disconnect",
|
|
28
|
+
hide_model: "Hide {{model}}",
|
|
29
|
+
hidden_models: "Hidden models ({{count}})",
|
|
30
|
+
restore: "Restore",
|
|
31
|
+
restore_all: "Restore all",
|
|
32
|
+
cooling: "Rate limited until",
|
|
33
|
+
noModels: "Refresh models after connecting",
|
|
34
|
+
free: "free",
|
|
35
|
+
refresh: "Refresh status"
|
|
36
|
+
} },
|
|
37
|
+
zh: { qoder: {
|
|
38
|
+
title: "Qoder \u8BA2\u9605",
|
|
39
|
+
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",
|
|
40
|
+
cnTitle: "Qoder \u56FD\u5185\u7248\u8BA2\u9605",
|
|
41
|
+
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",
|
|
42
|
+
loading: "\u52A0\u8F7D\u4E2D\u2026",
|
|
43
|
+
not_connected: "\u672A\u8FDE\u63A5",
|
|
44
|
+
connected: "\u5DF2\u8FDE\u63A5",
|
|
45
|
+
expired: "\u767B\u5F55\u5DF2\u5931\u6548 \u2014 \u8BF7\u4F7F\u7528\u65B0\u7684 PAT \u91CD\u65B0\u8FDE\u63A5",
|
|
46
|
+
error: "\u7BA1\u7406\u64CD\u4F5C\u5931\u8D25",
|
|
47
|
+
account: "\u8D26\u53F7",
|
|
48
|
+
models: "\u6A21\u578B",
|
|
49
|
+
models_count: "{{count}} \u4E2A\u6A21\u578B",
|
|
50
|
+
pat: "\u4E2A\u4EBA\u8BBF\u95EE\u4EE4\u724C",
|
|
51
|
+
patPlaceholder: "pt-\u2026",
|
|
52
|
+
connect: "\u8FDE\u63A5",
|
|
53
|
+
sync: "\u5237\u65B0\u6A21\u578B",
|
|
54
|
+
usage: "\u5237\u65B0\u989D\u5EA6",
|
|
55
|
+
unknown: "\u989D\u5EA6\u672A\u77E5",
|
|
56
|
+
stale: "\u4E0A\u6B21\u989D\u5EA6\uFF08\u5DF2\u8FC7\u671F\uFF09",
|
|
57
|
+
used: "\u5DF2\u7528\u989D\u5EA6",
|
|
58
|
+
restore_partial: "\u90E8\u5206\u5B8C\u6210\uFF1A\u5DF2\u6062\u590D {{count}} \u4E2A\u6A21\u578B\uFF0C\u5171 {{total}} \u4E2A\u3002\u7BA1\u7406\u64CD\u4F5C\u5931\u8D25\u3002",
|
|
59
|
+
hide_partial: "\u90E8\u5206\u5B8C\u6210\uFF1A\u5DF2\u9690\u85CF {{count}} \u4E2A\u6A21\u578B\uFF0C\u5171 {{total}} \u4E2A\u3002\u7BA1\u7406\u64CD\u4F5C\u5931\u8D25\u3002",
|
|
60
|
+
restore_model: "\u6062\u590D {{model}}",
|
|
61
|
+
disconnect: "\u65AD\u5F00\u8FDE\u63A5",
|
|
62
|
+
hide_model: "\u9690\u85CF {{model}}",
|
|
63
|
+
hidden_models: "\u5DF2\u9690\u85CF\u6A21\u578B\uFF08{{count}}\uFF09",
|
|
64
|
+
restore: "\u6062\u590D",
|
|
65
|
+
restore_all: "\u6062\u590D\u5168\u90E8",
|
|
66
|
+
cooling: "\u9650\u6D41\u81F3",
|
|
67
|
+
noModels: "\u8FDE\u63A5\u540E\u8BF7\u5237\u65B0\u6A21\u578B",
|
|
68
|
+
free: "\u514D\u8D39",
|
|
69
|
+
refresh: "\u5237\u65B0\u72B6\u6001"
|
|
70
|
+
} }
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// src/protocol.ts
|
|
74
|
+
var CHAT_PATH = "/algo/api/v2/service/pro/sse/agent_chat_generation?FetchKeys=llm_model_result&AgentId=agent_common&Encode=1";
|
|
75
|
+
var QODER_SITES = Object.freeze([
|
|
76
|
+
{
|
|
77
|
+
id: "qoder",
|
|
78
|
+
label: "Qoder",
|
|
79
|
+
openapi: "https://openapi.qoder.sh",
|
|
80
|
+
api: "https://api3.qoder.sh",
|
|
81
|
+
web: "https://qoder.com",
|
|
82
|
+
chatUrl: `https://api3.qoder.sh${CHAT_PATH}`,
|
|
83
|
+
provider: "qoder-subscription",
|
|
84
|
+
marker: "ccr-managed-qoder",
|
|
85
|
+
credentialFile: "qoder-credentials.json",
|
|
86
|
+
routeBase: "/api/qoder"
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
id: "qoder-cn",
|
|
90
|
+
label: "Qoder CN",
|
|
91
|
+
openapi: "https://openapi.qoder.com.cn",
|
|
92
|
+
api: "https://gateway.qoder.com.cn",
|
|
93
|
+
web: "https://qoder.cn",
|
|
94
|
+
chatUrl: `https://gateway.qoder.com.cn${CHAT_PATH}`,
|
|
95
|
+
provider: "qoder-cn-subscription",
|
|
96
|
+
marker: "ccr-managed-qoder-cn",
|
|
97
|
+
credentialFile: "qoder-cn-credentials.json",
|
|
98
|
+
routeBase: "/api/qoder/cn"
|
|
99
|
+
}
|
|
100
|
+
]);
|
|
101
|
+
var QODER_CHAT = QODER_SITES[0].chatUrl;
|
|
102
|
+
var QODER_PROVIDER = QODER_SITES[0].provider;
|
|
103
|
+
var QODER_MARKER = QODER_SITES[0].marker;
|
|
104
|
+
function qoderSite(site) {
|
|
105
|
+
return QODER_SITES.find((s) => s.id === site) ?? QODER_SITES[0];
|
|
106
|
+
}
|
|
107
|
+
function qoderCardPrefix(site) {
|
|
108
|
+
const base = QODER_SITES[0].routeBase;
|
|
109
|
+
return qoderSite(site).routeBase.slice(base.length);
|
|
110
|
+
}
|
|
111
|
+
function isQoderManaged(p, site = "qoder") {
|
|
112
|
+
const managedBy = qoderSite(site);
|
|
113
|
+
const name = p?.name;
|
|
114
|
+
const owner = typeof name === "string" ? QODER_SITES.find((s) => s.provider === name) : void 0;
|
|
115
|
+
if (owner) {
|
|
116
|
+
return owner.id === managedBy.id && (p?.qoder_managed === true || p?.qoderManaged === true || (p?.api_key ?? p?.apiKey) === managedBy.marker);
|
|
117
|
+
}
|
|
118
|
+
if (managedBy.id !== QODER_SITES[0].id) return false;
|
|
119
|
+
return p?.qoder_managed === true || p?.qoderManaged === true;
|
|
120
|
+
}
|
|
121
|
+
function mergeQoderProvider(current, server, site = "qoder") {
|
|
122
|
+
const managedBy = qoderSite(site);
|
|
123
|
+
const next = { ...current };
|
|
124
|
+
const key = Array.isArray(current?.providers) && !Array.isArray(current?.Providers) ? "providers" : "Providers";
|
|
125
|
+
const managed = (server?.Providers ?? server?.providers ?? []).find((p) => isQoderManaged(p, managedBy.id));
|
|
126
|
+
const list = [...current?.[key] ?? []];
|
|
127
|
+
const at = list.findIndex((p) => isQoderManaged(p, managedBy.id));
|
|
128
|
+
if (at >= 0) {
|
|
129
|
+
if (managed) list[at] = managed;
|
|
130
|
+
else list.splice(at, 1);
|
|
131
|
+
} else if (managed && !list.some((p) => p?.name === managedBy.provider)) list.push(managed);
|
|
132
|
+
next[key] = list;
|
|
133
|
+
return next;
|
|
134
|
+
}
|
|
135
|
+
var RETRY_AFTER_MAX_MS = 30 * 6e4;
|
|
136
|
+
|
|
137
|
+
// src/card-entry.ts
|
|
138
|
+
var mounted = /* @__PURE__ */ new WeakMap();
|
|
139
|
+
function mount(el, props) {
|
|
140
|
+
unmount(el);
|
|
141
|
+
const lang = props.locale.toLowerCase().startsWith("zh") ? "zh" : "en";
|
|
142
|
+
const words = { ...qoderLocales[lang].qoder, ...props.locales?.[lang]?.qoder ?? {} };
|
|
143
|
+
const sections = QODER_SITES.map((site) => ({ site, prefix: qoderCardPrefix(site.id), error: false, busy: false, draft: "" }));
|
|
144
|
+
const panels = /* @__PURE__ */ new Map();
|
|
145
|
+
let disposed = false;
|
|
146
|
+
const abort = new AbortController();
|
|
147
|
+
const call = (section, path, body) => props.callApi(`${section.prefix}${path}`, {
|
|
148
|
+
method: body === void 0 ? "GET" : "POST",
|
|
149
|
+
...body === void 0 ? {} : { body: JSON.stringify(body) },
|
|
150
|
+
signal: abort.signal,
|
|
151
|
+
timeoutMs: 45e3
|
|
152
|
+
});
|
|
153
|
+
const node = (tag, text, cls = "") => {
|
|
154
|
+
const n = document.createElement(tag);
|
|
155
|
+
n.textContent = text;
|
|
156
|
+
n.className = cls;
|
|
157
|
+
return n;
|
|
158
|
+
};
|
|
159
|
+
const CHIP = "inline-flex max-w-full items-center gap-1 break-all whitespace-normal rounded-lg border border-white/5 bg-white/5 px-2 py-0.5 text-[10px] font-bold text-muted-foreground";
|
|
160
|
+
const CHIP_HIDE = "rounded p-0.5 text-muted-foreground/60 transition-colors hover:bg-white/10 hover:text-foreground disabled:opacity-40";
|
|
161
|
+
const BUTTON_BASE = "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xl text-sm font-semibold ring-offset-background transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 active:scale-95";
|
|
162
|
+
const BUTTON_OUTLINE = "border border-input bg-input/50 backdrop-blur-md shadow-sm hover:bg-input/80 hover:border-primary/50 hover:shadow-md hover:text-accent-foreground";
|
|
163
|
+
const xIcon = (className) => {
|
|
164
|
+
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
165
|
+
svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
|
|
166
|
+
svg.setAttribute("width", "24");
|
|
167
|
+
svg.setAttribute("height", "24");
|
|
168
|
+
svg.setAttribute("viewBox", "0 0 24 24");
|
|
169
|
+
svg.setAttribute("fill", "none");
|
|
170
|
+
svg.setAttribute("stroke", "currentColor");
|
|
171
|
+
svg.setAttribute("stroke-width", "2");
|
|
172
|
+
svg.setAttribute("stroke-linecap", "round");
|
|
173
|
+
svg.setAttribute("stroke-linejoin", "round");
|
|
174
|
+
svg.setAttribute("aria-hidden", "true");
|
|
175
|
+
svg.setAttribute("class", className);
|
|
176
|
+
for (const d of ["M18 6 6 18", "m6 6 12 12"]) {
|
|
177
|
+
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
178
|
+
path.setAttribute("d", d);
|
|
179
|
+
svg.appendChild(path);
|
|
180
|
+
}
|
|
181
|
+
return svg;
|
|
182
|
+
};
|
|
183
|
+
const format = (key, vars = {}) => String(words[key] ?? key).replace(/\{\{(\w+)\}\}/g, (_, name) => String(vars[name] ?? ""));
|
|
184
|
+
const sectionBusy = () => disposed || props.disabled || sections.some((s) => s.busy);
|
|
185
|
+
const sectionWords = (section) => section.site.id === "qoder" ? { title: words.title, description: words.description } : { title: words.cnTitle, description: words.cnDescription };
|
|
186
|
+
const finish = (section) => {
|
|
187
|
+
section.busy = false;
|
|
188
|
+
section.pendingModel = null;
|
|
189
|
+
section.release?.();
|
|
190
|
+
section.release = void 0;
|
|
191
|
+
props.onBusy?.(false);
|
|
192
|
+
if (!disposed) render();
|
|
193
|
+
};
|
|
194
|
+
const refresh = async (section) => {
|
|
195
|
+
section.status = await call(section, "/status");
|
|
196
|
+
if (!disposed) render();
|
|
197
|
+
};
|
|
198
|
+
const reconcile = (section) => props.refreshConfig?.((current, server) => mergeQoderProvider(current, server, section.site.id));
|
|
199
|
+
const run = async (section, fn) => {
|
|
200
|
+
if (disposed || section.busy || props.disabled || !props.acquireManagement) return;
|
|
201
|
+
const lease = props.acquireManagement();
|
|
202
|
+
if (!lease) return;
|
|
203
|
+
section.release = lease;
|
|
204
|
+
section.busy = true;
|
|
205
|
+
section.error = false;
|
|
206
|
+
section.errorMessage = void 0;
|
|
207
|
+
props.onBusy?.(true);
|
|
208
|
+
render();
|
|
209
|
+
try {
|
|
210
|
+
await fn();
|
|
211
|
+
} catch {
|
|
212
|
+
section.error = true;
|
|
213
|
+
} finally {
|
|
214
|
+
finish(section);
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
const connect = (section, input) => void run(section, async () => {
|
|
218
|
+
const pat = input.value;
|
|
219
|
+
try {
|
|
220
|
+
await call(section, "/connect", { pat });
|
|
221
|
+
input.value = "";
|
|
222
|
+
section.draft = "";
|
|
223
|
+
await reconcile(section);
|
|
224
|
+
await refresh(section);
|
|
225
|
+
} catch (e) {
|
|
226
|
+
input.value = "";
|
|
227
|
+
section.draft = "";
|
|
228
|
+
throw e;
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
const action = (section, label, fn) => {
|
|
232
|
+
const button = node("button", words[label], "px-3 py-2 rounded-md bg-secondary text-secondary-foreground hover:bg-secondary/80 disabled:opacity-50");
|
|
233
|
+
button.disabled = sectionBusy();
|
|
234
|
+
button.onclick = fn;
|
|
235
|
+
return button;
|
|
236
|
+
};
|
|
237
|
+
const smallButton = (label, extra, fn) => {
|
|
238
|
+
const button = node("button", label, `${BUTTON_BASE} ${BUTTON_OUTLINE} ${extra}`);
|
|
239
|
+
button.type = "button";
|
|
240
|
+
button.disabled = sectionBusy();
|
|
241
|
+
button.onclick = fn;
|
|
242
|
+
return button;
|
|
243
|
+
};
|
|
244
|
+
const setModelVisibility = (section, ids, hidden) => void run(section, async () => {
|
|
245
|
+
let completed = 0;
|
|
246
|
+
try {
|
|
247
|
+
for (const id of ids) {
|
|
248
|
+
await call(section, "/models/visibility", { model: id, hidden });
|
|
249
|
+
completed += 1;
|
|
250
|
+
}
|
|
251
|
+
} catch (error) {
|
|
252
|
+
if (completed > 0) section.errorMessage = format(hidden ? "hide_partial" : "restore_partial", { count: completed, total: ids.length });
|
|
253
|
+
throw error;
|
|
254
|
+
} finally {
|
|
255
|
+
try {
|
|
256
|
+
await reconcile(section);
|
|
257
|
+
} finally {
|
|
258
|
+
await refresh(section);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
const renderSection = (section) => {
|
|
263
|
+
const panel = node("div", "", "space-y-3 border-t border-border pt-4 first:border-t-0 first:pt-0");
|
|
264
|
+
panel.append(
|
|
265
|
+
node("h3", sectionWords(section).title, "font-semibold text-lg"),
|
|
266
|
+
node("p", sectionWords(section).description, "text-sm text-muted-foreground")
|
|
267
|
+
);
|
|
268
|
+
panel.append(node("p", section.status ? words[section.status.status] ?? words.error : words.loading));
|
|
269
|
+
if (section.status?.expired) panel.append(node("p", words.expired, "text-destructive"));
|
|
270
|
+
if (section.status?.account) panel.append(node("p", `${words.account}: ${section.status.account}`));
|
|
271
|
+
if (section.error) panel.append(node("p", section.errorMessage ?? words.error, "text-destructive"));
|
|
272
|
+
const buttons = node("div", "", "flex flex-wrap gap-2");
|
|
273
|
+
if (section.status?.status === "not_connected") {
|
|
274
|
+
const input = node("input", "", "w-full rounded-md border border-input bg-background px-3 py-2 text-sm");
|
|
275
|
+
input.type = "password";
|
|
276
|
+
input.placeholder = words.patPlaceholder;
|
|
277
|
+
input.autocomplete = "off";
|
|
278
|
+
input.setAttribute("aria-label", words.pat);
|
|
279
|
+
input.value = section.draft;
|
|
280
|
+
input.oninput = () => {
|
|
281
|
+
section.draft = input.value;
|
|
282
|
+
};
|
|
283
|
+
const connectButton = action(section, "connect", () => connect(section, input));
|
|
284
|
+
input.onkeydown = (e) => {
|
|
285
|
+
if (e.key === "Enter") connect(section, input);
|
|
286
|
+
};
|
|
287
|
+
panel.append(node("p", words.pat, "text-sm font-medium"), input);
|
|
288
|
+
buttons.append(connectButton);
|
|
289
|
+
} else if (section.status) {
|
|
290
|
+
buttons.append(action(section, "sync", () => void run(section, async () => {
|
|
291
|
+
await call(section, "/sync", {});
|
|
292
|
+
await reconcile(section);
|
|
293
|
+
await refresh(section);
|
|
294
|
+
})));
|
|
295
|
+
buttons.append(action(section, "usage", () => void run(section, async () => {
|
|
296
|
+
section.usage = await call(section, "/usage", {});
|
|
297
|
+
})));
|
|
298
|
+
buttons.append(action(section, "disconnect", () => void run(section, async () => {
|
|
299
|
+
await call(section, "/disconnect", {});
|
|
300
|
+
section.usage = void 0;
|
|
301
|
+
await reconcile(section);
|
|
302
|
+
await refresh(section);
|
|
303
|
+
})));
|
|
304
|
+
}
|
|
305
|
+
buttons.append(action(section, "refresh", () => void run(section, () => refresh(section))));
|
|
306
|
+
panel.append(buttons);
|
|
307
|
+
const usageLine = node("p", "", "text-sm text-muted-foreground");
|
|
308
|
+
if (section.usage?.known && Array.isArray(section.usage.windows) && section.usage.windows.length) {
|
|
309
|
+
usageLine.textContent = `${section.usage.stale ? words.stale : words.used}: ` + section.usage.windows.map((w) => {
|
|
310
|
+
const name = typeof w?.name === "string" ? w.name.slice(0, 100) : "?";
|
|
311
|
+
const detail = typeof w?.display === "string" && w.display ? w.display : Number.isFinite(w?.used) ? `${Math.round(w.used)}%` : "?";
|
|
312
|
+
return `${name}: ${detail}`;
|
|
313
|
+
}).join(" \xB7 ");
|
|
314
|
+
} else usageLine.textContent = words.unknown;
|
|
315
|
+
panel.append(usageLine);
|
|
316
|
+
if (section.status?.coolingUntil > Date.now()) panel.append(node("p", `${words.cooling}: ${new Date(section.status.coolingUntil).toLocaleString()}`, "text-sm text-muted-foreground"));
|
|
317
|
+
const models = Array.isArray(section.status?.models) ? section.status.models : [];
|
|
318
|
+
const visible = models.filter((model) => !model.hidden);
|
|
319
|
+
const hiddenModels = models.filter((model) => model.hidden);
|
|
320
|
+
if (section.status?.status === "connected" || models.length) {
|
|
321
|
+
const count = node("dl", "", "grid max-w-md grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1 text-xs");
|
|
322
|
+
count.append(
|
|
323
|
+
node("dt", words.models, "text-muted-foreground"),
|
|
324
|
+
node("dd", format("models_count", { count: section.status?.modelCount ?? models.length }), "break-all text-foreground")
|
|
325
|
+
);
|
|
326
|
+
panel.append(count);
|
|
327
|
+
}
|
|
328
|
+
if (!models.length) panel.append(node("p", words.noModels, "text-sm text-muted-foreground"));
|
|
329
|
+
if (visible.length) {
|
|
330
|
+
const chips = node("div", "", "flex flex-wrap gap-2 pt-1");
|
|
331
|
+
for (const model of visible) {
|
|
332
|
+
const id = String(model.id);
|
|
333
|
+
const chip = node("span", id, CHIP);
|
|
334
|
+
if (model.context) chip.append(node("span", String(model.context), "font-medium text-muted-foreground/60"));
|
|
335
|
+
if (model.free) chip.append(node("span", words.free, "font-medium text-muted-foreground/60"));
|
|
336
|
+
const hide = node("button", "", CHIP_HIDE);
|
|
337
|
+
hide.type = "button";
|
|
338
|
+
hide.disabled = sectionBusy();
|
|
339
|
+
hide.setAttribute("aria-label", format("hide_model", { model: id }));
|
|
340
|
+
hide.setAttribute("aria-busy", String(section.busy && section.pendingModel === id));
|
|
341
|
+
hide.append(xIcon("h-3 w-3"));
|
|
342
|
+
hide.onclick = () => {
|
|
343
|
+
section.pendingModel = id;
|
|
344
|
+
setModelVisibility(section, [id], true);
|
|
345
|
+
};
|
|
346
|
+
chip.append(hide);
|
|
347
|
+
chips.append(chip);
|
|
348
|
+
}
|
|
349
|
+
panel.append(chips);
|
|
350
|
+
}
|
|
351
|
+
if (hiddenModels.length) {
|
|
352
|
+
const details = node("details", "", "max-w-md text-xs text-muted-foreground");
|
|
353
|
+
details.append(node("summary", format("hidden_models", { count: hiddenModels.length }), "cursor-pointer select-none font-medium"));
|
|
354
|
+
const list = node("ul", "", "mt-2 space-y-1");
|
|
355
|
+
for (const model of hiddenModels) {
|
|
356
|
+
const id = String(model.id);
|
|
357
|
+
const item = node("li", "", "flex items-center justify-between gap-2");
|
|
358
|
+
item.append(node("span", id, "break-all font-mono"));
|
|
359
|
+
const restore = smallButton(
|
|
360
|
+
words.restore,
|
|
361
|
+
"h-6 shrink-0 px-2 text-[11px]",
|
|
362
|
+
() => {
|
|
363
|
+
section.pendingModel = id;
|
|
364
|
+
setModelVisibility(section, [id], false);
|
|
365
|
+
}
|
|
366
|
+
);
|
|
367
|
+
restore.setAttribute("aria-label", format("restore_model", { model: id }));
|
|
368
|
+
item.append(restore);
|
|
369
|
+
list.append(item);
|
|
370
|
+
}
|
|
371
|
+
details.append(list);
|
|
372
|
+
details.append(smallButton(
|
|
373
|
+
words.restore_all,
|
|
374
|
+
"mt-2 h-7 px-2 text-[11px]",
|
|
375
|
+
() => setModelVisibility(section, hiddenModels.map((model) => String(model.id)), false)
|
|
376
|
+
));
|
|
377
|
+
panel.append(details);
|
|
378
|
+
}
|
|
379
|
+
return panel;
|
|
380
|
+
};
|
|
381
|
+
const render = () => {
|
|
382
|
+
if (disposed) return;
|
|
383
|
+
el.replaceChildren();
|
|
384
|
+
panels.clear();
|
|
385
|
+
const card = node("section", "", "glass-card rounded-xl p-5 space-y-4");
|
|
386
|
+
for (const section of sections) {
|
|
387
|
+
const panel = renderSection(section);
|
|
388
|
+
panels.set(section.site.id, panel);
|
|
389
|
+
card.append(panel);
|
|
390
|
+
}
|
|
391
|
+
el.append(card);
|
|
392
|
+
};
|
|
393
|
+
const focus = () => {
|
|
394
|
+
const section = sections.find((s) => props.focusKey === s.site.provider || props.focusKey === s.site.id);
|
|
395
|
+
const panel = section && panels.get(section.site.id);
|
|
396
|
+
if (!panel) return;
|
|
397
|
+
panel.tabIndex = -1;
|
|
398
|
+
panel.focus();
|
|
399
|
+
panel.scrollIntoView?.({ block: "nearest" });
|
|
400
|
+
};
|
|
401
|
+
const load = (section) => void refresh(section).catch(() => {
|
|
402
|
+
section.error = true;
|
|
403
|
+
if (!disposed) render();
|
|
404
|
+
});
|
|
405
|
+
const off = props.onHostChange?.(render);
|
|
406
|
+
mounted.set(el, () => {
|
|
407
|
+
disposed = true;
|
|
408
|
+
off?.();
|
|
409
|
+
abort.abort();
|
|
410
|
+
});
|
|
411
|
+
render();
|
|
412
|
+
focus();
|
|
413
|
+
for (const section of sections) load(section);
|
|
414
|
+
}
|
|
415
|
+
function unmount(el) {
|
|
416
|
+
mounted.get(el)?.();
|
|
417
|
+
mounted.delete(el);
|
|
418
|
+
el.replaceChildren();
|
|
419
|
+
}
|
|
420
|
+
var card_entry_default = { mount, unmount };
|
|
421
|
+
export {
|
|
422
|
+
card_entry_default as default,
|
|
423
|
+
mount,
|
|
424
|
+
unmount
|
|
425
|
+
};
|