@magpie-community/opencode-qoder-auth 0.1.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/LICENSE-CLIProxyAPI +22 -0
- package/README.md +96 -0
- package/index.mjs +850 -0
- package/package.json +11 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025-2005.9 Luis Pater
|
|
4
|
+
Copyright (c) 2025.9-present Router-For.ME
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# @magpie-community/opencode-qoder-auth
|
|
2
|
+
|
|
3
|
+
Your [Qoder](https://qoder.com) subscription in OpenCode and
|
|
4
|
+
[magpie](https://usemagpie.ai), with provider id `qoder`.
|
|
5
|
+
|
|
6
|
+
## Sign-in
|
|
7
|
+
|
|
8
|
+
**Sign in with Qoder** is the sign-in Qoder's desktop client uses:
|
|
9
|
+
|
|
10
|
+
1. A PKCE device flow opens qoder.com's account page.
|
|
11
|
+
2. openapi.qoder.sh is polled every 2 s until you authorize, for at most
|
|
12
|
+
15 minutes.
|
|
13
|
+
3. The device token is traded for a job token, and the account's email and
|
|
14
|
+
name are read.
|
|
15
|
+
|
|
16
|
+
What is kept: the job token, its refresh token and expiry, the uid, the
|
|
17
|
+
device token and a machine id made for this sign-in. OpenCode keeps them in
|
|
18
|
+
`auth.json`; magpie keeps them in `plugin-auth.json`.
|
|
19
|
+
|
|
20
|
+
The job token is refreshed 5 minutes before it runs out. Qoder spends a
|
|
21
|
+
refresh token once, so the new pair is saved straight away, and refreshes
|
|
22
|
+
never run at the same time. When Qoder refuses a refresh (401 or 403), the
|
|
23
|
+
request says to sign in again.
|
|
24
|
+
|
|
25
|
+
## Requests
|
|
26
|
+
|
|
27
|
+
Qoder serves its models on the API its client talks to:
|
|
28
|
+
`api3.qoder.sh/algo/api/v2/service/pro/sse/agent_chat_generation`. The
|
|
29
|
+
plugin's `fetch` takes the chat completion OpenCode sends and does the rest
|
|
30
|
+
itself.
|
|
31
|
+
|
|
32
|
+
**Writing the request**
|
|
33
|
+
|
|
34
|
+
- It writes the chat completion as Qoder's request:
|
|
35
|
+
- Qoder's own system line comes before yours.
|
|
36
|
+
- Messages become text and image blocks.
|
|
37
|
+
- Tool calls and results become OpenAI tool turns. Images a tool returned
|
|
38
|
+
follow in a user turn.
|
|
39
|
+
- Your tools become Qoder's native function tools.
|
|
40
|
+
- The model's own configuration from Qoder's list goes with it.
|
|
41
|
+
- It picks the reasoning effort from the model's own levels:
|
|
42
|
+
- The nearest level to the one asked, the higher one on a tie.
|
|
43
|
+
- The model's default when none is asked.
|
|
44
|
+
- The lowest level for "none", when the model can't turn thinking off.
|
|
45
|
+
- It encodes the body with the client's codec and signs the call with the
|
|
46
|
+
client's COSY envelope. The envelope carries:
|
|
47
|
+
- the account, AES-encrypted, with the key wrapped by Qoder's RSA key;
|
|
48
|
+
- an MD5 signature;
|
|
49
|
+
- the machine id and the client's headers.
|
|
50
|
+
|
|
51
|
+
**Reading the reply**
|
|
52
|
+
|
|
53
|
+
- Qoder's SSE comes back as a chat completion, streamed or not, with
|
|
54
|
+
reasoning and usage.
|
|
55
|
+
- Tool calls Qoder writes as XML or JSON in its text become tool calls.
|
|
56
|
+
Tool calls it sends the OpenAI way keep Qoder's id.
|
|
57
|
+
|
|
58
|
+
**Errors**
|
|
59
|
+
|
|
60
|
+
| What Qoder says | What the request returns |
|
|
61
|
+
|---|---|
|
|
62
|
+
| A refused sign-in | 401 |
|
|
63
|
+
| A quota | 429 |
|
|
64
|
+
| A failure before the answer starts | Qoder's status |
|
|
65
|
+
| A failure after the answer has started | The stream ends with an error |
|
|
66
|
+
|
|
67
|
+
## Models
|
|
68
|
+
|
|
69
|
+
The `provider.models` hook reads the account's own list, as Qoder's client
|
|
70
|
+
asks for it (`/algo/api/v2/model/list`). It keeps the enabled chat models
|
|
71
|
+
and leaves out "auto" and "default", which route inside Qoder. It takes
|
|
72
|
+
each model's reasoning levels from its `thinking_config`.
|
|
73
|
+
|
|
74
|
+
The `config` hook declares the list as it was on 2026-09-30:
|
|
75
|
+
|
|
76
|
+
- Ultimate, Performance, Efficient
|
|
77
|
+
- Sonus, Cantus
|
|
78
|
+
- Qwen3.8-Max, Qwen3.8-Flash, Qwen3.7-Max, Qwen3.7-Plus
|
|
79
|
+
- Kimi-K3, Kimi-K2.8-Preview
|
|
80
|
+
- GLM-5.3, GLM-5.3-Flash
|
|
81
|
+
- DeepSeek-V4-Pro, DeepSeek-Flash
|
|
82
|
+
- MiniMax-M3
|
|
83
|
+
|
|
84
|
+
Every model speaks chat completions (`@ai-sdk/openai-compatible`).
|
|
85
|
+
|
|
86
|
+
## Not here
|
|
87
|
+
|
|
88
|
+
- Qoder's usage and quota display.
|
|
89
|
+
- Several accounts at once.
|
|
90
|
+
- magpie's web-search stand-in.
|
|
91
|
+
|
|
92
|
+
## Credits
|
|
93
|
+
|
|
94
|
+
The protocol (endpoints, COSY envelope, body codec and device flow) comes
|
|
95
|
+
from [CLIProxyAPI](https://github.com/ufec/CLIProxyAPI)'s Qoder support, by
|
|
96
|
+
way of magpie. Its MIT license is in `LICENSE-CLIProxyAPI`.
|
package/index.mjs
ADDED
|
@@ -0,0 +1,850 @@
|
|
|
1
|
+
// Qoder's subscription (qoder.com) as an OpenCode provider plugin.
|
|
2
|
+
//
|
|
3
|
+
// Qoder is signed in to the way its desktop client is: a PKCE device flow
|
|
4
|
+
// (qoder.com's page, then a poll of openapi.qoder.sh for the device token),
|
|
5
|
+
// whose device token is traded for the job token the model calls use. The
|
|
6
|
+
// job token is refreshed with its refresh token before it runs out; Qoder
|
|
7
|
+
// spends a refresh token once, so the new pair is saved straight away.
|
|
8
|
+
//
|
|
9
|
+
// Qoder's models are served on the API its client talks to, api3.qoder.sh's
|
|
10
|
+
// agent_chat_generation SSE, signed with the client's COSY envelope and sent
|
|
11
|
+
// in its body codec. The fetch here takes the chat completion OpenCode sends,
|
|
12
|
+
// writes it as Qoder's request, and turns Qoder's reply back into one; tool
|
|
13
|
+
// calls Qoder writes as XML in its text are lifted out as tool calls.
|
|
14
|
+
//
|
|
15
|
+
// The protocol (endpoints, COSY envelope, body codec, device flow) is ported
|
|
16
|
+
// from magpie's internal/qoder, which ported it from CLIProxyAPI's qoder
|
|
17
|
+
// support (https://github.com/ufec/CLIProxyAPI, MIT).
|
|
18
|
+
import { createCipheriv, createHash, publicEncrypt, randomBytes, randomUUID, constants } from "node:crypto"
|
|
19
|
+
|
|
20
|
+
const ID = "qoder"
|
|
21
|
+
const CLIENT_ID = "732aef47-9cf2-46a2-95fe-4cebb5d0d1fa"
|
|
22
|
+
const DEVICE_HOST = "https://qoder.com"
|
|
23
|
+
const OPENAPI = "https://openapi.qoder.sh"
|
|
24
|
+
const API = "https://api3.qoder.sh"
|
|
25
|
+
const REDIRECT = "qoder-app://"
|
|
26
|
+
const CHAT_URL = API + "/algo/api/v2/service/pro/sse/agent_chat_generation?FetchKeys=llm_model_result&AgentId=agent_common&Encode=1"
|
|
27
|
+
const MODELS_URL = API + "/algo/api/v2/model/list?Encode=1"
|
|
28
|
+
const COSY_VERSION = "1.1.49"
|
|
29
|
+
const SIGN_IN_TIMEOUT = 15 * 60 * 1000
|
|
30
|
+
const REFRESH_LEAD = 5 * 60 * 1000
|
|
31
|
+
const DAY = 24 * 60 * 60 * 1000
|
|
32
|
+
const CHAT = "@ai-sdk/openai-compatible"
|
|
33
|
+
|
|
34
|
+
// Qoder's embedded 1024-bit RSA key (from the client's cosy source): it wraps
|
|
35
|
+
// the per-request AES key of the COSY Authorization header.
|
|
36
|
+
const RSA_KEY = `-----BEGIN PUBLIC KEY-----
|
|
37
|
+
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDA8iMH5c02LilrsERw9t6Pv5Nc
|
|
38
|
+
4k6Pz1EaDicBMpdpxKduSZu5OANqUq8er4GM95omAGIOPOh+Nx0spthYA2BqGz+l
|
|
39
|
+
6HRkPJ7S236FZz73In/KVuLnwI8JJ2CbuJap8kvheCCZpmAWpb/cPx/3Vr/J6I17
|
|
40
|
+
XcW+ML9FoCI6AOvOzwIDAQAB
|
|
41
|
+
-----END PUBLIC KEY-----`
|
|
42
|
+
|
|
43
|
+
const QODER_SYS = "You are a Qoder agent. Use the instructions below and the tools available to you to assist the user."
|
|
44
|
+
|
|
45
|
+
const hexID = () => randomUUID().replaceAll("-", "")
|
|
46
|
+
|
|
47
|
+
// ---- the COSY envelope --------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
// machineOS names the machine as Qoder's desktop clients do.
|
|
50
|
+
function machineOS() {
|
|
51
|
+
const arch = { x64: "x86_64", ia32: "x86", arm64: "aarch64" }[process.arch] ?? process.arch
|
|
52
|
+
return arch + "_" + process.platform
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// userBlob is the account as the envelope carries it: AES-128-CBC under a
|
|
56
|
+
// fresh key that is its own IV, the key wrapped with Qoder's RSA key.
|
|
57
|
+
function userBlob(u) {
|
|
58
|
+
const raw = JSON.stringify({ uid: u.uid, aid: "", name: u.name ?? "", email: u.email ?? "", security_oauth_token: u.access })
|
|
59
|
+
const key = Buffer.from(hexID().slice(0, 16))
|
|
60
|
+
const c = createCipheriv("aes-128-cbc", key, key)
|
|
61
|
+
const info = Buffer.concat([c.update(raw, "utf8"), c.final()]).toString("base64")
|
|
62
|
+
const wrapped = publicEncrypt({ key: RSA_KEY, padding: constants.RSA_PKCS1_PADDING }, key).toString("base64")
|
|
63
|
+
return { info, key: wrapped }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// cosyHeaders signs a call to url whose body, in its wire form, is body.
|
|
67
|
+
function cosyHeaders(url, u, body, ts = Math.floor(Date.now() / 1000)) {
|
|
68
|
+
if (!u?.machineId) throw new Error("Qoder: the account has no machine id; sign in again")
|
|
69
|
+
const blob = userBlob(u)
|
|
70
|
+
const payload = Buffer.from(
|
|
71
|
+
JSON.stringify({ version: "v1", requestId: hexID(), info: blob.info, cosyVersion: COSY_VERSION, ideVersion: "" }),
|
|
72
|
+
).toString("base64")
|
|
73
|
+
let path = new URL(url).pathname
|
|
74
|
+
if (path.startsWith("/algo")) path = path.slice(5)
|
|
75
|
+
const sig = createHash("md5").update(`${payload}\n${blob.key}\n${ts}\n${body}\n${path}`).digest("hex")
|
|
76
|
+
const m = u.machineId
|
|
77
|
+
return {
|
|
78
|
+
Accept: "application/json",
|
|
79
|
+
"Accept-Encoding": "identity",
|
|
80
|
+
"Content-Type": "application/json",
|
|
81
|
+
Authorization: `Bearer COSY.${payload}.${sig}`,
|
|
82
|
+
"Cosy-Business-Product": "app",
|
|
83
|
+
"Cosy-Business-Type": "agent",
|
|
84
|
+
"Cosy-ClientIp": m,
|
|
85
|
+
"Cosy-ClientType": "10",
|
|
86
|
+
"Cosy-Data-Policy": "disagree",
|
|
87
|
+
"Cosy-Date": String(ts),
|
|
88
|
+
"Cosy-Key": blob.key,
|
|
89
|
+
"Cosy-MachineId": m,
|
|
90
|
+
"Cosy-MachineToken": m,
|
|
91
|
+
"Cosy-MachineType": "5",
|
|
92
|
+
"Cosy-MachineOS": machineOS(),
|
|
93
|
+
"Cosy-Scene": "app",
|
|
94
|
+
"Cosy-User": u.uid,
|
|
95
|
+
"Cosy-Version": COSY_VERSION,
|
|
96
|
+
"Login-Version": "v2",
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---- the body codec -----------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
// Base64 through the client's shuffled alphabet ('$' pads), then the first
|
|
103
|
+
// and last thirds swapped.
|
|
104
|
+
const ALPHABET = "_doRTgHZBKcGVjlvpC,@aFSx#DPuNJme&i*MzLOEn)sUrthbf%Y^w.(kIQyXqWA!"
|
|
105
|
+
|
|
106
|
+
function segmentEncode(bytes) {
|
|
107
|
+
let s = ""
|
|
108
|
+
let acc = 0
|
|
109
|
+
let nb = 0
|
|
110
|
+
for (const b of bytes) {
|
|
111
|
+
acc = ((acc << 8) | b) & 0xffffff
|
|
112
|
+
nb += 8
|
|
113
|
+
while (nb >= 6) {
|
|
114
|
+
nb -= 6
|
|
115
|
+
s += ALPHABET[(acc >> nb) & 63]
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (nb > 0) s += ALPHABET[(acc << (6 - nb)) & 63]
|
|
119
|
+
while (s.length % 4) s += "$"
|
|
120
|
+
return s
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function swapThirds(s) {
|
|
124
|
+
const t = Math.floor(s.length / 3)
|
|
125
|
+
return t ? s.slice(s.length - t) + s.slice(t, s.length - t) + s.slice(0, t) : s
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const encodeBody = (text) => swapThirds(segmentEncode(Buffer.from(text, "utf8")))
|
|
129
|
+
|
|
130
|
+
function decodeBody(wire) {
|
|
131
|
+
const s = swapThirds(wire)
|
|
132
|
+
const out = []
|
|
133
|
+
for (let g = 0; g + 4 <= s.length; g += 4) {
|
|
134
|
+
const vals = [...s.slice(g, g + 4)].filter((c) => c !== "$").map((c) => ALPHABET.indexOf(c))
|
|
135
|
+
let x = 0
|
|
136
|
+
for (const v of vals) x = x * 64 + v
|
|
137
|
+
const n = Math.floor((6 * vals.length) / 8)
|
|
138
|
+
for (let k = 0; k < n; k++) out.push(Math.floor(x / 2 ** (6 * vals.length - 8 * (k + 1))) & 255)
|
|
139
|
+
}
|
|
140
|
+
return Buffer.from(out).toString("utf8")
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---- sign-in ------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
const b64url = (buf) => buf.toString("base64url")
|
|
146
|
+
|
|
147
|
+
async function openapi(path, { method = "GET", token, body, query } = {}) {
|
|
148
|
+
const url = new URL(OPENAPI + path)
|
|
149
|
+
for (const [k, v] of Object.entries(query ?? {})) url.searchParams.set(k, v)
|
|
150
|
+
const headers = { Accept: "application/json" }
|
|
151
|
+
if (body) headers["Content-Type"] = "application/json"
|
|
152
|
+
if (token) headers.Authorization = `Bearer ${token}`
|
|
153
|
+
const res = await fetch(url, { method, headers, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(20_000) })
|
|
154
|
+
const text = await res.text()
|
|
155
|
+
let json = null
|
|
156
|
+
try {
|
|
157
|
+
json = JSON.parse(text)
|
|
158
|
+
} catch {}
|
|
159
|
+
return { status: res.status, json, text }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// expiresAt is when a job token Qoder gave with expires_in (milliseconds)
|
|
163
|
+
// runs out; a day when it said none.
|
|
164
|
+
const expiresAt = (jt) => Date.now() + (jt.expires_in > 0 ? jt.expires_in : DAY)
|
|
165
|
+
|
|
166
|
+
async function deviceSignIn() {
|
|
167
|
+
const verifier = b64url(randomBytes(64))
|
|
168
|
+
const challenge = b64url(createHash("sha256").update(verifier).digest())
|
|
169
|
+
const nonce = randomUUID()
|
|
170
|
+
const machineId = randomUUID()
|
|
171
|
+
const q = new URLSearchParams({ challenge, challenge_method: "S256", nonce, machine_id: machineId, client_id: CLIENT_ID, redirect_uri: REDIRECT })
|
|
172
|
+
const until = Date.now() + SIGN_IN_TIMEOUT
|
|
173
|
+
return {
|
|
174
|
+
url: `${DEVICE_HOST}/device/selectAccounts?${q}`,
|
|
175
|
+
instructions: "Sign in to Qoder in the browser and authorize this device",
|
|
176
|
+
method: "auto",
|
|
177
|
+
async callback() {
|
|
178
|
+
let dt
|
|
179
|
+
for (;;) {
|
|
180
|
+
if (Date.now() > until) throw new Error("Qoder: the sign-in timed out; start again")
|
|
181
|
+
try {
|
|
182
|
+
const r = await openapi("/api/v1/deviceToken/poll", { query: { nonce, verifier, challenge_method: "S256" } })
|
|
183
|
+
if (r.status === 200 && String(r.json?.token ?? "").trim()) {
|
|
184
|
+
dt = r.json
|
|
185
|
+
break
|
|
186
|
+
}
|
|
187
|
+
} catch {} // a hiccup: ask again
|
|
188
|
+
await new Promise((r) => setTimeout(r, 2000))
|
|
189
|
+
}
|
|
190
|
+
const jr = await openapi("/api/v1/me/jobToken", { method: "POST", token: dt.token, body: { clientId: CLIENT_ID } })
|
|
191
|
+
if (jr.status !== 200) throw new Error(`Qoder job token: status ${jr.status}: ${jr.text.trim().slice(0, 300)}`)
|
|
192
|
+
if (!String(jr.json?.token ?? "").trim()) throw new Error("Qoder job token: empty token in response")
|
|
193
|
+
let email = ""
|
|
194
|
+
let name = ""
|
|
195
|
+
try {
|
|
196
|
+
const ui = await openapi("/api/v1/userinfo", { token: dt.token })
|
|
197
|
+
if (ui.status >= 200 && ui.status < 300) [email, name] = [ui.json?.email ?? "", ui.json?.name ?? ""]
|
|
198
|
+
} catch {}
|
|
199
|
+
return {
|
|
200
|
+
type: "success",
|
|
201
|
+
access: jr.json.token,
|
|
202
|
+
refresh: jr.json.refresh_token ?? "",
|
|
203
|
+
expires: expiresAt(jr.json),
|
|
204
|
+
accountId: email || dt.user_id,
|
|
205
|
+
uid: dt.user_id,
|
|
206
|
+
email,
|
|
207
|
+
name,
|
|
208
|
+
machineId,
|
|
209
|
+
deviceToken: dt.token,
|
|
210
|
+
deviceRefresh: dt.refresh_token ?? "",
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
class SignInGone extends Error {}
|
|
217
|
+
|
|
218
|
+
// refreshJob trades the job token's refresh token for a new pair; the old
|
|
219
|
+
// one is spent.
|
|
220
|
+
async function refreshJob(refresh) {
|
|
221
|
+
if (!String(refresh ?? "").trim()) throw new SignInGone("Qoder: the sign-in lapsed; sign in again")
|
|
222
|
+
const r = await openapi("/api/v1/jobToken/refresh", { method: "POST", body: { refresh_token: refresh } })
|
|
223
|
+
if (r.status === 401 || r.status === 403) throw new SignInGone("Qoder: the sign-in has expired — sign in again")
|
|
224
|
+
if (r.status !== 200) throw new Error(`Qoder job token refresh: status ${r.status}`)
|
|
225
|
+
if (!String(r.json?.token ?? "").trim() || !String(r.json?.refresh_token ?? "").trim())
|
|
226
|
+
throw new Error("Qoder job token refresh: incomplete token pair")
|
|
227
|
+
return r.json
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ---- models -------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
const EFFORT_ORDER = ["minimal", "low", "medium", "high", "xhigh", "max"]
|
|
233
|
+
const EFF5 = ["low", "medium", "high", "xhigh", "max"]
|
|
234
|
+
|
|
235
|
+
// Qoder's list when the account's can't be asked (as it was on 2026-09-30).
|
|
236
|
+
const MODELS = [
|
|
237
|
+
{ id: "ultimate", name: "Ultimate", context: 1_000_000, efforts: EFF5 },
|
|
238
|
+
{ id: "performance", name: "Performance", context: 1_000_000, efforts: EFF5 },
|
|
239
|
+
{ id: "efficient", name: "Efficient", context: 200_000 },
|
|
240
|
+
{ id: "smodel", name: "Sonus", context: 180_000, efforts: EFF5 },
|
|
241
|
+
{ id: "cmodel", name: "Cantus", context: 180_000, efforts: EFF5 },
|
|
242
|
+
{ id: "qmodel_38max", name: "Qwen3.8-Max", context: 180_000, efforts: ["low", "medium", "xhigh"] },
|
|
243
|
+
{ id: "qfmodel", name: "Qwen3.8-Flash", context: 180_000, efforts: ["low", "medium", "xhigh"] },
|
|
244
|
+
{ id: "qmodel_latest", name: "Qwen3.7-Max", context: 1_000_000 },
|
|
245
|
+
{ id: "qmodel", name: "Qwen3.7-Plus", context: 1_000_000 },
|
|
246
|
+
{ id: "kmodel_latest", name: "Kimi-K3", context: 180_000, efforts: ["low", "high", "max"] },
|
|
247
|
+
{ id: "kmodel", name: "Kimi-K2.8-Preview", efforts: ["low", "high", "max"] },
|
|
248
|
+
{ id: "gmodel", name: "GLM-5.3", context: 180_000, efforts: ["low", "high", "max"] },
|
|
249
|
+
{ id: "gfmodel", name: "GLM-5.3-Flash", context: 1_000_000, efforts: ["high", "max"] },
|
|
250
|
+
{ id: "dmodel", name: "DeepSeek-V4-Pro", context: 1_000_000, efforts: ["high", "max"] },
|
|
251
|
+
{ id: "dfmodel", name: "DeepSeek-Flash", context: 1_000_000, efforts: ["low", "high", "max"] },
|
|
252
|
+
{ id: "mmodel", name: "MiniMax-M3", context: 180_000 },
|
|
253
|
+
].map((m) => ({ images: true, ...m }))
|
|
254
|
+
|
|
255
|
+
// modelInfo reads one entry of the listing's "chat" array: its efforts and
|
|
256
|
+
// default from thinking_config (is_reasoning alone when it has none).
|
|
257
|
+
function modelInfo(raw) {
|
|
258
|
+
const m = {
|
|
259
|
+
key: raw.key,
|
|
260
|
+
source: raw.source ?? "",
|
|
261
|
+
name: raw.display_name || raw.key,
|
|
262
|
+
images: !!raw.is_vl,
|
|
263
|
+
context: raw.max_input_tokens || 0,
|
|
264
|
+
thinks: !!raw.is_reasoning,
|
|
265
|
+
alwaysThinks: false,
|
|
266
|
+
efforts: Array.isArray(raw.reasoning_efforts) ? [...raw.reasoning_efforts] : [],
|
|
267
|
+
defaultEffort: "",
|
|
268
|
+
config: raw,
|
|
269
|
+
}
|
|
270
|
+
const tc = raw.thinking_config
|
|
271
|
+
if (tc && typeof tc === "object") {
|
|
272
|
+
m.thinks = tc.enabled != null
|
|
273
|
+
m.alwaysThinks = m.thinks && tc.disabled == null
|
|
274
|
+
m.efforts = []
|
|
275
|
+
if (m.thinks) {
|
|
276
|
+
for (const [name, e] of Object.entries(tc.enabled.efforts ?? {})) {
|
|
277
|
+
m.efforts.push(name)
|
|
278
|
+
if (e?.is_default) m.defaultEffort = name
|
|
279
|
+
}
|
|
280
|
+
const rank = (s) => (EFFORT_ORDER.includes(s) ? EFFORT_ORDER.indexOf(s) : EFFORT_ORDER.length)
|
|
281
|
+
m.efforts.sort((a, b) => rank(a) - rank(b) || (a < b ? -1 : a > b ? 1 : 0))
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return m
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// modelInfos is the listing's enabled chat models an agent can pick: "auto"
|
|
288
|
+
// and "default" route inside Qoder and aren't one model.
|
|
289
|
+
function modelInfos(listing) {
|
|
290
|
+
return (listing?.chat ?? [])
|
|
291
|
+
.filter((m) => m?.enable && !["", "auto", "default"].includes(String(m.key ?? "").trim()))
|
|
292
|
+
.map(modelInfo)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function fetchListing(cred) {
|
|
296
|
+
const res = await fetch(MODELS_URL, { headers: cosyHeaders(MODELS_URL, cred, ""), signal: AbortSignal.timeout(15_000) })
|
|
297
|
+
const text = await res.text()
|
|
298
|
+
if (!res.ok) throw new Error(`Qoder models: HTTP ${res.status}: ${text.trim().slice(0, 512)}`)
|
|
299
|
+
return JSON.parse(text)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function configModel(m) {
|
|
303
|
+
return {
|
|
304
|
+
name: m.name,
|
|
305
|
+
limit: { context: m.context ?? 0, output: 0 },
|
|
306
|
+
...(m.images ? { attachment: true, modalities: { input: ["text", "image"], output: ["text"] } } : {}),
|
|
307
|
+
...(m.efforts?.length ? { reasoning: true, variants: Object.fromEntries(m.efforts.map((e) => [e, { reasoningEffort: e }])) } : {}),
|
|
308
|
+
tool_call: true,
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function runtimeModel(m) {
|
|
313
|
+
return {
|
|
314
|
+
id: m.id,
|
|
315
|
+
providerID: ID,
|
|
316
|
+
name: m.name ?? m.id,
|
|
317
|
+
api: { id: m.id, url: API, npm: CHAT },
|
|
318
|
+
status: "active",
|
|
319
|
+
headers: {},
|
|
320
|
+
options: {},
|
|
321
|
+
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
|
322
|
+
limit: { context: m.context ?? 0, output: 0 },
|
|
323
|
+
capabilities: {
|
|
324
|
+
temperature: true,
|
|
325
|
+
reasoning: !!m.efforts?.length,
|
|
326
|
+
attachment: !!m.images,
|
|
327
|
+
toolcall: true,
|
|
328
|
+
input: { text: true, image: !!m.images, audio: false, video: false, pdf: false },
|
|
329
|
+
output: { text: true, image: false, audio: false, video: false, pdf: false },
|
|
330
|
+
interleaved: false,
|
|
331
|
+
},
|
|
332
|
+
release_date: "",
|
|
333
|
+
variants: Object.fromEntries((m.efforts ?? []).map((e) => [e, { reasoningEffort: e }])),
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ---- the request --------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
const EFFORT_RANK = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]
|
|
340
|
+
|
|
341
|
+
function fitEffort(want, levels) {
|
|
342
|
+
if (want === "ultra" && !levels.includes(want)) want = "max"
|
|
343
|
+
if (!levels.length || levels.includes(want)) return want
|
|
344
|
+
const at = EFFORT_RANK.indexOf(want)
|
|
345
|
+
if (at < 0) return want
|
|
346
|
+
let best = want
|
|
347
|
+
let dist = EFFORT_RANK.length
|
|
348
|
+
for (const l of levels) {
|
|
349
|
+
const i = EFFORT_RANK.indexOf(l)
|
|
350
|
+
if (i < 0 || l === "none") continue
|
|
351
|
+
const d = Math.abs(i - at)
|
|
352
|
+
if (d < dist || (d === dist && i > at)) [best, dist] = [l, d]
|
|
353
|
+
}
|
|
354
|
+
return best
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// effortFor is whether to think and at which of the model's efforts ("" for
|
|
358
|
+
// none): asked for none, a model that always thinks thinks at its lowest;
|
|
359
|
+
// asked for nothing, or a level Qoder doesn't name, at its own default.
|
|
360
|
+
function effortFor(asked, m) {
|
|
361
|
+
let want = String(asked ?? "").toLowerCase()
|
|
362
|
+
if (!EFFORT_RANK.includes(want)) want = ""
|
|
363
|
+
if (!m.thinks || (want === "none" && !m.alwaysThinks)) return [false, ""]
|
|
364
|
+
if (!m.efforts.length) return [true, ""]
|
|
365
|
+
if (want === "") return [true, m.defaultEffort]
|
|
366
|
+
if (want === "none") return [true, m.efforts[0]]
|
|
367
|
+
return [true, fitEffort(want, m.efforts)]
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function imageBlock(p) {
|
|
371
|
+
const url = typeof p.image_url === "string" ? p.image_url : p.image_url?.url
|
|
372
|
+
return url ? { type: "image_url", image_url: { url } } : null
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function blocksOf(content) {
|
|
376
|
+
if (typeof content === "string") return content ? [{ type: "text", text: content }] : []
|
|
377
|
+
const out = []
|
|
378
|
+
for (const p of content ?? []) {
|
|
379
|
+
if (p?.type === "text" && p.text != null) out.push({ type: "text", text: p.text })
|
|
380
|
+
else if (p?.type === "image_url") {
|
|
381
|
+
const b = imageBlock(p)
|
|
382
|
+
if (b) out.push(b)
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return out
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const textOf = (c) => (typeof c === "string" ? c : blocksOf(c).filter((b) => b.type === "text").map((b) => b.text).join(""))
|
|
389
|
+
|
|
390
|
+
// qoderMessages is the chat's turns as Qoder takes them: text and image
|
|
391
|
+
// blocks, an assistant's tool calls as tool_calls, results as tool turns by
|
|
392
|
+
// tool_call_id. A tool turn holds text only, so the images a tool returned
|
|
393
|
+
// follow in a user turn — the start of the user's own when one comes next.
|
|
394
|
+
function qoderMessages(msgs) {
|
|
395
|
+
const out = []
|
|
396
|
+
const names = {}
|
|
397
|
+
let seen = []
|
|
398
|
+
const showSeen = () => {
|
|
399
|
+
if (seen.length) out.push({ role: "user", content: seen })
|
|
400
|
+
seen = []
|
|
401
|
+
}
|
|
402
|
+
for (const m of msgs) {
|
|
403
|
+
if (m.role === "system" || m.role === "developer") continue
|
|
404
|
+
if (m.role !== "user" && m.role !== "tool") showSeen()
|
|
405
|
+
if (m.role === "tool") {
|
|
406
|
+
let txt = textOf(m.content)
|
|
407
|
+
const ims = Array.isArray(m.content) ? m.content.filter((p) => p?.type === "image_url").map(imageBlock).filter(Boolean) : []
|
|
408
|
+
if (ims.length) {
|
|
409
|
+
let of = "tool call " + m.tool_call_id
|
|
410
|
+
const name = names[m.tool_call_id] || m.name
|
|
411
|
+
if (name) of = `${name} (${of})`
|
|
412
|
+
seen.push({ type: "text", text: `[From the result of ${of}:]` }, ...ims)
|
|
413
|
+
const note = ims.length === 1 ? "[The tool returned an image; it follows in the next message.]" : `[The tool returned ${ims.length} images; they follow in the next message.]`
|
|
414
|
+
txt = txt.trim() ? txt + "\n\n" + note : txt + note
|
|
415
|
+
}
|
|
416
|
+
out.push({ role: "tool", tool_call_id: m.tool_call_id, content: txt })
|
|
417
|
+
continue
|
|
418
|
+
}
|
|
419
|
+
if (m.role === "assistant" && m.tool_calls?.length) {
|
|
420
|
+
const calls = m.tool_calls.map((c) => {
|
|
421
|
+
const id = c.id || "call_" + hexID()
|
|
422
|
+
names[id] = c.function?.name
|
|
423
|
+
const args = c.function?.arguments
|
|
424
|
+
return { id, type: "function", function: { name: c.function?.name, arguments: typeof args === "string" ? args : JSON.stringify(args ?? {}) } }
|
|
425
|
+
})
|
|
426
|
+
out.push({ role: "assistant", content: textOf(m.content), tool_calls: calls })
|
|
427
|
+
continue
|
|
428
|
+
}
|
|
429
|
+
let blocks = blocksOf(m.content)
|
|
430
|
+
if (m.role === "assistant") blocks = blocks.filter((b) => b.type === "text")
|
|
431
|
+
if (!blocks.length) continue
|
|
432
|
+
if (m.role === "user" && seen.length) [blocks, seen] = [[...seen, ...blocks], []]
|
|
433
|
+
out.push({ role: m.role, content: blocks })
|
|
434
|
+
}
|
|
435
|
+
showSeen()
|
|
436
|
+
return out
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// qoderBody is the plaintext agent_chat_generation takes for a chat
|
|
440
|
+
// completion request, for model m.
|
|
441
|
+
function qoderBody(chat, m) {
|
|
442
|
+
const system = (chat.messages ?? []).filter((x) => x.role === "system" || x.role === "developer").map((x) => textOf(x.content)).filter(Boolean).join("\n\n")
|
|
443
|
+
const tools = chat.tool_choice !== "none" ? (chat.tools ?? []).filter((t) => t?.type === "function" && t.function?.name) : []
|
|
444
|
+
let sysText = system ? QODER_SYS + "\n\n" + system : QODER_SYS
|
|
445
|
+
if (chat.tool_choice === "required" && tools.length) sysText += "\nYou must call an available function in this response."
|
|
446
|
+
const sys = { type: "text", text: sysText }
|
|
447
|
+
const [thinking, effort] = effortFor(chat.reasoning_effort, m)
|
|
448
|
+
const params = { enable_thinking: thinking, max_tokens: chat.max_completion_tokens || chat.max_tokens || 32000 }
|
|
449
|
+
if (effort) params.reasoning_effort = effort
|
|
450
|
+
if (m.context > 0) params.context_length = m.context
|
|
451
|
+
const body = {
|
|
452
|
+
parameters: params,
|
|
453
|
+
business: { product: "app", version: COSY_VERSION, type: "agent", id: hexID(), name: "magpie session", begin_at: Date.now(), stage: "start" },
|
|
454
|
+
agent_id: "agent_common",
|
|
455
|
+
task_id: "common",
|
|
456
|
+
session_type: "app",
|
|
457
|
+
model_config: m.config,
|
|
458
|
+
system: [sys],
|
|
459
|
+
messages: [{ role: "system", content: [sys] }, ...qoderMessages(chat.messages ?? [])],
|
|
460
|
+
}
|
|
461
|
+
if (tools.length)
|
|
462
|
+
body.tools = tools.map((t) => ({
|
|
463
|
+
type: "function",
|
|
464
|
+
function: { name: t.function.name, description: t.function.description ?? "", parameters: t.function.parameters ?? { type: "object", properties: {} } },
|
|
465
|
+
}))
|
|
466
|
+
return body
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// failure is the status and message for a Qoder failure: a refused sign-in
|
|
470
|
+
// asks for another, a quota is a 429.
|
|
471
|
+
function failure(status, text) {
|
|
472
|
+
if (status < 400 || status > 599) status = 502
|
|
473
|
+
let msg = String(text ?? "").trim()
|
|
474
|
+
try {
|
|
475
|
+
const j = JSON.parse(text)
|
|
476
|
+
if (j?.message) msg = j.message
|
|
477
|
+
const d = typeof j?.details === "string" ? JSON.parse(j.details) : null
|
|
478
|
+
if (d?.error?.message) msg += ": " + d.error.message
|
|
479
|
+
} catch {}
|
|
480
|
+
msg ||= `HTTP ${status}`
|
|
481
|
+
if (status === 401 || status === 403) return { status: 401, message: "Qoder: the sign-in lapsed — sign in again" }
|
|
482
|
+
if (status === 429 || msg.toLowerCase().includes("quota")) return { status: 429, message: "usage limit reached: " + msg }
|
|
483
|
+
return { status, message: "Qoder: " + msg }
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const errorResponse = ({ status, message }) =>
|
|
487
|
+
new Response(JSON.stringify({ error: { message, type: "qoder_error", code: status } }), { status, headers: { "Content-Type": "application/json" } })
|
|
488
|
+
|
|
489
|
+
// ---- the reply ----------------------------------------------------------------
|
|
490
|
+
|
|
491
|
+
const CALL_OPEN = "\x3ctool_call\x3e"
|
|
492
|
+
const CALL_CLOSE = "\x3c/tool_call\x3e"
|
|
493
|
+
const FUNC = /\x3cfunction=([^>]+)\x3e([\s\S]*?)\x3c\/function\x3e/
|
|
494
|
+
const PARAM = /\x3cparameter=([^>]+)\x3e([\s\S]*?)\x3c\/parameter\x3e/g
|
|
495
|
+
|
|
496
|
+
// parseCall reads one tool-call block: JSON {name, arguments}, or the XML
|
|
497
|
+
// function/parameter form.
|
|
498
|
+
function parseCall(v) {
|
|
499
|
+
try {
|
|
500
|
+
const f = JSON.parse(v.trim())
|
|
501
|
+
if (String(f?.name ?? "").trim() && f.arguments && typeof f.arguments === "object" && !Array.isArray(f.arguments))
|
|
502
|
+
return { name: f.name.trim(), args: JSON.stringify(f.arguments) }
|
|
503
|
+
} catch {}
|
|
504
|
+
const m = FUNC.exec(v)
|
|
505
|
+
if (!m || !m[1].trim()) return null
|
|
506
|
+
const args = {}
|
|
507
|
+
for (const pm of m[2].matchAll(PARAM)) {
|
|
508
|
+
const key = pm[1].trim()
|
|
509
|
+
if (!key) continue
|
|
510
|
+
const val = pm[2].trim()
|
|
511
|
+
try {
|
|
512
|
+
args[key] = JSON.parse(val)
|
|
513
|
+
} catch {
|
|
514
|
+
args[key] = val
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return { name: m[1].trim(), args: JSON.stringify(args) }
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Splitter holds text until a whole tool-call block has come, then gives
|
|
521
|
+
// it as a call.
|
|
522
|
+
class Splitter {
|
|
523
|
+
textBuf = ""
|
|
524
|
+
callBuf = ""
|
|
525
|
+
inCall = false
|
|
526
|
+
sawTool = false
|
|
527
|
+
feed(input) {
|
|
528
|
+
const out = []
|
|
529
|
+
while (input) {
|
|
530
|
+
if (!this.inCall) {
|
|
531
|
+
const comb = this.textBuf + input
|
|
532
|
+
this.textBuf = ""
|
|
533
|
+
const i = comb.indexOf(CALL_OPEN)
|
|
534
|
+
if (i < 0) {
|
|
535
|
+
let keep = 0
|
|
536
|
+
for (let n = CALL_OPEN.length - 1; n > 0; n--)
|
|
537
|
+
if (comb.endsWith(CALL_OPEN.slice(0, n))) {
|
|
538
|
+
keep = n
|
|
539
|
+
break
|
|
540
|
+
}
|
|
541
|
+
if (keep < comb.length) out.push({ text: comb.slice(0, comb.length - keep) })
|
|
542
|
+
this.textBuf = comb.slice(comb.length - keep)
|
|
543
|
+
break
|
|
544
|
+
}
|
|
545
|
+
if (i > 0) out.push({ text: comb.slice(0, i) })
|
|
546
|
+
input = comb.slice(i + CALL_OPEN.length)
|
|
547
|
+
this.inCall = true
|
|
548
|
+
continue
|
|
549
|
+
}
|
|
550
|
+
const comb = this.callBuf + input
|
|
551
|
+
this.callBuf = ""
|
|
552
|
+
const j = comb.indexOf(CALL_CLOSE)
|
|
553
|
+
if (j < 0) {
|
|
554
|
+
this.callBuf = comb
|
|
555
|
+
break
|
|
556
|
+
}
|
|
557
|
+
const c = parseCall(comb.slice(0, j))
|
|
558
|
+
if (c) {
|
|
559
|
+
this.sawTool = true
|
|
560
|
+
out.push({ call: c })
|
|
561
|
+
} else out.push({ text: CALL_OPEN + comb.slice(0, j) + CALL_CLOSE })
|
|
562
|
+
input = comb.slice(j + CALL_CLOSE.length)
|
|
563
|
+
this.inCall = false
|
|
564
|
+
}
|
|
565
|
+
return out
|
|
566
|
+
}
|
|
567
|
+
flush() {
|
|
568
|
+
if (this.inCall) {
|
|
569
|
+
const t = CALL_OPEN + this.callBuf
|
|
570
|
+
this.inCall = false
|
|
571
|
+
this.callBuf = ""
|
|
572
|
+
return [{ text: t }]
|
|
573
|
+
}
|
|
574
|
+
if (this.textBuf) {
|
|
575
|
+
const t = this.textBuf
|
|
576
|
+
this.textBuf = ""
|
|
577
|
+
return [{ text: t }]
|
|
578
|
+
}
|
|
579
|
+
return []
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
async function* sseLines(body) {
|
|
584
|
+
const dec = new TextDecoder()
|
|
585
|
+
let buf = ""
|
|
586
|
+
for await (const chunk of body) {
|
|
587
|
+
buf += dec.decode(chunk, { stream: true })
|
|
588
|
+
let n
|
|
589
|
+
while ((n = buf.indexOf("\n")) >= 0) {
|
|
590
|
+
yield buf.slice(0, n).trim()
|
|
591
|
+
buf = buf.slice(n + 1)
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
if (buf.trim()) yield buf.trim()
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// events turns Qoder's SSE into chat completion pieces: each data line's
|
|
598
|
+
// envelope has a "body" that is an OpenAI chunk, whose content may hold
|
|
599
|
+
// tool calls to lift out.
|
|
600
|
+
async function* events(body) {
|
|
601
|
+
const a = new Splitter()
|
|
602
|
+
let index = -1 // the tool call being given
|
|
603
|
+
let native = -1 // the native tool call being assembled
|
|
604
|
+
const flushed = function* () {
|
|
605
|
+
for (const f of a.flush()) if (f.text) yield { text: f.text }
|
|
606
|
+
}
|
|
607
|
+
let usage
|
|
608
|
+
let fr = ""
|
|
609
|
+
for await (const line of sseLines(body)) {
|
|
610
|
+
if (!line.startsWith("data:")) continue
|
|
611
|
+
const payload = line.slice(5).trim()
|
|
612
|
+
let env
|
|
613
|
+
try {
|
|
614
|
+
env = JSON.parse(payload)
|
|
615
|
+
} catch {
|
|
616
|
+
continue
|
|
617
|
+
}
|
|
618
|
+
const inner = typeof env?.body === "string" ? env.body : ""
|
|
619
|
+
if (env?.statusCodeValue != null && env.statusCodeValue !== 200) {
|
|
620
|
+
yield { error: failure(Number(env.statusCodeValue), inner || payload) }
|
|
621
|
+
return
|
|
622
|
+
}
|
|
623
|
+
let chunk
|
|
624
|
+
try {
|
|
625
|
+
chunk = JSON.parse(inner)
|
|
626
|
+
} catch {
|
|
627
|
+
continue
|
|
628
|
+
}
|
|
629
|
+
const delta = chunk?.choices?.[0]?.delta ?? {}
|
|
630
|
+
if (delta.reasoning_content) yield { reasoning: delta.reasoning_content }
|
|
631
|
+
if (typeof delta.content === "string")
|
|
632
|
+
for (const f of a.feed(delta.content)) {
|
|
633
|
+
if (f.call) yield { tool: { index: ++index, id: "call_" + hexID(), name: f.call.name, args: f.call.args } }
|
|
634
|
+
else if (f.text) yield { text: f.text }
|
|
635
|
+
}
|
|
636
|
+
// tool calls served the OpenAI way keep Qoder's id: a result answers it
|
|
637
|
+
for (const tc of delta.tool_calls ?? []) {
|
|
638
|
+
const i = Number(tc.index ?? 0)
|
|
639
|
+
if (i !== native) {
|
|
640
|
+
native = i
|
|
641
|
+
yield* flushed()
|
|
642
|
+
a.sawTool = true
|
|
643
|
+
yield { tool: { index: ++index, id: tc.id || "call_" + hexID(), name: tc.function?.name ?? "", args: tc.function?.arguments ?? "" } }
|
|
644
|
+
} else if (tc.function?.arguments) yield { args: { index, text: tc.function.arguments } }
|
|
645
|
+
}
|
|
646
|
+
fr = chunk?.choices?.[0]?.finish_reason ?? ""
|
|
647
|
+
if (fr) {
|
|
648
|
+
const u = chunk.usage
|
|
649
|
+
if (u) usage = { prompt_tokens: u.prompt_tokens ?? 0, completion_tokens: u.completion_tokens ?? 0, total_tokens: (u.prompt_tokens ?? 0) + (u.completion_tokens ?? 0) }
|
|
650
|
+
break
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
yield* flushed()
|
|
654
|
+
yield { stop: a.sawTool ? "tool_calls" : fr === "length" ? "length" : "stop", usage }
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// answer turns Qoder's reply into the chat completion OpenAI's API gives.
|
|
658
|
+
// A failure before the answer keeps its status; one after it ends the
|
|
659
|
+
// stream with an error.
|
|
660
|
+
async function answer(res, chat) {
|
|
661
|
+
const id = "chatcmpl-" + hexID()
|
|
662
|
+
const created = Math.floor(Date.now() / 1000)
|
|
663
|
+
const it = events(res.body)
|
|
664
|
+
const first = await it.next()
|
|
665
|
+
if (first.value?.error) return errorResponse(first.value.error)
|
|
666
|
+
if (first.done) return errorResponse({ status: 502, message: "Qoder ended without an answer" })
|
|
667
|
+
|
|
668
|
+
if (!chat.stream) {
|
|
669
|
+
const msg = { role: "assistant", content: "" }
|
|
670
|
+
let reasoning = ""
|
|
671
|
+
const calls = []
|
|
672
|
+
let stop = "stop"
|
|
673
|
+
let usage
|
|
674
|
+
for (let r = first; !r.done; r = await it.next()) {
|
|
675
|
+
const e = r.value
|
|
676
|
+
if (e.error) return errorResponse(e.error)
|
|
677
|
+
if (e.text) msg.content += e.text
|
|
678
|
+
if (e.reasoning) reasoning += e.reasoning
|
|
679
|
+
if (e.tool) calls.push({ id: e.tool.id, type: "function", function: { name: e.tool.name, arguments: e.tool.args } })
|
|
680
|
+
if (e.args) calls[calls.length - 1].function.arguments += e.args.text
|
|
681
|
+
if (e.stop) [stop, usage] = [e.stop, e.usage]
|
|
682
|
+
}
|
|
683
|
+
if (reasoning) msg.reasoning_content = reasoning
|
|
684
|
+
if (calls.length) msg.tool_calls = calls
|
|
685
|
+
return Response.json({ id, object: "chat.completion", created, model: chat.model, choices: [{ index: 0, message: msg, finish_reason: stop }], ...(usage ? { usage } : {}) })
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const enc = new TextEncoder()
|
|
689
|
+
const chunk = (delta, finish_reason = null, extra = {}) =>
|
|
690
|
+
enc.encode(`data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model: chat.model, choices: [{ index: 0, delta, finish_reason }], ...extra })}\n\n`)
|
|
691
|
+
let pending = first
|
|
692
|
+
let began = false
|
|
693
|
+
const stream = new ReadableStream({
|
|
694
|
+
async pull(ctl) {
|
|
695
|
+
const r = pending ?? (await it.next())
|
|
696
|
+
pending = null
|
|
697
|
+
if (!began) {
|
|
698
|
+
began = true
|
|
699
|
+
ctl.enqueue(chunk({ role: "assistant", content: "" }))
|
|
700
|
+
}
|
|
701
|
+
if (r.done) {
|
|
702
|
+
ctl.enqueue(enc.encode("data: [DONE]\n\n"))
|
|
703
|
+
return ctl.close()
|
|
704
|
+
}
|
|
705
|
+
const e = r.value
|
|
706
|
+
if (e.text) ctl.enqueue(chunk({ content: e.text }))
|
|
707
|
+
else if (e.reasoning) ctl.enqueue(chunk({ reasoning_content: e.reasoning }))
|
|
708
|
+
else if (e.tool) ctl.enqueue(chunk({ tool_calls: [{ index: e.tool.index, id: e.tool.id, type: "function", function: { name: e.tool.name, arguments: e.tool.args } }] }))
|
|
709
|
+
else if (e.args) ctl.enqueue(chunk({ tool_calls: [{ index: e.args.index, function: { arguments: e.args.text } }] }))
|
|
710
|
+
else if (e.stop) ctl.enqueue(chunk({}, e.stop, e.usage ? { usage: e.usage } : {}))
|
|
711
|
+
else if (e.error) {
|
|
712
|
+
ctl.enqueue(enc.encode(`data: ${JSON.stringify({ error: { message: e.error.message, code: e.error.status } })}\n\n`))
|
|
713
|
+
ctl.close()
|
|
714
|
+
}
|
|
715
|
+
},
|
|
716
|
+
cancel() {
|
|
717
|
+
it.return?.()
|
|
718
|
+
},
|
|
719
|
+
})
|
|
720
|
+
return new Response(stream, { status: 200, headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" } })
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// ---- the plugin ---------------------------------------------------------------
|
|
724
|
+
|
|
725
|
+
async function bodyText(input, init) {
|
|
726
|
+
const b = init.body ?? (input instanceof Request ? await input.clone().text() : undefined)
|
|
727
|
+
return typeof b === "string" ? b : new TextDecoder().decode(b)
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
export async function QoderAuthPlugin({ client }) {
|
|
731
|
+
// serializes checking, rotating and saving tokens: a refresh token is
|
|
732
|
+
// spent once, so two refreshes would spend it twice
|
|
733
|
+
let lock = Promise.resolve()
|
|
734
|
+
const locked = (fn) => {
|
|
735
|
+
const run = lock.then(fn, fn)
|
|
736
|
+
lock = run.catch(() => {})
|
|
737
|
+
return run
|
|
738
|
+
}
|
|
739
|
+
// each account's listing, the model configs a request carries
|
|
740
|
+
const listings = new Map()
|
|
741
|
+
|
|
742
|
+
// fresh is the account with a live job token, refreshed and saved near
|
|
743
|
+
// its end.
|
|
744
|
+
const fresh = (getAuth) =>
|
|
745
|
+
locked(async () => {
|
|
746
|
+
const a = await getAuth()
|
|
747
|
+
if (a?.type !== "oauth" || !a.access || !a.uid) throw new SignInGone("Qoder: not signed in")
|
|
748
|
+
if (a.expires - Date.now() > REFRESH_LEAD) return a
|
|
749
|
+
const jt = await refreshJob(a.refresh)
|
|
750
|
+
const next = { ...a, access: jt.token, refresh: jt.refresh_token, expires: expiresAt(jt) }
|
|
751
|
+
await client.auth.set({ path: { id: ID }, body: next })
|
|
752
|
+
return next
|
|
753
|
+
})
|
|
754
|
+
|
|
755
|
+
const models = async (cred, again = false) => {
|
|
756
|
+
let l = listings.get(cred.uid)
|
|
757
|
+
if (!l || again) {
|
|
758
|
+
l = modelInfos(await fetchListing(cred))
|
|
759
|
+
listings.set(cred.uid, l)
|
|
760
|
+
}
|
|
761
|
+
return l
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const signedInError = (e) =>
|
|
765
|
+
errorResponse(e instanceof SignInGone ? { status: 401, message: e.message } : { status: 502, message: "Qoder: " + (e?.message ?? e) })
|
|
766
|
+
|
|
767
|
+
return {
|
|
768
|
+
auth: {
|
|
769
|
+
provider: ID,
|
|
770
|
+
async loader(getAuth) {
|
|
771
|
+
const a = await getAuth()
|
|
772
|
+
if (a?.type !== "oauth") return {}
|
|
773
|
+
return {
|
|
774
|
+
baseURL: API,
|
|
775
|
+
apiKey: "qoder",
|
|
776
|
+
// every chat completion written as Qoder's own request
|
|
777
|
+
async fetch(input, init = {}) {
|
|
778
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
|
|
779
|
+
if (!/\/chat\/completions$/.test(new URL(url).pathname))
|
|
780
|
+
return errorResponse({ status: 404, message: "Qoder: only chat completions are served" })
|
|
781
|
+
let chat
|
|
782
|
+
try {
|
|
783
|
+
chat = JSON.parse(await bodyText(input, init))
|
|
784
|
+
} catch {
|
|
785
|
+
return errorResponse({ status: 400, message: "Qoder: a request that isn't JSON" })
|
|
786
|
+
}
|
|
787
|
+
let cred
|
|
788
|
+
try {
|
|
789
|
+
cred = await fresh(getAuth)
|
|
790
|
+
} catch (e) {
|
|
791
|
+
return signedInError(e)
|
|
792
|
+
}
|
|
793
|
+
let m
|
|
794
|
+
try {
|
|
795
|
+
m = (await models(cred)).find((x) => x.key === chat.model) ?? (await models(cred, true)).find((x) => x.key === chat.model)
|
|
796
|
+
} catch (e) {
|
|
797
|
+
return errorResponse({ status: 502, message: e.message })
|
|
798
|
+
}
|
|
799
|
+
if (!m) return errorResponse({ status: 400, message: `Qoder: unknown or disabled model "${chat.model}"` })
|
|
800
|
+
const wire = encodeBody(JSON.stringify(qoderBody(chat, m)))
|
|
801
|
+
const headers = {
|
|
802
|
+
...cosyHeaders(CHAT_URL, cred, wire),
|
|
803
|
+
Accept: "text/event-stream",
|
|
804
|
+
"Cache-Control": "no-cache",
|
|
805
|
+
"X-Model-Key": m.key,
|
|
806
|
+
"X-Model-Source": m.source,
|
|
807
|
+
}
|
|
808
|
+
let res
|
|
809
|
+
try {
|
|
810
|
+
res = await fetch(CHAT_URL, { method: "POST", headers, body: wire, signal: init.signal })
|
|
811
|
+
} catch (e) {
|
|
812
|
+
return errorResponse({ status: 502, message: "Qoder: " + e.message })
|
|
813
|
+
}
|
|
814
|
+
if (!res.ok) return errorResponse(failure(res.status, (await res.text()).slice(0, 1 << 20)))
|
|
815
|
+
return answer(res, chat)
|
|
816
|
+
},
|
|
817
|
+
}
|
|
818
|
+
},
|
|
819
|
+
methods: [{ type: "oauth", label: "Sign in with Qoder", authorize: deviceSignIn }],
|
|
820
|
+
},
|
|
821
|
+
async config(config) {
|
|
822
|
+
config.provider ??= {}
|
|
823
|
+
const was = config.provider[ID] ?? {}
|
|
824
|
+
config.provider[ID] = {
|
|
825
|
+
name: "Qoder",
|
|
826
|
+
npm: CHAT,
|
|
827
|
+
api: API,
|
|
828
|
+
...was,
|
|
829
|
+
models: { ...Object.fromEntries(MODELS.map((m) => [m.id, configModel(m)])), ...(was.models ?? {}) },
|
|
830
|
+
}
|
|
831
|
+
},
|
|
832
|
+
// the account's own list, as Qoder's client asks it
|
|
833
|
+
provider: {
|
|
834
|
+
id: ID,
|
|
835
|
+
async models(provider, { auth } = {}) {
|
|
836
|
+
if (auth?.type !== "oauth") return provider.models
|
|
837
|
+
try {
|
|
838
|
+
const cred = await fresh(async () => auth)
|
|
839
|
+
const ms = await models(cred, true)
|
|
840
|
+
if (!ms.length) return provider.models
|
|
841
|
+
return Object.fromEntries(
|
|
842
|
+
ms.map((m) => [m.key, runtimeModel({ id: m.key, name: m.name, context: m.context, images: m.images, efforts: m.thinks ? m.efforts : [] })]),
|
|
843
|
+
)
|
|
844
|
+
} catch {
|
|
845
|
+
return provider.models
|
|
846
|
+
}
|
|
847
|
+
},
|
|
848
|
+
},
|
|
849
|
+
}
|
|
850
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@magpie-community/opencode-qoder-auth",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Qoder subscription in OpenCode and magpie",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./index.mjs",
|
|
7
|
+
"exports": "./index.mjs",
|
|
8
|
+
"files": ["index.mjs", "README.md", "LICENSE-CLIProxyAPI"],
|
|
9
|
+
"keywords": ["opencode", "opencode-plugin", "magpie", "qoder"],
|
|
10
|
+
"license": "MIT"
|
|
11
|
+
}
|