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.
@@ -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
+ }