@bitkyc08/opencodex 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 +21 -0
- package/README.ko.md +164 -0
- package/README.md +165 -0
- package/README.zh-CN.md +162 -0
- package/gui/README.md +73 -0
- package/gui/dist/assets/index-C1wlp1SM.css +1 -0
- package/gui/dist/assets/index-C9y3iMF1.js +9 -0
- package/gui/dist/favicon.png +0 -0
- package/gui/dist/icons.svg +24 -0
- package/gui/dist/index.html +15 -0
- package/gui/dist/logo.png +0 -0
- package/package.json +56 -0
- package/scripts/postinstall.mjs +57 -0
- package/src/adapters/anthropic.ts +306 -0
- package/src/adapters/azure.ts +31 -0
- package/src/adapters/base.ts +20 -0
- package/src/adapters/google.ts +195 -0
- package/src/adapters/image.ts +23 -0
- package/src/adapters/openai-chat.ts +265 -0
- package/src/adapters/openai-responses.ts +43 -0
- package/src/bridge.ts +296 -0
- package/src/cli.ts +183 -0
- package/src/codex-catalog.ts +318 -0
- package/src/codex-inject.ts +186 -0
- package/src/config.ts +108 -0
- package/src/index.ts +20 -0
- package/src/init.ts +163 -0
- package/src/model-cache.ts +42 -0
- package/src/oauth/anthropic.ts +151 -0
- package/src/oauth/callback-server.ts +249 -0
- package/src/oauth/index.ts +235 -0
- package/src/oauth/key-providers.ts +126 -0
- package/src/oauth/kimi.ts +160 -0
- package/src/oauth/local-token-detect.ts +71 -0
- package/src/oauth/login-cli.ts +90 -0
- package/src/oauth/pkce.ts +15 -0
- package/src/oauth/store.ts +39 -0
- package/src/oauth/types.ts +22 -0
- package/src/oauth/xai.ts +234 -0
- package/src/responses/parser.ts +402 -0
- package/src/responses/schema.ts +145 -0
- package/src/router.ts +86 -0
- package/src/server.ts +522 -0
- package/src/service.ts +130 -0
- package/src/star-prompt.ts +50 -0
- package/src/types.ts +228 -0
- package/src/update.ts +64 -0
- package/src/vision/describe.ts +98 -0
- package/src/vision/index.ts +141 -0
- package/src/web-search/executor.ts +75 -0
- package/src/web-search/format-result.ts +45 -0
- package/src/web-search/index.ts +62 -0
- package/src/web-search/loop.ts +188 -0
- package/src/web-search/parse.ts +128 -0
- package/src/web-search/synthetic-tool.ts +42 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Abstract base class for OAuth flows with local callback servers.
|
|
3
|
+
* Ported from jawcode packages/ai/src/utils/oauth/callback-server.ts.
|
|
4
|
+
*
|
|
5
|
+
* Change vs source: the success/error page is an inline HTML constant. opencodex's GUI polls
|
|
6
|
+
* GET /api/oauth/status, so it does not need OAuth state injected into the callback page.
|
|
7
|
+
*
|
|
8
|
+
* Handles: port allocation (preferred → random fallback), callback server, CSRF state,
|
|
9
|
+
* manual-input race, 300s timeout. Providers implement generateAuthUrl() + exchangeToken().
|
|
10
|
+
*/
|
|
11
|
+
import type { OAuthController, OAuthCredentials } from "./types";
|
|
12
|
+
|
|
13
|
+
const DEFAULT_TIMEOUT = 300_000;
|
|
14
|
+
const DEFAULT_HOSTNAME = "localhost";
|
|
15
|
+
const CALLBACK_PATH = "/callback";
|
|
16
|
+
|
|
17
|
+
const SUCCESS_HTML =
|
|
18
|
+
"<!doctype html><html><head><meta charset='utf-8'><title>opencodex</title></head>" +
|
|
19
|
+
"<body style='font-family:system-ui,sans-serif;text-align:center;padding:4rem;color:#111'>" +
|
|
20
|
+
"<h2>✅ Login complete</h2><p>You can close this tab and return to opencodex.</p></body></html>";
|
|
21
|
+
|
|
22
|
+
function escapeHtml(s: string): string {
|
|
23
|
+
return s.replace(/[&<>"']/g, (c) =>
|
|
24
|
+
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] ?? c,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function errorHtml(message: string): string {
|
|
29
|
+
return (
|
|
30
|
+
"<!doctype html><html><head><meta charset='utf-8'><title>opencodex</title></head>" +
|
|
31
|
+
"<body style='font-family:system-ui,sans-serif;text-align:center;padding:4rem;color:#111'>" +
|
|
32
|
+
`<h2>⚠ Login failed</h2><p>${escapeHtml(message)}</p></body></html>`
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type CallbackResult = { code: string; state: string };
|
|
37
|
+
|
|
38
|
+
export interface OAuthCallbackFlowOptions {
|
|
39
|
+
preferredPort: number;
|
|
40
|
+
callbackPath?: string;
|
|
41
|
+
callbackHostname?: string;
|
|
42
|
+
/** Local listener hostname; defaults to callbackHostname when omitted. */
|
|
43
|
+
callbackBindHostname?: string;
|
|
44
|
+
/** Exact redirect URI advertised to the provider; disables port fallback. */
|
|
45
|
+
redirectUri?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type BunServer = ReturnType<typeof Bun.serve>;
|
|
49
|
+
|
|
50
|
+
export abstract class OAuthCallbackFlow {
|
|
51
|
+
ctrl: OAuthController;
|
|
52
|
+
preferredPort: number;
|
|
53
|
+
callbackPath: string;
|
|
54
|
+
callbackHostname: string;
|
|
55
|
+
callbackBindHostname: string;
|
|
56
|
+
redirectUri?: string;
|
|
57
|
+
#callbackResolve?: (result: CallbackResult) => void;
|
|
58
|
+
#callbackReject?: (error: string) => void;
|
|
59
|
+
|
|
60
|
+
constructor(
|
|
61
|
+
ctrl: OAuthController,
|
|
62
|
+
preferredPortOrOptions: number | OAuthCallbackFlowOptions,
|
|
63
|
+
callbackPath: string = CALLBACK_PATH,
|
|
64
|
+
) {
|
|
65
|
+
this.ctrl = ctrl;
|
|
66
|
+
if (typeof preferredPortOrOptions === "number") {
|
|
67
|
+
this.preferredPort = preferredPortOrOptions;
|
|
68
|
+
this.callbackPath = callbackPath;
|
|
69
|
+
this.callbackHostname = DEFAULT_HOSTNAME;
|
|
70
|
+
this.callbackBindHostname = DEFAULT_HOSTNAME;
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
this.preferredPort = preferredPortOrOptions.preferredPort;
|
|
74
|
+
this.callbackPath = preferredPortOrOptions.callbackPath ?? CALLBACK_PATH;
|
|
75
|
+
this.callbackHostname = preferredPortOrOptions.callbackHostname ?? DEFAULT_HOSTNAME;
|
|
76
|
+
this.callbackBindHostname = preferredPortOrOptions.callbackBindHostname ?? this.callbackHostname;
|
|
77
|
+
this.redirectUri = preferredPortOrOptions.redirectUri;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Build provider-specific authorization URL. */
|
|
81
|
+
abstract generateAuthUrl(state: string, redirectUri: string): Promise<{ url: string; instructions?: string }>;
|
|
82
|
+
|
|
83
|
+
/** Exchange authorization code for OAuth tokens. */
|
|
84
|
+
abstract exchangeToken(code: string, state: string, redirectUri: string): Promise<OAuthCredentials>;
|
|
85
|
+
|
|
86
|
+
/** Generate CSRF state token. */
|
|
87
|
+
generateState(): string {
|
|
88
|
+
const bytes = new Uint8Array(16);
|
|
89
|
+
crypto.getRandomValues(bytes);
|
|
90
|
+
return Array.from(bytes)
|
|
91
|
+
.map((value) => value.toString(16).padStart(2, "0"))
|
|
92
|
+
.join("");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Execute the OAuth login flow. */
|
|
96
|
+
async login(): Promise<OAuthCredentials> {
|
|
97
|
+
const state = this.generateState();
|
|
98
|
+
const { server, redirectUri } = await this.#startCallbackServer(state);
|
|
99
|
+
try {
|
|
100
|
+
const { url: authUrl, instructions } = await this.generateAuthUrl(state, redirectUri);
|
|
101
|
+
this.ctrl.onAuth?.({ url: authUrl, instructions });
|
|
102
|
+
this.ctrl.onProgress?.("Waiting for browser authentication...");
|
|
103
|
+
const { code } = await this.#waitForCallback(state);
|
|
104
|
+
this.ctrl.onProgress?.("Exchanging authorization code for tokens...");
|
|
105
|
+
return await this.exchangeToken(code, state, redirectUri);
|
|
106
|
+
} finally {
|
|
107
|
+
server.stop();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async #startCallbackServer(expectedState: string): Promise<{ server: BunServer; redirectUri: string }> {
|
|
112
|
+
try {
|
|
113
|
+
const server = this.#createServer(this.preferredPort, expectedState);
|
|
114
|
+
if (this.redirectUri) {
|
|
115
|
+
return { server, redirectUri: this.redirectUri };
|
|
116
|
+
}
|
|
117
|
+
const redirectUri = `http://${this.callbackHostname}:${this.preferredPort}${this.callbackPath}`;
|
|
118
|
+
return { server, redirectUri };
|
|
119
|
+
} catch {
|
|
120
|
+
if (this.redirectUri) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`OAuth callback port ${this.preferredPort} unavailable; cannot fall back to a random port when redirectUri is set`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
const server = this.#createServer(0, expectedState);
|
|
126
|
+
const actualPort = server.port;
|
|
127
|
+
const redirectUri = `http://${this.callbackHostname}:${actualPort}${this.callbackPath}`;
|
|
128
|
+
this.ctrl.onProgress?.(`Preferred port ${this.preferredPort} unavailable, using port ${actualPort}`);
|
|
129
|
+
return { server, redirectUri };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
#createServer(port: number, expectedState: string): BunServer {
|
|
134
|
+
return Bun.serve({
|
|
135
|
+
hostname: this.callbackBindHostname,
|
|
136
|
+
port,
|
|
137
|
+
reusePort: false,
|
|
138
|
+
fetch: (req: Request) => this.#handleCallback(req, expectedState),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
#handleCallback(req: Request, expectedState: string): Response {
|
|
143
|
+
const url = new URL(req.url);
|
|
144
|
+
if (url.pathname !== this.callbackPath) {
|
|
145
|
+
return new Response("Not Found", { status: 404 });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const code = url.searchParams.get("code");
|
|
149
|
+
const state = url.searchParams.get("state") || "";
|
|
150
|
+
const error = url.searchParams.get("error") || "";
|
|
151
|
+
const errorDescription = url.searchParams.get("error_description") || error;
|
|
152
|
+
|
|
153
|
+
let ok = false;
|
|
154
|
+
let errMessage = "";
|
|
155
|
+
if (error) {
|
|
156
|
+
errMessage = `Authorization failed: ${errorDescription}`;
|
|
157
|
+
} else if (!code) {
|
|
158
|
+
errMessage = "Missing authorization code";
|
|
159
|
+
} else if (expectedState && state !== expectedState) {
|
|
160
|
+
errMessage = "State mismatch - possible CSRF attack";
|
|
161
|
+
} else {
|
|
162
|
+
ok = true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Capture refs before they could be cleared, then resolve on the next microtask.
|
|
166
|
+
const resolve = this.#callbackResolve;
|
|
167
|
+
const reject = this.#callbackReject;
|
|
168
|
+
queueMicrotask(() => {
|
|
169
|
+
if (ok && code) {
|
|
170
|
+
resolve?.({ code, state });
|
|
171
|
+
} else {
|
|
172
|
+
reject?.(errMessage || "Unknown error");
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
return new Response(ok ? SUCCESS_HTML : errorHtml(errMessage), {
|
|
177
|
+
status: ok ? 200 : 500,
|
|
178
|
+
headers: { "Content-Type": "text/html" },
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
#waitForCallback(expectedState: string): Promise<CallbackResult> {
|
|
183
|
+
const timeoutSignal = AbortSignal.timeout(DEFAULT_TIMEOUT);
|
|
184
|
+
const signal = this.ctrl.signal ? AbortSignal.any([this.ctrl.signal, timeoutSignal]) : timeoutSignal;
|
|
185
|
+
|
|
186
|
+
const callbackPromise = new Promise<CallbackResult>((resolve, reject) => {
|
|
187
|
+
this.#callbackResolve = resolve;
|
|
188
|
+
this.#callbackReject = (e: string) => reject(new Error(e));
|
|
189
|
+
|
|
190
|
+
signal.addEventListener("abort", () => {
|
|
191
|
+
this.#callbackResolve = undefined;
|
|
192
|
+
this.#callbackReject = undefined;
|
|
193
|
+
reject(new Error(`OAuth callback cancelled: ${signal.reason}`));
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
if (this.ctrl.onManualCodeInput) {
|
|
198
|
+
const requestManualInput = this.ctrl.onManualCodeInput;
|
|
199
|
+
const manualPromise = (async (): Promise<CallbackResult> => {
|
|
200
|
+
while (true) {
|
|
201
|
+
const result = await Promise.race([
|
|
202
|
+
callbackPromise,
|
|
203
|
+
requestManualInput()
|
|
204
|
+
.then((input): CallbackResult | null => {
|
|
205
|
+
const parsed = parseCallbackInput(input);
|
|
206
|
+
if (!parsed.code) return null;
|
|
207
|
+
if (expectedState && parsed.state && parsed.state !== expectedState) return null;
|
|
208
|
+
return { code: parsed.code, state: parsed.state ?? "" };
|
|
209
|
+
})
|
|
210
|
+
.catch((): CallbackResult | null => null),
|
|
211
|
+
]);
|
|
212
|
+
if (result) return result;
|
|
213
|
+
}
|
|
214
|
+
})();
|
|
215
|
+
|
|
216
|
+
return Promise.race([callbackPromise, manualPromise]);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return callbackPromise;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Parse a redirect URL or code string to extract code and state. */
|
|
224
|
+
export function parseCallbackInput(input: string): { code?: string; state?: string } {
|
|
225
|
+
const value = input.trim();
|
|
226
|
+
if (!value) return {};
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
const url = new URL(value);
|
|
230
|
+
return {
|
|
231
|
+
code: url.searchParams.get("code") ?? undefined,
|
|
232
|
+
state: url.searchParams.get("state") ?? undefined,
|
|
233
|
+
};
|
|
234
|
+
} catch {
|
|
235
|
+
// Not a URL - check for query string format
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (value.includes("code=")) {
|
|
239
|
+
const params = new URLSearchParams(value.replace(/^[?#]/, ""));
|
|
240
|
+
return {
|
|
241
|
+
code: params.get("code") ?? undefined,
|
|
242
|
+
state: params.get("state") ?? undefined,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Assume raw code, possibly with state after #
|
|
247
|
+
const [code, state] = value.split("#", 2);
|
|
248
|
+
return { code, state };
|
|
249
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import type { OAuthController, OAuthCredentials } from "./types";
|
|
2
|
+
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
3
|
+
import { loadConfig, resolveEnvValue, saveConfig } from "../config";
|
|
4
|
+
import { getCredential, saveCredential } from "./store";
|
|
5
|
+
import { loginXai, refreshXaiToken } from "./xai";
|
|
6
|
+
import { ANTHROPIC_OAUTH_BETA, loginAnthropic, refreshAnthropicToken } from "./anthropic";
|
|
7
|
+
import { loginKimi, refreshKimiToken } from "./kimi";
|
|
8
|
+
|
|
9
|
+
const REFRESH_SKEW_MS = 60_000;
|
|
10
|
+
|
|
11
|
+
interface OAuthProviderDef {
|
|
12
|
+
login(ctrl: OAuthController): Promise<OAuthCredentials>;
|
|
13
|
+
refresh(refreshToken: string, signal?: AbortSignal): Promise<OAuthCredentials>;
|
|
14
|
+
/** provider entry written into config.json on first login. */
|
|
15
|
+
providerConfig: OcxProviderConfig;
|
|
16
|
+
defaultModel: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const OAUTH_PROVIDERS: Record<string, OAuthProviderDef> = {
|
|
20
|
+
xai: {
|
|
21
|
+
login: (ctrl) => loginXai(ctrl, { importLocal: "fallback" }),
|
|
22
|
+
refresh: refreshXaiToken,
|
|
23
|
+
providerConfig: {
|
|
24
|
+
adapter: "openai-chat",
|
|
25
|
+
baseUrl: "https://api.x.ai/v1",
|
|
26
|
+
authMode: "oauth",
|
|
27
|
+
// Real xAI model ids (verified live via GET api.x.ai/v1/models); the proxy also fetches
|
|
28
|
+
// the live list at sync time, so this is the routing hint / fallback + explicit additions.
|
|
29
|
+
models: ["grok-4.3", "grok-4.20-0309-reasoning", "grok-4.20-0309-non-reasoning", "grok-build-0.1", "grok-composer-2.5-fast"],
|
|
30
|
+
defaultModel: "grok-4.3",
|
|
31
|
+
// These don't accept a reasoning/thinking param — never forward reasoning_effort for them.
|
|
32
|
+
noReasoningModels: ["grok-build-0.1", "grok-composer-2.5-fast"],
|
|
33
|
+
// These are text-only (no image input) — the vision sidecar describes images for them.
|
|
34
|
+
noVisionModels: ["grok-build-0.1", "grok-composer-2.5-fast"],
|
|
35
|
+
},
|
|
36
|
+
defaultModel: "grok-4.3",
|
|
37
|
+
},
|
|
38
|
+
anthropic: {
|
|
39
|
+
login: (ctrl) => loginAnthropic(ctrl, { importLocal: "fallback" }),
|
|
40
|
+
refresh: refreshAnthropicToken,
|
|
41
|
+
providerConfig: {
|
|
42
|
+
adapter: "anthropic",
|
|
43
|
+
baseUrl: "https://api.anthropic.com",
|
|
44
|
+
authMode: "oauth",
|
|
45
|
+
// Current dateless flagship ids — routing hint / fallback only; the proxy fetches the live
|
|
46
|
+
// list from Anthropic's GET /v1/models at sync time (always-latest), so this just seeds a
|
|
47
|
+
// sane set when that fetch is unavailable.
|
|
48
|
+
models: ["claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5"],
|
|
49
|
+
defaultModel: "claude-sonnet-4-6",
|
|
50
|
+
},
|
|
51
|
+
defaultModel: "claude-sonnet-4-6",
|
|
52
|
+
},
|
|
53
|
+
kimi: {
|
|
54
|
+
login: (ctrl) => loginKimi(ctrl),
|
|
55
|
+
refresh: refreshKimiToken,
|
|
56
|
+
providerConfig: {
|
|
57
|
+
adapter: "openai-chat",
|
|
58
|
+
baseUrl: "https://api.kimi.com/coding/v1",
|
|
59
|
+
authMode: "oauth",
|
|
60
|
+
models: ["kimi-k2.6", "kimi-k2.5"],
|
|
61
|
+
defaultModel: "kimi-k2.6",
|
|
62
|
+
},
|
|
63
|
+
defaultModel: "kimi-k2.6",
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export function isOAuthProvider(name: string): boolean {
|
|
68
|
+
return name in OAUTH_PROVIDERS;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Provider ids that support real OAuth login (drives the GUI's "Log in with …" buttons). */
|
|
72
|
+
export function listOAuthProviders(): string[] {
|
|
73
|
+
return Object.keys(OAUTH_PROVIDERS);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Return a valid access token, refreshing + persisting if expired. Throws if not logged in. */
|
|
77
|
+
export async function getValidAccessToken(provider: string): Promise<string> {
|
|
78
|
+
const def = OAUTH_PROVIDERS[provider];
|
|
79
|
+
if (!def) throw new Error(`Unknown OAuth provider: ${provider}`);
|
|
80
|
+
const cred = getCredential(provider);
|
|
81
|
+
if (!cred) throw new Error(`Not logged in to ${provider}. Run: ocx login ${provider}`);
|
|
82
|
+
if (cred.expires > Date.now() + REFRESH_SKEW_MS) return cred.access;
|
|
83
|
+
const fresh = await def.refresh(cred.refresh);
|
|
84
|
+
saveCredential(provider, fresh);
|
|
85
|
+
return fresh.access;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Shared bearer-token resolver for /models listing — used by BOTH server.ts:fetchAllModels and
|
|
90
|
+
* codex-catalog.ts:fetchProviderModels so OAuth providers' models are listed once logged in.
|
|
91
|
+
* Returns undefined for forward-mode or oauth-not-logged-in (caller skips).
|
|
92
|
+
*/
|
|
93
|
+
export async function resolveModelsAuthToken(name: string, prov: OcxProviderConfig): Promise<string | undefined> {
|
|
94
|
+
if (prov.authMode === "forward") return undefined;
|
|
95
|
+
if (prov.authMode === "oauth") {
|
|
96
|
+
try {
|
|
97
|
+
return await getValidAccessToken(name);
|
|
98
|
+
} catch {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return resolveEnvValue(prov.apiKey);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Provider-correct `GET /models` request (URL + headers), so both model-listing paths fetch the
|
|
107
|
+
* LIVE catalog correctly per adapter. Anthropic is the special case: its endpoint is `/v1/models`
|
|
108
|
+
* (not `/models`), it needs `anthropic-version`, and it authenticates with `x-api-key` (key) or
|
|
109
|
+
* `Authorization: Bearer` + the OAuth beta (oauth) — not a bare Bearer. Everyone else uses the
|
|
110
|
+
* OpenAI-style `/models` + Bearer. Response shape is `{ data: [{ id, owned_by? }] }` for both.
|
|
111
|
+
*/
|
|
112
|
+
export function buildModelsRequest(prov: OcxProviderConfig, apiKey: string | undefined): { url: string; headers: Record<string, string> } {
|
|
113
|
+
const headers: Record<string, string> = { ...(prov.headers ?? {}) };
|
|
114
|
+
if (prov.adapter === "anthropic") {
|
|
115
|
+
headers["anthropic-version"] = "2023-06-01";
|
|
116
|
+
if (prov.authMode === "oauth") {
|
|
117
|
+
headers["anthropic-beta"] = ANTHROPIC_OAUTH_BETA;
|
|
118
|
+
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
|
119
|
+
} else if (apiKey) {
|
|
120
|
+
headers["x-api-key"] = apiKey;
|
|
121
|
+
}
|
|
122
|
+
return { url: `${prov.baseUrl}/v1/models?limit=1000`, headers };
|
|
123
|
+
}
|
|
124
|
+
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
|
125
|
+
return { url: `${prov.baseUrl}/models`, headers };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Refresh OAuth-managed provider presets (`models`, `noReasoningModels`, and a stale `defaultModel`)
|
|
130
|
+
* from the registry so a proxy update that revises a provider's models — e.g. dropping deprecated
|
|
131
|
+
* Claude snapshots or adding a new grok endpoint not in the live `/models` — reaches EXISTING
|
|
132
|
+
* configs on the next `ocx start`, instead of only fresh installs. The live `/models` fetch stays
|
|
133
|
+
* the primary source; this keeps the static fallback (and models-not-in-/models) current.
|
|
134
|
+
*
|
|
135
|
+
* Only touches providers that are registry-managed AND still `authMode: "oauth"`, and only the
|
|
136
|
+
* preset fields (never apiKey/baseUrl/user toggles). Persists + returns true when anything changed.
|
|
137
|
+
*/
|
|
138
|
+
export function reconcileOAuthProviders(config: OcxConfig): boolean {
|
|
139
|
+
let changed = false;
|
|
140
|
+
for (const [name, prov] of Object.entries(config.providers)) {
|
|
141
|
+
const def = OAUTH_PROVIDERS[name];
|
|
142
|
+
if (!def || prov.authMode !== "oauth") continue;
|
|
143
|
+
const preset = def.providerConfig;
|
|
144
|
+
if (preset.models && JSON.stringify(prov.models) !== JSON.stringify(preset.models)) {
|
|
145
|
+
prov.models = [...preset.models];
|
|
146
|
+
changed = true;
|
|
147
|
+
}
|
|
148
|
+
if (JSON.stringify(prov.noReasoningModels) !== JSON.stringify(preset.noReasoningModels)) {
|
|
149
|
+
if (preset.noReasoningModels) prov.noReasoningModels = [...preset.noReasoningModels];
|
|
150
|
+
else delete prov.noReasoningModels;
|
|
151
|
+
changed = true;
|
|
152
|
+
}
|
|
153
|
+
if (JSON.stringify(prov.noVisionModels) !== JSON.stringify(preset.noVisionModels)) {
|
|
154
|
+
if (preset.noVisionModels) prov.noVisionModels = [...preset.noVisionModels];
|
|
155
|
+
else delete prov.noVisionModels;
|
|
156
|
+
changed = true;
|
|
157
|
+
}
|
|
158
|
+
// Heal a defaultModel that no longer exists in the refreshed list (e.g. a deprecated snapshot).
|
|
159
|
+
if (prov.defaultModel && preset.defaultModel && !(prov.models ?? []).includes(prov.defaultModel)) {
|
|
160
|
+
prov.defaultModel = preset.defaultModel;
|
|
161
|
+
changed = true;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (changed) saveConfig(config);
|
|
165
|
+
return changed;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Add/refresh an OAuth provider's config entry on a config object (does not persist). */
|
|
169
|
+
export function upsertOAuthProvider(config: OcxConfig, provider: string): void {
|
|
170
|
+
const def = OAUTH_PROVIDERS[provider];
|
|
171
|
+
if (!def) return;
|
|
172
|
+
config.providers[provider] = { ...def.providerConfig };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Run the login flow, persist the credential + upsert the provider entry to disk, return cred. */
|
|
176
|
+
export async function runLogin(provider: string, ctrl: OAuthController): Promise<OAuthCredentials> {
|
|
177
|
+
const def = OAUTH_PROVIDERS[provider];
|
|
178
|
+
if (!def) throw new Error(`Unknown OAuth provider: ${provider}`);
|
|
179
|
+
const cred = await def.login(ctrl);
|
|
180
|
+
saveCredential(provider, cred);
|
|
181
|
+
const config = loadConfig();
|
|
182
|
+
upsertOAuthProvider(config, provider);
|
|
183
|
+
saveConfig(config);
|
|
184
|
+
return cred;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* GUI async login: start the flow, return the auth URL EARLY (the flow keeps running in the
|
|
189
|
+
* background until the callback server captures the redirect), with a concurrency guard and an
|
|
190
|
+
* error surfaced via getLoginStatus().
|
|
191
|
+
*/
|
|
192
|
+
const loginState = new Map<string, { error?: string; done: boolean }>();
|
|
193
|
+
|
|
194
|
+
export function getLoginStatus(provider: string): { loggedIn: boolean; email?: string; error?: string } {
|
|
195
|
+
const cred = getCredential(provider);
|
|
196
|
+
const st = loginState.get(provider);
|
|
197
|
+
return { loggedIn: !!cred, email: cred?.email, error: st?.error };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function clearLoginState(provider: string): void {
|
|
201
|
+
loginState.delete(provider);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function startLoginFlow(provider: string): Promise<{ url: string; instructions?: string }> {
|
|
205
|
+
const def = OAUTH_PROVIDERS[provider];
|
|
206
|
+
if (!def) throw new Error(`Unknown OAuth provider: ${provider}`);
|
|
207
|
+
const existing = loginState.get(provider);
|
|
208
|
+
if (existing && !existing.done) {
|
|
209
|
+
throw new Error(`A login for ${provider} is already in progress`);
|
|
210
|
+
}
|
|
211
|
+
loginState.set(provider, { done: false });
|
|
212
|
+
return new Promise((resolve, reject) => {
|
|
213
|
+
let urlResolved = false;
|
|
214
|
+
const ctrl: OAuthController = {
|
|
215
|
+
onAuth: ({ url, instructions }) => {
|
|
216
|
+
urlResolved = true;
|
|
217
|
+
resolve({ url, instructions });
|
|
218
|
+
},
|
|
219
|
+
onProgress: () => {},
|
|
220
|
+
};
|
|
221
|
+
// Background: runLogin persists the credential + upserts the provider entry to disk config.
|
|
222
|
+
runLogin(provider, ctrl)
|
|
223
|
+
.then(() => {
|
|
224
|
+
loginState.set(provider, { done: true });
|
|
225
|
+
// Local-token import (grok-cli / Claude Code keychain) completes WITHOUT firing onAuth —
|
|
226
|
+
// resolve so the GUI call returns instead of hanging.
|
|
227
|
+
if (!urlResolved) resolve({ url: "", instructions: "Logged in via an existing local CLI/keychain token — no browser needed." });
|
|
228
|
+
})
|
|
229
|
+
.catch((e: unknown) => {
|
|
230
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
231
|
+
loginState.set(provider, { done: true, error: msg });
|
|
232
|
+
if (!urlResolved) reject(e);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { OcxProviderConfig } from "../types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* API-key "login" providers: not OAuth — the flow opens the provider's dashboard so the user can
|
|
5
|
+
* create/copy a key, then validates + stores it as the provider's `apiKey` (authMode "key").
|
|
6
|
+
* Most use the OpenAI-compatible chat API (`openai-chat` adapter, `Authorization: Bearer <key>`); a
|
|
7
|
+
* few expose only an Anthropic-compatible endpoint and set `adapter: "anthropic"` (`x-api-key`).
|
|
8
|
+
*/
|
|
9
|
+
export interface KeyLoginProvider {
|
|
10
|
+
label: string;
|
|
11
|
+
baseUrl: string;
|
|
12
|
+
adapter: string;
|
|
13
|
+
/** Where the user creates/copies the API key. */
|
|
14
|
+
dashboardUrl: string;
|
|
15
|
+
models?: string[];
|
|
16
|
+
defaultModel?: string;
|
|
17
|
+
/**
|
|
18
|
+
* Model ids that do NOT accept image input (the vision sidecar describes images for them) / do NOT
|
|
19
|
+
* accept a reasoning param. Copied into the created provider config by `enrichProviderFromCatalog`,
|
|
20
|
+
* so the classification actually gates the sidecars (matching is tolerant of an Ollama ":size" tag).
|
|
21
|
+
*/
|
|
22
|
+
noVisionModels?: string[];
|
|
23
|
+
noReasoningModels?: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const KEY_LOGIN_PROVIDERS: Record<string, KeyLoginProvider> = {
|
|
27
|
+
deepseek: { label: "DeepSeek", baseUrl: "https://api.deepseek.com", adapter: "openai-chat", dashboardUrl: "https://platform.deepseek.com/api_keys", models: ["deepseek-chat", "deepseek-reasoner"], defaultModel: "deepseek-chat" },
|
|
28
|
+
cerebras: { label: "Cerebras", baseUrl: "https://api.cerebras.ai/v1", adapter: "openai-chat", dashboardUrl: "https://cloud.cerebras.ai/platform/apikeys", defaultModel: "llama-3.3-70b" },
|
|
29
|
+
together: { label: "Together", baseUrl: "https://api.together.xyz/v1", adapter: "openai-chat", dashboardUrl: "https://api.together.xyz/settings/api-keys" },
|
|
30
|
+
fireworks: { label: "Fireworks", baseUrl: "https://api.fireworks.ai/inference/v1", adapter: "openai-chat", dashboardUrl: "https://fireworks.ai/account/api-keys" },
|
|
31
|
+
firepass: { label: "Fire Pass (Fireworks Kimi)", baseUrl: "https://api.fireworks.ai/inference/v1", adapter: "openai-chat", dashboardUrl: "https://fireworks.ai/account/api-keys" },
|
|
32
|
+
moonshot: { label: "Moonshot (Kimi API)", baseUrl: "https://api.moonshot.ai/v1", adapter: "openai-chat", dashboardUrl: "https://platform.moonshot.ai/console/api-keys", defaultModel: "kimi-k2-0905-preview" },
|
|
33
|
+
huggingface: { label: "Hugging Face", baseUrl: "https://router.huggingface.co/v1", adapter: "openai-chat", dashboardUrl: "https://huggingface.co/settings/tokens" },
|
|
34
|
+
nvidia: { label: "NVIDIA NIM", baseUrl: "https://integrate.api.nvidia.com/v1", adapter: "openai-chat", dashboardUrl: "https://build.nvidia.com" },
|
|
35
|
+
venice: { label: "Venice", baseUrl: "https://api.venice.ai/api/v1", adapter: "openai-chat", dashboardUrl: "https://venice.ai/settings/api" },
|
|
36
|
+
zai: { label: "Z.AI (GLM Coding)", baseUrl: "https://api.z.ai/api/coding/paas/v4", adapter: "openai-chat", dashboardUrl: "https://z.ai/manage-apikey/apikey-list", defaultModel: "glm-4.6" },
|
|
37
|
+
nanogpt: { label: "NanoGPT", baseUrl: "https://nano-gpt.com/api/v1", adapter: "openai-chat", dashboardUrl: "https://nano-gpt.com/api" },
|
|
38
|
+
synthetic: { label: "Synthetic", baseUrl: "https://api.synthetic.new/openai/v1", adapter: "openai-chat", dashboardUrl: "https://synthetic.new" },
|
|
39
|
+
"qwen-portal": { label: "Qwen Portal", baseUrl: "https://portal.qwen.ai/v1", adapter: "openai-chat", dashboardUrl: "https://portal.qwen.ai" },
|
|
40
|
+
qianfan: { label: "Qianfan (Baidu)", baseUrl: "https://qianfan.baidubce.com/v2", adapter: "openai-chat", dashboardUrl: "https://console.bce.baidu.com/iam/#/iam/apikey/list" },
|
|
41
|
+
alibaba: { label: "Alibaba Coding Plan", baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1", adapter: "openai-chat", dashboardUrl: "https://dashscope.console.aliyun.com/apiKey" },
|
|
42
|
+
parallel: { label: "Parallel", baseUrl: "https://platform.parallel.ai", adapter: "openai-chat", dashboardUrl: "https://platform.parallel.ai" },
|
|
43
|
+
zenmux: { label: "ZenMux", baseUrl: "https://zenmux.ai/api/v1", adapter: "openai-chat", dashboardUrl: "https://zenmux.ai" },
|
|
44
|
+
litellm: { label: "LiteLLM (self-hosted)", baseUrl: "http://localhost:4000/v1", adapter: "openai-chat", dashboardUrl: "https://docs.litellm.ai/docs/proxy/quick_start" },
|
|
45
|
+
// Ollama Cloud — hosted (not local), OpenAI-compatible at /v1, Bearer key from ollama.com.
|
|
46
|
+
// models/noVisionModels reflect the live ollama.com cloud lineup (the proxy still fetches /v1/models
|
|
47
|
+
// live; this is the seed + the vision/text classification, web-verified against ollama.com search
|
|
48
|
+
// filters). Vision-capable cloud models are EXCLUDED from noVisionModels: kimi-k2.5/.6/.7-code,
|
|
49
|
+
// minimax-m3, gemma3/gemma4, qwen3.5, gemini-3-flash-preview, ministral-3, devstral-small-2,
|
|
50
|
+
// mistral-large-3. gpt-oss is text-only despite a stale third-party list claiming otherwise.
|
|
51
|
+
"ollama-cloud": {
|
|
52
|
+
label: "Ollama Cloud",
|
|
53
|
+
baseUrl: "https://ollama.com/v1",
|
|
54
|
+
adapter: "openai-chat",
|
|
55
|
+
dashboardUrl: "https://ollama.com/settings/keys",
|
|
56
|
+
models: ["glm-5.2", "deepseek-v4-pro", "qwen3-coder", "gpt-oss:120b", "kimi-k2.6", "minimax-m3", "qwen3.5", "gemma4"],
|
|
57
|
+
defaultModel: "glm-5.2",
|
|
58
|
+
noVisionModels: [
|
|
59
|
+
"glm-5.2", "glm-5.1", "glm-5", "glm-4.7",
|
|
60
|
+
"minimax-m2.7", "minimax-m2.5", "minimax-m2.1",
|
|
61
|
+
"nemotron-3-ultra", "nemotron-3-super",
|
|
62
|
+
"deepseek-v4-pro", "deepseek-v4-flash",
|
|
63
|
+
"gpt-oss", "qwen3-coder",
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
// ── Brought over from the jawcode provider registry ────────────────────────────────────
|
|
67
|
+
// Real LLM API providers. CLI-agent integrations (cursor, github-copilot, gitlab-duo,
|
|
68
|
+
// google-gemini-cli/antigravity, kilo, opencode, openai-codex) and native-cloud-auth providers
|
|
69
|
+
// (amazon-bedrock, google-vertex) are intentionally excluded. baseUrls are taken from jawcode.
|
|
70
|
+
mistral: { label: "Mistral", baseUrl: "https://api.mistral.ai/v1", adapter: "openai-chat", dashboardUrl: "https://console.mistral.ai/api-keys", defaultModel: "codestral-latest" },
|
|
71
|
+
minimax: { label: "MiniMax", baseUrl: "https://api.minimax.io/v1", adapter: "openai-chat", dashboardUrl: "https://platform.minimax.io", defaultModel: "MiniMax-M2.5" },
|
|
72
|
+
"minimax-cn": { label: "MiniMax (CN)", baseUrl: "https://api.minimaxi.com/v1", adapter: "openai-chat", dashboardUrl: "https://platform.minimaxi.com", defaultModel: "MiniMax-M2.5" },
|
|
73
|
+
"kimi-code": { label: "Kimi (coding)", baseUrl: "https://api.kimi.com/coding/v1", adapter: "openai-chat", dashboardUrl: "https://platform.moonshot.cn/console/api-keys", defaultModel: "kimi-k2.5" },
|
|
74
|
+
"opencode-zen": { label: "opencode zen", baseUrl: "https://opencode.ai/zen/v1", adapter: "openai-chat", dashboardUrl: "https://opencode.ai/auth" },
|
|
75
|
+
"vercel-ai-gateway": { label: "Vercel AI Gateway", baseUrl: "https://ai-gateway.vercel.sh/v1", adapter: "openai-chat", dashboardUrl: "https://vercel.com/dashboard" },
|
|
76
|
+
// Xiaomi MiMo exposes an Anthropic-compatible endpoint → anthropic adapter (x-api-key).
|
|
77
|
+
xiaomi: { label: "Xiaomi MiMo", baseUrl: "https://api.xiaomimimo.com/anthropic", adapter: "anthropic", dashboardUrl: "https://xiaomimimo.com", defaultModel: "mimo-v2.5-pro" },
|
|
78
|
+
// ── Gateways / multi-model proxies (standard wire; subscription-token auth) ──────────────
|
|
79
|
+
// kilo: single-protocol OpenAI-compatible gateway (443 models). Cloudflare AI Gateway: anthropic
|
|
80
|
+
// wire, URL is a template (fill in your account + gateway). github-copilot & gitlab-duo are
|
|
81
|
+
// multi-model gateways whose models span 3 protocols on ONE host — mapped to their universal
|
|
82
|
+
// OpenAI-compatible endpoint (one wire serves the whole lineup). Both need a Bearer subscription
|
|
83
|
+
// token (not a plain API key), and copilot may need a `User-Agent` header via custom provider config.
|
|
84
|
+
kilo: { label: "Kilo", baseUrl: "https://api.kilo.ai/api/gateway", adapter: "openai-chat", dashboardUrl: "https://kilo.ai" },
|
|
85
|
+
"cloudflare-ai-gateway": { label: "Cloudflare AI Gateway", baseUrl: "https://gateway.ai.cloudflare.com/v1/{account-id}/{gateway}/anthropic", adapter: "anthropic", dashboardUrl: "https://dash.cloudflare.com/?to=/:account/ai/ai-gateway" },
|
|
86
|
+
"github-copilot": { label: "GitHub Copilot", baseUrl: "https://api.githubcopilot.com", adapter: "openai-chat", dashboardUrl: "https://github.com/settings/copilot" },
|
|
87
|
+
"gitlab-duo": { label: "GitLab Duo", baseUrl: "https://cloud.gitlab.com/ai/v1/proxy/openai/v1", adapter: "openai-chat", dashboardUrl: "https://gitlab.com/-/user_settings/personal_access_tokens" },
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Copy a key-login catalog entry's seed/classification (`models`, `noVisionModels`,
|
|
92
|
+
* `noReasoningModels`, `defaultModel`) onto a provider config being created, for any field the caller
|
|
93
|
+
* didn't already supply. Lets the vision/reasoning classification actually reach the saved config
|
|
94
|
+
* (the GUI/API only send adapter/baseUrl/apiKey/defaultModel). No-op for non-catalog provider names.
|
|
95
|
+
*/
|
|
96
|
+
export function enrichProviderFromCatalog(name: string, prov: OcxProviderConfig): void {
|
|
97
|
+
const e = KEY_LOGIN_PROVIDERS[name];
|
|
98
|
+
if (!e) return;
|
|
99
|
+
if (!prov.models && e.models) prov.models = [...e.models];
|
|
100
|
+
if (!prov.defaultModel && e.defaultModel) prov.defaultModel = e.defaultModel;
|
|
101
|
+
if (!prov.noVisionModels && e.noVisionModels) prov.noVisionModels = [...e.noVisionModels];
|
|
102
|
+
if (!prov.noReasoningModels && e.noReasoningModels) prov.noReasoningModels = [...e.noReasoningModels];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function isKeyLoginProvider(name: string): boolean {
|
|
106
|
+
return name in KEY_LOGIN_PROVIDERS;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function listKeyLoginProviders(): Array<{ id: string } & KeyLoginProvider> {
|
|
110
|
+
return Object.entries(KEY_LOGIN_PROVIDERS).map(([id, p]) => ({ id, ...p }));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Best-effort key validation: GET {baseUrl}/models with the key. Returns true/false/unknown. */
|
|
114
|
+
export async function validateApiKey(baseUrl: string, key: string): Promise<boolean | "unknown"> {
|
|
115
|
+
try {
|
|
116
|
+
const res = await fetch(`${baseUrl}/models`, {
|
|
117
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
118
|
+
signal: AbortSignal.timeout(8000),
|
|
119
|
+
});
|
|
120
|
+
if (res.ok) return true;
|
|
121
|
+
if (res.status === 401 || res.status === 403) return false;
|
|
122
|
+
return "unknown";
|
|
123
|
+
} catch {
|
|
124
|
+
return "unknown";
|
|
125
|
+
}
|
|
126
|
+
}
|