opencode-qoder 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/dist/index.js ADDED
@@ -0,0 +1,272 @@
1
+ import { decodeOAuthRefresh, encodeOAuthRefresh, generatePKCE, } from "./auth.js";
2
+ import { getMachineId } from "./cosy.js";
3
+ import { PROVIDER_ID, PROVIDER_NAME, QODER_BASE_URL, QODER_MODELS, QODER_OPENAPI_URL, QODER_PAT_ENV, USER_AGENT, ZERO_COST, } from "./constants.js";
4
+ import { createQoder, QoderLanguageModel } from "./language-model.js";
5
+ export { createQoder, QoderLanguageModel };
6
+ function optionString(options, key) {
7
+ const value = options?.[key];
8
+ return typeof value === "string" && value.length > 0 ? value : undefined;
9
+ }
10
+ function providerID(options) {
11
+ return optionString(options, "providerID") || PROVIDER_ID;
12
+ }
13
+ function shouldSetDefault(options) {
14
+ return options?.setDefault === true;
15
+ }
16
+ function legacyModelConfig(model) {
17
+ return {
18
+ name: model.name,
19
+ reasoning: model.reasoning,
20
+ tool_call: true,
21
+ attachment: model.input.includes("image"),
22
+ cost: ZERO_COST,
23
+ limit: {
24
+ context: model.contextWindow,
25
+ input: model.contextWindow,
26
+ output: model.maxTokens,
27
+ },
28
+ modalities: {
29
+ input: model.input,
30
+ output: ["text"],
31
+ },
32
+ };
33
+ }
34
+ function applyLegacyConfig(cfg, options) {
35
+ const id = providerID(options);
36
+ cfg.provider ??= {};
37
+ const current = (cfg.provider[id] ??= {});
38
+ current.name ??= PROVIDER_NAME;
39
+ current.env ??= [...QODER_PAT_ENV];
40
+ current.npm ??= import.meta.url;
41
+ current.options ??= {};
42
+ current.options.baseURL ??= QODER_BASE_URL;
43
+ const apiKey = optionString(options, "apiKey");
44
+ if (apiKey && current.options.apiKey === undefined)
45
+ current.options.apiKey = apiKey;
46
+ current.models ??= {};
47
+ for (const model of QODER_MODELS) {
48
+ current.models[model.id] = {
49
+ ...legacyModelConfig(model),
50
+ ...(current.models[model.id] ?? {}),
51
+ };
52
+ }
53
+ if (shouldSetDefault(options) && !cfg.model)
54
+ cfg.model = `${id}/auto`;
55
+ }
56
+ function v2ModelConfig(model) {
57
+ return {
58
+ name: model.name,
59
+ family: model.id,
60
+ api: {
61
+ id: model.id,
62
+ type: "aisdk",
63
+ package: "@ai-sdk/openai-compatible",
64
+ url: QODER_BASE_URL,
65
+ settings: {},
66
+ },
67
+ capabilities: {
68
+ tools: true,
69
+ input: model.input,
70
+ output: ["text"],
71
+ },
72
+ variants: [],
73
+ time: { released: 0 },
74
+ cost: [{ input: 0, output: 0, cache: { read: 0, write: 0 } }],
75
+ status: "active",
76
+ enabled: true,
77
+ limit: {
78
+ context: model.contextWindow,
79
+ input: model.contextWindow,
80
+ output: model.maxTokens,
81
+ },
82
+ };
83
+ }
84
+ async function authOptionsFromV2Connection(ctx, id) {
85
+ const connection = await ctx.integration.connection.active(id);
86
+ const credential = connection ? (await ctx.integration.connection.resolve(connection)) : undefined;
87
+ if (!credential)
88
+ return {};
89
+ if (credential.type === "key") {
90
+ return {
91
+ apiKey: credential.key,
92
+ qoderUserID: typeof credential.metadata?.userID === "string" ? credential.metadata.userID : undefined,
93
+ qoderEmail: typeof credential.metadata?.email === "string" ? credential.metadata.email : undefined,
94
+ qoderName: typeof credential.metadata?.name === "string" ? credential.metadata.name : undefined,
95
+ qoderMachineID: typeof credential.metadata?.machineID === "string" ? credential.metadata.machineID : undefined,
96
+ };
97
+ }
98
+ if (credential.type === "oauth") {
99
+ const decoded = decodeOAuthRefresh(credential.refresh || "");
100
+ return {
101
+ apiKey: credential.access,
102
+ qoderUserID: typeof credential.metadata?.userID === "string" ? credential.metadata.userID : credential.accountId || decoded.userID,
103
+ qoderEmail: typeof credential.metadata?.email === "string" ? credential.metadata.email : undefined,
104
+ qoderName: typeof credential.metadata?.name === "string" ? credential.metadata.name : undefined,
105
+ qoderMachineID: typeof credential.metadata?.machineID === "string" ? credential.metadata.machineID : decoded.machineID,
106
+ };
107
+ }
108
+ return {};
109
+ }
110
+ async function setupV2(ctx) {
111
+ const id = providerID(ctx.options);
112
+ await ctx.integration.transform((integrations) => {
113
+ integrations.update(id, (integration) => {
114
+ integration.name = PROVIDER_NAME;
115
+ });
116
+ integrations.method.update({ integrationID: id, method: { type: "key", label: "Qoder Personal Access Token" } });
117
+ integrations.method.update({ integrationID: id, method: { type: "env", names: [...QODER_PAT_ENV] } });
118
+ });
119
+ await ctx.catalog.transform((catalog) => {
120
+ catalog.provider.update(id, (provider) => {
121
+ provider.name = PROVIDER_NAME;
122
+ provider.integrationID = id;
123
+ provider.api = { type: "aisdk", package: "@ai-sdk/openai-compatible", url: QODER_BASE_URL, settings: {} };
124
+ provider.request = { headers: {}, body: {} };
125
+ const apiKey = optionString(ctx.options, "apiKey");
126
+ if (apiKey)
127
+ provider.request.body.apiKey = apiKey;
128
+ });
129
+ for (const model of QODER_MODELS) {
130
+ catalog.model.update(id, model.id, (draft) => {
131
+ Object.assign(draft, v2ModelConfig(model));
132
+ });
133
+ }
134
+ if (shouldSetDefault(ctx.options))
135
+ catalog.model.default.set(id, "auto");
136
+ });
137
+ await ctx.aisdk.language(async (event) => {
138
+ if (event.model.providerID !== id)
139
+ return;
140
+ const connectionOptions = await authOptionsFromV2Connection(ctx, id);
141
+ event.language = new QoderLanguageModel(String(event.model.api.id), {
142
+ ...event.options,
143
+ ...connectionOptions,
144
+ apiKey: connectionOptions.apiKey || optionString(ctx.options, "apiKey") || event.options.apiKey,
145
+ });
146
+ });
147
+ }
148
+ function abortableDelay(ms) {
149
+ return new Promise((resolve) => setTimeout(resolve, ms));
150
+ }
151
+ async function pollDeviceFlow(codeVerifier, nonce, machineID) {
152
+ const pollURL = `${QODER_OPENAPI_URL}/api/v1/deviceToken/poll?nonce=${encodeURIComponent(nonce)}&verifier=${encodeURIComponent(codeVerifier)}&challenge_method=S256`;
153
+ for (let attempt = 0; attempt < 90; attempt++) {
154
+ await abortableDelay(2000);
155
+ const response = await fetch(pollURL, {
156
+ method: "GET",
157
+ headers: { Accept: "application/json", "User-Agent": USER_AGENT },
158
+ });
159
+ if (response.status === 202 || response.status === 404)
160
+ continue;
161
+ if (!response.ok) {
162
+ const errText = await response.text().catch(() => "");
163
+ throw new Error(`Device token poll failed: ${response.status} ${response.statusText}. Response: ${errText}`);
164
+ }
165
+ const tokenData = (await response.json());
166
+ if (!tokenData.token)
167
+ throw new Error("Device token poll returned empty access token");
168
+ let email = "";
169
+ let name = "";
170
+ try {
171
+ const userinfoRes = await fetch(`${QODER_OPENAPI_URL}/api/v1/userinfo`, {
172
+ method: "GET",
173
+ headers: { Authorization: `Bearer ${tokenData.token}`, Accept: "application/json", "User-Agent": USER_AGENT },
174
+ });
175
+ if (userinfoRes.ok) {
176
+ const userinfo = (await userinfoRes.json());
177
+ email = userinfo.email || "";
178
+ name = userinfo.name || userinfo.username || "";
179
+ }
180
+ }
181
+ catch { }
182
+ const parsedExpires = tokenData.expires_at ? Date.parse(tokenData.expires_at) : Number.NaN;
183
+ const expires = Number.isFinite(parsedExpires)
184
+ ? parsedExpires
185
+ : Date.now() + (tokenData.expires_in || 30 * 24 * 60 * 60) * 1000;
186
+ return {
187
+ refresh: encodeOAuthRefresh(tokenData.refresh_token || "", tokenData.user_id || "", machineID),
188
+ access: tokenData.token,
189
+ expires: expires - 5 * 60 * 1000,
190
+ userID: tokenData.user_id || "qoder-user",
191
+ email: email || "user@qoder.com",
192
+ name: name || "Qoder User",
193
+ machineID,
194
+ };
195
+ }
196
+ throw new Error("Authorization timed out");
197
+ }
198
+ function legacyHooks(options) {
199
+ const id = providerID(options);
200
+ return {
201
+ config: async (cfg) => applyLegacyConfig(cfg, options),
202
+ auth: {
203
+ provider: id,
204
+ loader: async (auth) => {
205
+ const stored = (await auth());
206
+ if (!stored)
207
+ return {};
208
+ if (stored.type === "api") {
209
+ return {
210
+ apiKey: stored.key,
211
+ qoderUserID: stored.metadata?.userID,
212
+ qoderEmail: stored.metadata?.email,
213
+ qoderName: stored.metadata?.name,
214
+ qoderMachineID: stored.metadata?.machineID,
215
+ };
216
+ }
217
+ if (stored.type === "oauth") {
218
+ const decoded = decodeOAuthRefresh(stored.refresh || "");
219
+ return {
220
+ apiKey: stored.access,
221
+ qoderUserID: stored.accountId || decoded.userID,
222
+ qoderMachineID: decoded.machineID,
223
+ };
224
+ }
225
+ return {};
226
+ },
227
+ methods: [
228
+ {
229
+ type: "api",
230
+ label: "Personal Access Token",
231
+ },
232
+ {
233
+ type: "oauth",
234
+ label: "Browser Login",
235
+ authorize: async () => {
236
+ const { codeVerifier, codeChallenge } = generatePKCE();
237
+ const nonce = crypto.randomUUID();
238
+ const machineID = getMachineId();
239
+ const url = `https://qoder.com/device/selectAccounts?challenge=${codeChallenge}&challenge_method=S256&machine_id=${machineID}&nonce=${nonce}`;
240
+ return {
241
+ url,
242
+ instructions: "Complete the Qoder browser login, then return to opencode.",
243
+ method: "auto",
244
+ callback: async () => {
245
+ try {
246
+ const credential = await pollDeviceFlow(codeVerifier, nonce, machineID);
247
+ return {
248
+ type: "success",
249
+ provider: id,
250
+ refresh: credential.refresh,
251
+ access: credential.access,
252
+ expires: credential.expires,
253
+ accountId: credential.userID,
254
+ };
255
+ }
256
+ catch {
257
+ return { type: "failed" };
258
+ }
259
+ },
260
+ };
261
+ },
262
+ },
263
+ ],
264
+ },
265
+ };
266
+ }
267
+ const plugin = {
268
+ id: "opencode-qoder",
269
+ setup: setupV2,
270
+ server: async (_input, options) => legacyHooks(options),
271
+ };
272
+ export default plugin;
@@ -0,0 +1,18 @@
1
+ import type { LanguageModelV3, LanguageModelV3CallOptions, LanguageModelV3GenerateResult, LanguageModelV3StreamResult } from "@ai-sdk/provider";
2
+ import { type QoderProviderOptions } from "./auth.js";
3
+ export declare class QoderLanguageModel implements LanguageModelV3 {
4
+ readonly modelId: string;
5
+ private readonly providerOptions;
6
+ readonly specificationVersion: "v3";
7
+ readonly provider = "qoder";
8
+ readonly supportedUrls: {
9
+ "image/*": RegExp[];
10
+ };
11
+ constructor(modelId: string, providerOptions?: QoderProviderOptions);
12
+ doGenerate(options: LanguageModelV3CallOptions): Promise<LanguageModelV3GenerateResult>;
13
+ doStream(options: LanguageModelV3CallOptions): Promise<LanguageModelV3StreamResult>;
14
+ private responseToStream;
15
+ }
16
+ export declare function createQoder(options?: QoderProviderOptions): {
17
+ languageModel(modelID: string): LanguageModelV3;
18
+ };