opencode-kilocode-auth 1.0.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 +229 -0
- package/index.ts +11 -0
- package/package.json +21 -0
- package/src/cli/select-model.ts +111 -0
- package/src/constants.ts +62 -0
- package/src/kilocode/auth.ts +281 -0
- package/src/kilocode/models.ts +242 -0
- package/src/plugin/request.ts +165 -0
- package/src/plugin/types.ts +121 -0
- package/src/plugin.ts +231 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import {
|
|
3
|
+
KILOCODE_API_BASE_URL,
|
|
4
|
+
DEFAULT_HEADERS,
|
|
5
|
+
DEVICE_AUTH_POLL_INTERVAL_MS,
|
|
6
|
+
DEFAULT_MODEL_ID,
|
|
7
|
+
} from "../constants";
|
|
8
|
+
import type {
|
|
9
|
+
DeviceAuthInitiateResponse,
|
|
10
|
+
DeviceAuthPollResponse,
|
|
11
|
+
KilocodeProfileData,
|
|
12
|
+
KilocodeTokenExchangeResult,
|
|
13
|
+
ModelInfo,
|
|
14
|
+
} from "../plugin/types";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Get API URL based on token environment (dev vs prod)
|
|
18
|
+
*/
|
|
19
|
+
export function getApiUrl(path: string, token?: string): string {
|
|
20
|
+
if (token) {
|
|
21
|
+
try {
|
|
22
|
+
const payloadString = token.split(".")[1];
|
|
23
|
+
if (payloadString) {
|
|
24
|
+
const payloadJson = Buffer.from(payloadString, "base64").toString();
|
|
25
|
+
const payload = JSON.parse(payloadJson);
|
|
26
|
+
if (payload.env === "development") {
|
|
27
|
+
const customBackend = process.env.KILOCODE_BACKEND_BASE_URL;
|
|
28
|
+
if (customBackend) {
|
|
29
|
+
return new URL(path, customBackend).toString();
|
|
30
|
+
}
|
|
31
|
+
return `http://localhost:3000${path}`;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
// Ignore parsing errors
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return `${KILOCODE_API_BASE_URL}${path}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Initiate device authorization flow
|
|
43
|
+
*/
|
|
44
|
+
export async function initiateDeviceAuth(): Promise<DeviceAuthInitiateResponse> {
|
|
45
|
+
const response = await fetch(getApiUrl("/api/device-auth/codes"), {
|
|
46
|
+
method: "POST",
|
|
47
|
+
headers: DEFAULT_HEADERS,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
if (!response.ok) {
|
|
51
|
+
if (response.status === 429) {
|
|
52
|
+
throw new Error("Too many pending authorization requests. Please try again later.");
|
|
53
|
+
}
|
|
54
|
+
throw new Error(`Failed to initiate device authorization: ${response.status}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return (await response.json()) as DeviceAuthInitiateResponse;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Poll for device authorization status
|
|
62
|
+
*/
|
|
63
|
+
export async function pollDeviceAuth(code: string): Promise<DeviceAuthPollResponse> {
|
|
64
|
+
const response = await fetch(getApiUrl(`/api/device-auth/codes/${code}`));
|
|
65
|
+
|
|
66
|
+
if (response.status === 202) {
|
|
67
|
+
return { status: "pending" };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (response.status === 403) {
|
|
71
|
+
return { status: "denied" };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (response.status === 410) {
|
|
75
|
+
return { status: "expired" };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!response.ok) {
|
|
79
|
+
throw new Error(`Failed to poll device authorization: ${response.status}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return (await response.json()) as DeviceAuthPollResponse;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Fetch user profile from Kilo Code API
|
|
87
|
+
*/
|
|
88
|
+
export async function getKilocodeProfile(token: string): Promise<KilocodeProfileData> {
|
|
89
|
+
const response = await fetch(getApiUrl("/api/profile", token), {
|
|
90
|
+
headers: {
|
|
91
|
+
...DEFAULT_HEADERS,
|
|
92
|
+
Authorization: `Bearer ${token}`,
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
if (!response.ok) {
|
|
97
|
+
if (response.status === 401 || response.status === 403) {
|
|
98
|
+
throw new Error("INVALID_TOKEN");
|
|
99
|
+
}
|
|
100
|
+
throw new Error(`Failed to fetch profile: ${response.status}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return (await response.json()) as KilocodeProfileData;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Fetch default model from Kilo Code API
|
|
108
|
+
*/
|
|
109
|
+
export async function getKilocodeDefaultModel(
|
|
110
|
+
token: string,
|
|
111
|
+
organizationId?: string
|
|
112
|
+
): Promise<string> {
|
|
113
|
+
try {
|
|
114
|
+
const path = organizationId
|
|
115
|
+
? `/api/organizations/${organizationId}/defaults`
|
|
116
|
+
: `/api/defaults`;
|
|
117
|
+
|
|
118
|
+
const response = await fetch(getApiUrl(path, token), {
|
|
119
|
+
headers: {
|
|
120
|
+
...DEFAULT_HEADERS,
|
|
121
|
+
Authorization: `Bearer ${token}`,
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
if (!response.ok) {
|
|
126
|
+
throw new Error(`Failed to fetch default model: ${response.status}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const data = await response.json();
|
|
130
|
+
return data.defaultModel || DEFAULT_MODEL_ID;
|
|
131
|
+
} catch (error) {
|
|
132
|
+
console.error("Failed to get default model:", error);
|
|
133
|
+
return DEFAULT_MODEL_ID;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Fetch available models from Kilo Code API
|
|
139
|
+
*/
|
|
140
|
+
export async function getKilocodeModels(
|
|
141
|
+
token: string,
|
|
142
|
+
organizationId?: string
|
|
143
|
+
): Promise<Record<string, ModelInfo>> {
|
|
144
|
+
try {
|
|
145
|
+
const baseUrl = getApiUrl("/api/openrouter/models", token);
|
|
146
|
+
const headers: Record<string, string> = {
|
|
147
|
+
...DEFAULT_HEADERS,
|
|
148
|
+
Authorization: `Bearer ${token}`,
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
if (organizationId) {
|
|
152
|
+
headers["X-KILOCODE-ORGANIZATIONID"] = organizationId;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const response = await fetch(baseUrl, { headers });
|
|
156
|
+
|
|
157
|
+
if (!response.ok) {
|
|
158
|
+
throw new Error(`Failed to fetch models: ${response.status}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const data = await response.json();
|
|
162
|
+
const models: Record<string, ModelInfo> = {};
|
|
163
|
+
|
|
164
|
+
if (data.data && Array.isArray(data.data)) {
|
|
165
|
+
for (const model of data.data) {
|
|
166
|
+
models[model.id] = {
|
|
167
|
+
id: model.id,
|
|
168
|
+
name: model.name,
|
|
169
|
+
displayName: model.name,
|
|
170
|
+
contextWindow: model.context_length || 128000,
|
|
171
|
+
maxTokens: model.max_completion_tokens || model.top_provider?.max_completion_tokens,
|
|
172
|
+
supportsImages: model.architecture?.input_modalities?.includes("image"),
|
|
173
|
+
inputPrice: model.pricing?.prompt ? parseFloat(model.pricing.prompt) : undefined,
|
|
174
|
+
outputPrice: model.pricing?.completion ? parseFloat(model.pricing.completion) : undefined,
|
|
175
|
+
description: model.description,
|
|
176
|
+
preferredIndex: model.preferredIndex,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return models;
|
|
182
|
+
} catch (error) {
|
|
183
|
+
console.error("Failed to fetch models:", error);
|
|
184
|
+
return {};
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Open browser URL (cross-platform)
|
|
190
|
+
*/
|
|
191
|
+
export function openBrowserUrl(url: string): boolean {
|
|
192
|
+
try {
|
|
193
|
+
const platform = process.platform;
|
|
194
|
+
const command =
|
|
195
|
+
platform === "darwin"
|
|
196
|
+
? "open"
|
|
197
|
+
: platform === "win32"
|
|
198
|
+
? "rundll32"
|
|
199
|
+
: "xdg-open";
|
|
200
|
+
const args =
|
|
201
|
+
platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url];
|
|
202
|
+
|
|
203
|
+
const child = spawn(command, args, {
|
|
204
|
+
stdio: "ignore",
|
|
205
|
+
detached: true,
|
|
206
|
+
});
|
|
207
|
+
child.unref?.();
|
|
208
|
+
return true;
|
|
209
|
+
} catch {
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Execute the full device authorization flow
|
|
216
|
+
*/
|
|
217
|
+
export async function authenticateWithDeviceAuth(): Promise<KilocodeTokenExchangeResult> {
|
|
218
|
+
try {
|
|
219
|
+
// Step 1: Initiate device auth
|
|
220
|
+
const authData = await initiateDeviceAuth();
|
|
221
|
+
const { code, verificationUrl, expiresIn } = authData;
|
|
222
|
+
|
|
223
|
+
console.log(`\nVisit: ${verificationUrl}`);
|
|
224
|
+
console.log(`Verification code: ${code}`);
|
|
225
|
+
|
|
226
|
+
// Open browser
|
|
227
|
+
openBrowserUrl(verificationUrl);
|
|
228
|
+
|
|
229
|
+
// Step 2: Poll for authorization
|
|
230
|
+
const maxAttempts = Math.ceil((expiresIn * 1000) / DEVICE_AUTH_POLL_INTERVAL_MS);
|
|
231
|
+
let attempt = 0;
|
|
232
|
+
|
|
233
|
+
while (attempt < maxAttempts) {
|
|
234
|
+
await new Promise((resolve) => setTimeout(resolve, DEVICE_AUTH_POLL_INTERVAL_MS));
|
|
235
|
+
|
|
236
|
+
const pollResult = await pollDeviceAuth(code);
|
|
237
|
+
|
|
238
|
+
if (pollResult.status === "approved" && pollResult.token) {
|
|
239
|
+
// Get profile for organizations
|
|
240
|
+
let organizationId: string | undefined;
|
|
241
|
+
try {
|
|
242
|
+
const profile = await getKilocodeProfile(pollResult.token);
|
|
243
|
+
if (profile.organizations && profile.organizations.length > 0) {
|
|
244
|
+
// Use first organization by default
|
|
245
|
+
organizationId = profile.organizations[0].id;
|
|
246
|
+
}
|
|
247
|
+
} catch {
|
|
248
|
+
// Continue without organization
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Get default model
|
|
252
|
+
const model = await getKilocodeDefaultModel(pollResult.token, organizationId);
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
type: "success",
|
|
256
|
+
token: pollResult.token,
|
|
257
|
+
userEmail: pollResult.userEmail,
|
|
258
|
+
organizationId,
|
|
259
|
+
model,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (pollResult.status === "denied") {
|
|
264
|
+
return { type: "failed", error: "Authorization denied by user" };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (pollResult.status === "expired") {
|
|
268
|
+
return { type: "failed", error: "Authorization code expired" };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
attempt++;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return { type: "failed", error: "Authorization timed out" };
|
|
275
|
+
} catch (error) {
|
|
276
|
+
return {
|
|
277
|
+
type: "failed",
|
|
278
|
+
error: error instanceof Error ? error.message : "Unknown error",
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model utilities for Kilo Code
|
|
3
|
+
*
|
|
4
|
+
* This module provides utilities for working with Kilo Code models,
|
|
5
|
+
* including fetching available models, selecting models, and getting model info.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { getApiUrl } from "./auth";
|
|
9
|
+
import { DEFAULT_HEADERS, DEFAULT_MODEL_ID } from "../constants";
|
|
10
|
+
import type { ModelInfo } from "../plugin/types";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Giga Potato model details
|
|
14
|
+
* A stealth model optimized for agentic programming with vision capability
|
|
15
|
+
*/
|
|
16
|
+
export const GIGA_POTATO_MODEL = {
|
|
17
|
+
id: "giga-potato",
|
|
18
|
+
name: "Giga Potato (free)",
|
|
19
|
+
description: "A stealth model deeply optimized for agentic programming, with visual understanding capability. Free for a limited time.",
|
|
20
|
+
contextWindow: 256000,
|
|
21
|
+
maxTokens: 32000,
|
|
22
|
+
supportsImages: true,
|
|
23
|
+
supportsTools: true,
|
|
24
|
+
supportsReasoning: true,
|
|
25
|
+
inputPrice: 0,
|
|
26
|
+
outputPrice: 0,
|
|
27
|
+
preferredIndex: 2,
|
|
28
|
+
} as const;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Popular/Featured models available through Kilo Code
|
|
32
|
+
* These are commonly used models that users might want quick access to
|
|
33
|
+
*/
|
|
34
|
+
export const FEATURED_MODELS = [
|
|
35
|
+
// FREE Models (Top Priority)
|
|
36
|
+
"giga-potato", // Stealth model optimized for agentic programming - FREE!
|
|
37
|
+
"x-ai/grok-code-fast-1", // xAI Grok Code Fast - FREE
|
|
38
|
+
"mistralai/devstral-2512:free", // Mistral Devstral - FREE
|
|
39
|
+
"mistralai/devstral-small-2512:free", // Mistral Devstral Small - FREE
|
|
40
|
+
"corethink:free", // CoreThink - FREE
|
|
41
|
+
|
|
42
|
+
// Anthropic Claude models
|
|
43
|
+
"anthropic/claude-opus-4.5",
|
|
44
|
+
"anthropic/claude-sonnet-4.5",
|
|
45
|
+
"anthropic/claude-haiku-4.5",
|
|
46
|
+
"anthropic/claude-sonnet-4",
|
|
47
|
+
"anthropic/claude-opus-4",
|
|
48
|
+
"anthropic/claude-haiku-4",
|
|
49
|
+
"anthropic/claude-3.7-sonnet",
|
|
50
|
+
"anthropic/claude-3.7-sonnet:thinking",
|
|
51
|
+
|
|
52
|
+
// OpenAI models
|
|
53
|
+
"openai/gpt-5.2",
|
|
54
|
+
"openai/gpt-5.2-codex",
|
|
55
|
+
"openai/gpt-4.1",
|
|
56
|
+
"openai/gpt-4.1-mini",
|
|
57
|
+
"openai/gpt-4o",
|
|
58
|
+
"openai/o1",
|
|
59
|
+
"openai/o1-mini",
|
|
60
|
+
"openai/o3-mini",
|
|
61
|
+
|
|
62
|
+
// Google Gemini models
|
|
63
|
+
"google/gemini-3-pro-preview",
|
|
64
|
+
"google/gemini-3-flash-preview",
|
|
65
|
+
"google/gemini-2.5-pro",
|
|
66
|
+
"google/gemini-2.5-flash",
|
|
67
|
+
|
|
68
|
+
// DeepSeek models
|
|
69
|
+
"deepseek/deepseek-r1",
|
|
70
|
+
"deepseek/deepseek-chat",
|
|
71
|
+
"deepseek/deepseek-coder",
|
|
72
|
+
|
|
73
|
+
// Meta Llama models
|
|
74
|
+
"meta-llama/llama-3.3-70b-instruct",
|
|
75
|
+
"meta-llama/llama-3.1-405b-instruct",
|
|
76
|
+
|
|
77
|
+
// Mistral models
|
|
78
|
+
"mistralai/mistral-large-2411",
|
|
79
|
+
"mistralai/codestral-2508",
|
|
80
|
+
|
|
81
|
+
// xAI Grok models
|
|
82
|
+
"x-ai/grok-3",
|
|
83
|
+
"x-ai/grok-3-mini",
|
|
84
|
+
] as const;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Model categories for organization
|
|
88
|
+
*/
|
|
89
|
+
export const MODEL_CATEGORIES = {
|
|
90
|
+
anthropic: ["anthropic/claude-sonnet-4", "anthropic/claude-opus-4", "anthropic/claude-haiku-4"],
|
|
91
|
+
openai: ["openai/gpt-4.1", "openai/gpt-4o", "openai/o1", "openai/o3-mini"],
|
|
92
|
+
google: ["google/gemini-2.5-pro", "google/gemini-2.5-flash"],
|
|
93
|
+
deepseek: ["deepseek/deepseek-r1", "deepseek/deepseek-chat"],
|
|
94
|
+
meta: ["meta-llama/llama-3.3-70b-instruct"],
|
|
95
|
+
mistral: ["mistralai/mistral-large-2411", "mistralai/codestral-2508"],
|
|
96
|
+
xai: ["x-ai/grok-3", "x-ai/grok-3-mini"],
|
|
97
|
+
} as const;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Fetch all available models from Kilo Code API
|
|
101
|
+
*/
|
|
102
|
+
export async function fetchAllModels(
|
|
103
|
+
token: string,
|
|
104
|
+
organizationId?: string
|
|
105
|
+
): Promise<ModelInfo[]> {
|
|
106
|
+
try {
|
|
107
|
+
const baseUrl = getApiUrl("/api/openrouter/models", token);
|
|
108
|
+
const headers: Record<string, string> = {
|
|
109
|
+
...DEFAULT_HEADERS,
|
|
110
|
+
Authorization: `Bearer ${token}`,
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
if (organizationId) {
|
|
114
|
+
headers["X-KILOCODE-ORGANIZATIONID"] = organizationId;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const response = await fetch(baseUrl, { headers });
|
|
118
|
+
|
|
119
|
+
if (!response.ok) {
|
|
120
|
+
throw new Error(`Failed to fetch models: ${response.status}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const data = await response.json();
|
|
124
|
+
const models: ModelInfo[] = [];
|
|
125
|
+
|
|
126
|
+
if (data.data && Array.isArray(data.data)) {
|
|
127
|
+
for (const model of data.data) {
|
|
128
|
+
models.push({
|
|
129
|
+
id: model.id,
|
|
130
|
+
name: model.name,
|
|
131
|
+
displayName: model.name,
|
|
132
|
+
contextWindow: model.context_length || 128000,
|
|
133
|
+
maxTokens: model.max_completion_tokens || model.top_provider?.max_completion_tokens,
|
|
134
|
+
supportsImages: model.architecture?.input_modalities?.includes("image"),
|
|
135
|
+
inputPrice: model.pricing?.prompt ? parseFloat(model.pricing.prompt) : undefined,
|
|
136
|
+
outputPrice: model.pricing?.completion ? parseFloat(model.pricing.completion) : undefined,
|
|
137
|
+
description: model.description,
|
|
138
|
+
preferredIndex: model.preferredIndex,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Sort by preferred index if available, otherwise alphabetically
|
|
144
|
+
models.sort((a, b) => {
|
|
145
|
+
if (a.preferredIndex !== undefined && b.preferredIndex !== undefined) {
|
|
146
|
+
return a.preferredIndex - b.preferredIndex;
|
|
147
|
+
}
|
|
148
|
+
if (a.preferredIndex !== undefined) return -1;
|
|
149
|
+
if (b.preferredIndex !== undefined) return 1;
|
|
150
|
+
return a.id.localeCompare(b.id);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
return models;
|
|
154
|
+
} catch (error) {
|
|
155
|
+
console.error("Failed to fetch models:", error);
|
|
156
|
+
return [];
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Search models by name or ID
|
|
162
|
+
*/
|
|
163
|
+
export function searchModels(models: ModelInfo[], query: string): ModelInfo[] {
|
|
164
|
+
const lowerQuery = query.toLowerCase();
|
|
165
|
+
return models.filter(
|
|
166
|
+
(model) =>
|
|
167
|
+
model.id.toLowerCase().includes(lowerQuery) ||
|
|
168
|
+
model.name.toLowerCase().includes(lowerQuery) ||
|
|
169
|
+
model.description?.toLowerCase().includes(lowerQuery)
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Get models by category/provider
|
|
175
|
+
*/
|
|
176
|
+
export function getModelsByCategory(
|
|
177
|
+
models: ModelInfo[],
|
|
178
|
+
category: keyof typeof MODEL_CATEGORIES
|
|
179
|
+
): ModelInfo[] {
|
|
180
|
+
const categoryIds = MODEL_CATEGORIES[category];
|
|
181
|
+
return models.filter((model) =>
|
|
182
|
+
categoryIds.some((id) => model.id.startsWith(id.split("/")[0]))
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Get featured models from the full model list
|
|
188
|
+
*/
|
|
189
|
+
export function getFeaturedModels(models: ModelInfo[]): ModelInfo[] {
|
|
190
|
+
const featured: ModelInfo[] = [];
|
|
191
|
+
|
|
192
|
+
for (const featuredId of FEATURED_MODELS) {
|
|
193
|
+
const model = models.find((m) => m.id === featuredId);
|
|
194
|
+
if (model) {
|
|
195
|
+
featured.push(model);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return featured;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Format model for display
|
|
204
|
+
*/
|
|
205
|
+
export function formatModelDisplay(model: ModelInfo): string {
|
|
206
|
+
let display = `${model.displayName || model.name} (${model.id})`;
|
|
207
|
+
|
|
208
|
+
if (model.contextWindow) {
|
|
209
|
+
const contextK = Math.round(model.contextWindow / 1000);
|
|
210
|
+
display += ` - ${contextK}K context`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (model.supportsImages) {
|
|
214
|
+
display += " [Vision]";
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return display;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Get model info by ID
|
|
222
|
+
*/
|
|
223
|
+
export function getModelById(
|
|
224
|
+
models: ModelInfo[],
|
|
225
|
+
modelId: string
|
|
226
|
+
): ModelInfo | undefined {
|
|
227
|
+
return models.find((m) => m.id === modelId);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Check if a model supports images/vision
|
|
232
|
+
*/
|
|
233
|
+
export function modelSupportsVision(model: ModelInfo): boolean {
|
|
234
|
+
return model.supportsImages === true;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Get default model ID
|
|
239
|
+
*/
|
|
240
|
+
export function getDefaultModelId(): string {
|
|
241
|
+
return DEFAULT_MODEL_ID;
|
|
242
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import {
|
|
2
|
+
KILOCODE_API_BASE_URL,
|
|
3
|
+
DEFAULT_HEADERS,
|
|
4
|
+
X_KILOCODE_ORGANIZATIONID,
|
|
5
|
+
X_KILOCODE_EDITORNAME,
|
|
6
|
+
OPENROUTER_PATH,
|
|
7
|
+
} from "../constants";
|
|
8
|
+
import { getApiUrl } from "../kilocode/auth";
|
|
9
|
+
import type { KilocodeAuthDetails } from "./types";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Check if the request is targeting an OpenAI-compatible API that should be intercepted
|
|
13
|
+
*/
|
|
14
|
+
export function isKilocodeRequest(input: RequestInfo): boolean {
|
|
15
|
+
const url = toUrlString(input);
|
|
16
|
+
|
|
17
|
+
// Intercept requests to common OpenAI-compatible endpoints
|
|
18
|
+
const interceptPatterns = [
|
|
19
|
+
"/v1/chat/completions",
|
|
20
|
+
"/v1/completions",
|
|
21
|
+
"/v1/models",
|
|
22
|
+
"/chat/completions",
|
|
23
|
+
"/completions",
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
return interceptPatterns.some((pattern) => url.includes(pattern));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Convert RequestInfo to URL string
|
|
31
|
+
*/
|
|
32
|
+
function toUrlString(value: RequestInfo): string {
|
|
33
|
+
if (typeof value === "string") {
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
return (value as Request).url || value.toString();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Prepare the Kilo Code API request by rewriting URL and adding auth headers
|
|
41
|
+
*/
|
|
42
|
+
export function prepareKilocodeRequest(
|
|
43
|
+
input: RequestInfo,
|
|
44
|
+
init: RequestInit | undefined,
|
|
45
|
+
auth: KilocodeAuthDetails
|
|
46
|
+
): {
|
|
47
|
+
request: string;
|
|
48
|
+
init: RequestInit;
|
|
49
|
+
streaming: boolean;
|
|
50
|
+
} {
|
|
51
|
+
const originalUrl = toUrlString(input);
|
|
52
|
+
|
|
53
|
+
// Determine the endpoint path
|
|
54
|
+
let targetPath = OPENROUTER_PATH + "v1/chat/completions";
|
|
55
|
+
|
|
56
|
+
if (originalUrl.includes("/v1/models") || originalUrl.includes("/models")) {
|
|
57
|
+
targetPath = OPENROUTER_PATH + "models";
|
|
58
|
+
} else if (originalUrl.includes("/completions") && !originalUrl.includes("/chat/")) {
|
|
59
|
+
targetPath = OPENROUTER_PATH + "v1/completions";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Build the new URL
|
|
63
|
+
const newUrl = getApiUrl(targetPath, auth.token);
|
|
64
|
+
|
|
65
|
+
// Build headers
|
|
66
|
+
const existingHeaders = init?.headers || {};
|
|
67
|
+
const headerEntries = existingHeaders instanceof Headers
|
|
68
|
+
? Array.from(existingHeaders.entries())
|
|
69
|
+
: Object.entries(existingHeaders as Record<string, string>);
|
|
70
|
+
|
|
71
|
+
const headers: Record<string, string> = {
|
|
72
|
+
...DEFAULT_HEADERS,
|
|
73
|
+
...Object.fromEntries(headerEntries),
|
|
74
|
+
Authorization: `Bearer ${auth.token}`,
|
|
75
|
+
[X_KILOCODE_EDITORNAME]: "opencode",
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// Add organization header if present
|
|
79
|
+
if (auth.organizationId) {
|
|
80
|
+
headers[X_KILOCODE_ORGANIZATIONID] = auth.organizationId;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Process request body to inject model if needed
|
|
84
|
+
let body = init?.body;
|
|
85
|
+
let streaming = false;
|
|
86
|
+
|
|
87
|
+
if (body && typeof body === "string") {
|
|
88
|
+
try {
|
|
89
|
+
const parsed = JSON.parse(body);
|
|
90
|
+
|
|
91
|
+
// Override model if specified in auth
|
|
92
|
+
if (auth.model && !parsed.model) {
|
|
93
|
+
parsed.model = auth.model;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Check if streaming
|
|
97
|
+
streaming = parsed.stream === true;
|
|
98
|
+
|
|
99
|
+
body = JSON.stringify(parsed);
|
|
100
|
+
} catch {
|
|
101
|
+
// Keep original body if not JSON
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
request: newUrl,
|
|
107
|
+
init: {
|
|
108
|
+
...init,
|
|
109
|
+
headers,
|
|
110
|
+
body,
|
|
111
|
+
},
|
|
112
|
+
streaming,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Transform Kilo Code response back to standard format
|
|
118
|
+
*/
|
|
119
|
+
export async function transformKilocodeResponse(
|
|
120
|
+
response: Response,
|
|
121
|
+
streaming: boolean
|
|
122
|
+
): Promise<Response> {
|
|
123
|
+
// For non-streaming responses, just pass through
|
|
124
|
+
if (!streaming) {
|
|
125
|
+
return response;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// For streaming, we might need to normalize the SSE format
|
|
129
|
+
// Kilo Code uses OpenRouter which is OpenAI-compatible, so mostly pass-through
|
|
130
|
+
return response;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Fetch with retry logic for rate limiting
|
|
135
|
+
*/
|
|
136
|
+
export async function fetchWithRetry(
|
|
137
|
+
input: RequestInfo,
|
|
138
|
+
init: RequestInit | undefined,
|
|
139
|
+
maxRetries = 2
|
|
140
|
+
): Promise<Response> {
|
|
141
|
+
const retryableStatuses = new Set([429, 503]);
|
|
142
|
+
let attempt = 0;
|
|
143
|
+
|
|
144
|
+
while (true) {
|
|
145
|
+
const response = await fetch(input, init);
|
|
146
|
+
|
|
147
|
+
if (!retryableStatuses.has(response.status) || attempt >= maxRetries) {
|
|
148
|
+
return response;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Get retry delay from headers or use exponential backoff
|
|
152
|
+
const retryAfter = response.headers.get("retry-after");
|
|
153
|
+
let delayMs = 800 * Math.pow(2, attempt);
|
|
154
|
+
|
|
155
|
+
if (retryAfter) {
|
|
156
|
+
const parsed = parseInt(retryAfter, 10);
|
|
157
|
+
if (!isNaN(parsed)) {
|
|
158
|
+
delayMs = parsed * 1000;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(delayMs, 8000)));
|
|
163
|
+
attempt++;
|
|
164
|
+
}
|
|
165
|
+
}
|