@bitkyc08/opencodex 2.7.17 → 2.7.19
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/gui/dist/assets/index-D8ODGlXj.js +40 -0
- package/gui/dist/assets/index-DbIT5GLo.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +4 -2
- package/src/adapters/anthropic-image-guard.ts +63 -7
- package/src/adapters/anthropic-image-normalize.ts +383 -0
- package/src/adapters/anthropic.ts +7 -2
- package/src/adapters/base.ts +6 -0
- package/src/adapters/cursor/exec-policy.ts +9 -1
- package/src/adapters/cursor/live-transport.ts +19 -11
- package/src/adapters/cursor/protobuf-request.ts +7 -6
- package/src/adapters/kiro-images.ts +94 -0
- package/src/adapters/kiro.ts +6 -2
- package/src/adapters/mimo-free.ts +228 -0
- package/src/adapters/openai-chat.ts +153 -1
- package/src/adapters/openai-responses.ts +128 -2
- package/src/cli/claude.ts +3 -0
- package/src/codex/catalog.ts +25 -1
- package/src/oauth/callback-server.ts +17 -7
- package/src/oauth/index.ts +93 -0
- package/src/oauth/types.ts +1 -1
- package/src/providers/derive.ts +4 -0
- package/src/providers/registry.ts +64 -3
- package/src/server/adapter-resolve.ts +3 -0
- package/src/server/auth-cors.ts +4 -0
- package/src/server/claude-messages.ts +11 -0
- package/src/server/image-retry.ts +42 -0
- package/src/server/management-api.ts +57 -1
- package/src/server/relay.ts +6 -2
- package/src/server/request-log.ts +4 -1
- package/src/server/responses.ts +74 -22
- package/src/server/system-env.ts +7 -3
- package/src/types.ts +22 -4
- package/src/web-search/index.ts +8 -5
- package/gui/dist/assets/index-Cq8maiJf.css +0 -1
- package/gui/dist/assets/index-m4o3xsSn.js +0 -40
package/src/adapters/base.ts
CHANGED
|
@@ -4,6 +4,12 @@ import type { AdapterEvent, OcxParsedRequest } from "../types";
|
|
|
4
4
|
export interface IncomingMeta {
|
|
5
5
|
headers: Headers;
|
|
6
6
|
abortSignal?: AbortSignal;
|
|
7
|
+
/**
|
|
8
|
+
* Image-normalization ladder bias for upstream-413 tightened retries: every image
|
|
9
|
+
* starts one tier lower (devlog/260714_image_normalization_pipeline/030). Only the
|
|
10
|
+
* anthropic adapter consumes it; others ignore it.
|
|
11
|
+
*/
|
|
12
|
+
imageTierBias?: number;
|
|
7
13
|
}
|
|
8
14
|
|
|
9
15
|
export interface ProviderAdapter {
|
|
@@ -6,7 +6,15 @@ export type CursorNativeExecMode = "off" | "codex-sandbox" | "on";
|
|
|
6
6
|
/** Codex permissions template marker, e.g. "`sandbox_mode` is `danger-full-access`". */
|
|
7
7
|
export const CURSOR_SANDBOX_FULL_ACCESS_RE = /sandbox_mode[^\n]{0,80}danger-full-access/i;
|
|
8
8
|
|
|
9
|
-
/**
|
|
9
|
+
/**
|
|
10
|
+
* Config-owner-selected policy; explicit mode wins, legacy boolean maps to "on".
|
|
11
|
+
* The UNSET default is "codex-sandbox": native local exec is APPROVED for requests that
|
|
12
|
+
* declare the Codex danger-full-access sandbox (the normal full-access Codex flow — "approve
|
|
13
|
+
* most") and DENIED for requests that do not. Set `nativeLocalExec: "off"` to deny all, or
|
|
14
|
+
* "on" to always allow. Legacy `unsafeAllowNativeLocalExec: true` still maps to "on".
|
|
15
|
+
* Security note: codex-sandbox trusts a caller-controlled full-access marker the proxy cannot
|
|
16
|
+
* verify, and the auth-free loopback bind admits any local process — see the src/types.ts doc.
|
|
17
|
+
*/
|
|
10
18
|
export function resolveCursorNativeExecMode(provider: OcxProviderConfig): CursorNativeExecMode {
|
|
11
19
|
const mode = provider.nativeLocalExec;
|
|
12
20
|
if (mode === "off" || mode === "codex-sandbox" || mode === "on") return mode;
|
|
@@ -15,14 +15,14 @@ import {
|
|
|
15
15
|
CreatePlanResultSchema,
|
|
16
16
|
CreatePlanSuccessSchema,
|
|
17
17
|
ExaFetchRequestResponseSchema,
|
|
18
|
-
|
|
18
|
+
ExaFetchRequestResponse_ApprovedSchema,
|
|
19
19
|
ExaSearchRequestResponseSchema,
|
|
20
|
-
|
|
20
|
+
ExaSearchRequestResponse_ApprovedSchema,
|
|
21
21
|
InteractionResponseSchema,
|
|
22
22
|
SwitchModeRequestResponseSchema,
|
|
23
23
|
SwitchModeRequestResponse_RejectedSchema,
|
|
24
24
|
WebSearchRequestResponseSchema,
|
|
25
|
-
|
|
25
|
+
WebSearchRequestResponse_ApprovedSchema,
|
|
26
26
|
type AgentServerMessage,
|
|
27
27
|
type ExecServerMessage,
|
|
28
28
|
type InteractionQuery,
|
|
@@ -181,8 +181,16 @@ export function planMcpArgsHandling(
|
|
|
181
181
|
* Codex as visible output so the user still sees it.
|
|
182
182
|
* - askQuestion: reject with a reason — the agent must proceed autonomously; there is no human to
|
|
183
183
|
* answer mid-turn. (Future: bridge to a Codex user-input request.)
|
|
184
|
-
* -
|
|
185
|
-
*
|
|
184
|
+
* - webSearch / exaSearch / exaFetch: APPROVE (empty approval). These are approve/reject
|
|
185
|
+
* permission gates, not client-run requests — the response schema has no result field, so
|
|
186
|
+
* approval delegates the search to Cursor's SERVER, which runs it and injects results into the
|
|
187
|
+
* model server-side (the answer then streams back as textDelta; the display-plane
|
|
188
|
+
* web_search_tool_call/exa_*_tool_call result frames are native, non-mcp, and safely dropped by
|
|
189
|
+
* the event mapper). Rejecting them (the old default) killed the model's web capability on the
|
|
190
|
+
* Cursor path. Tradeoff: approval consumes the user's Cursor web-search/Exa quota. The synthetic
|
|
191
|
+
* web_search sidecar (src/web-search) is an orthogonal proxy-side path used only when the client
|
|
192
|
+
* sends a hosted web_search tool; it does not cover Cursor-native web search.
|
|
193
|
+
* - switchMode: reject (deterministic default; no non-interactive mode switch).
|
|
186
194
|
* - setupVmEnvironment: the result schema has no error case — reply success so the agent is not
|
|
187
195
|
* left waiting; the command itself was never run locally.
|
|
188
196
|
* Pure (no I/O) for unit testing; `handleServerMessage` writes the frame and emits liveness.
|
|
@@ -240,10 +248,10 @@ export function planInteractionQueryReply(query: InteractionQuery): { response:
|
|
|
240
248
|
response: respond({
|
|
241
249
|
case: "webSearchRequestResponse",
|
|
242
250
|
value: create(WebSearchRequestResponseSchema, {
|
|
243
|
-
result: { case: "
|
|
251
|
+
result: { case: "approved", value: create(WebSearchRequestResponse_ApprovedSchema, {}) },
|
|
244
252
|
}),
|
|
245
253
|
}),
|
|
246
|
-
replyCase: "webSearchRequestResponse:
|
|
254
|
+
replyCase: "webSearchRequestResponse:approved",
|
|
247
255
|
};
|
|
248
256
|
}
|
|
249
257
|
if (q.case === "exaSearchRequestQuery") {
|
|
@@ -251,10 +259,10 @@ export function planInteractionQueryReply(query: InteractionQuery): { response:
|
|
|
251
259
|
response: respond({
|
|
252
260
|
case: "exaSearchRequestResponse",
|
|
253
261
|
value: create(ExaSearchRequestResponseSchema, {
|
|
254
|
-
result: { case: "
|
|
262
|
+
result: { case: "approved", value: create(ExaSearchRequestResponse_ApprovedSchema, {}) },
|
|
255
263
|
}),
|
|
256
264
|
}),
|
|
257
|
-
replyCase: "exaSearchRequestResponse:
|
|
265
|
+
replyCase: "exaSearchRequestResponse:approved",
|
|
258
266
|
};
|
|
259
267
|
}
|
|
260
268
|
if (q.case === "exaFetchRequestQuery") {
|
|
@@ -262,10 +270,10 @@ export function planInteractionQueryReply(query: InteractionQuery): { response:
|
|
|
262
270
|
response: respond({
|
|
263
271
|
case: "exaFetchRequestResponse",
|
|
264
272
|
value: create(ExaFetchRequestResponseSchema, {
|
|
265
|
-
result: { case: "
|
|
273
|
+
result: { case: "approved", value: create(ExaFetchRequestResponse_ApprovedSchema, {}) },
|
|
266
274
|
}),
|
|
267
275
|
}),
|
|
268
|
-
replyCase: "exaFetchRequestResponse:
|
|
276
|
+
replyCase: "exaFetchRequestResponse:approved",
|
|
269
277
|
};
|
|
270
278
|
}
|
|
271
279
|
if (q.case === "setupVmEnvironmentArgs") {
|
|
@@ -108,12 +108,13 @@ function rootPromptMessages(request: CursorRunRequest): Uint8Array[] {
|
|
|
108
108
|
} else if (message.role === "assistant") {
|
|
109
109
|
const text = assistantRootText(message).trim();
|
|
110
110
|
if (text.length > 0) entries.push(storeCursorBlob(jsonBlob({ role: "assistant", content: [{ type: "text", text }] })));
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
111
|
+
// Assistant tool CALLS are intentionally NOT replayed as visible "[Tool Call]" text here.
|
|
112
|
+
// rootPromptMessagesJson is the model-visible prompt, so a synthetic "[Tool Call]" marker in an
|
|
113
|
+
// assistant turn gets few-shot-mimicked: the model then emits later (esp. parallel/mixed) tool
|
|
114
|
+
// calls as inert text instead of real tool frames, halting multi-tool continuations. The paired
|
|
115
|
+
// tool result below ([Tool Result]/[Tool Error]) carries the call id/name/output Cursor needs to
|
|
116
|
+
// continue, and conversationTurns replays the native mcpToolCall step. Mirrors request-builder.ts
|
|
117
|
+
// contentPartToText() which returns undefined for toolCall for the same reason.
|
|
117
118
|
} else if (message.role === "toolResult") {
|
|
118
119
|
const prefix = message.isError ? "[Tool Error]" : "[Tool Result]";
|
|
119
120
|
const text = `${prefix}\n${toolResultToText(message)}`;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { OcxContentPart } from "../types";
|
|
2
|
+
import { normalizeImageTargets, type NormalizeOptions, type NormalizeTarget } from "./anthropic-image-normalize";
|
|
2
3
|
|
|
3
4
|
// CodeWhisperer native image part (matches Kiro IDE wire format): the base64 bytes live directly in
|
|
4
5
|
// userInputMessage.images, NOT in userInputMessageContext. Verified against kiro-gateway.
|
|
@@ -33,3 +34,96 @@ export function extractKiroImages(content: string | OcxContentPart[]): KiroImage
|
|
|
33
34
|
}
|
|
34
35
|
return out;
|
|
35
36
|
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Conservative POLICY caps for the CodeWhisperer GenerateAssistantResponse payload,
|
|
40
|
+
* whose limits are undocumented. Derived from adjacent AWS surfaces
|
|
41
|
+
* (devlog/260714_image_normalization_pipeline/050): Bedrock `Message` allows 20 images
|
|
42
|
+
* per message (Converse), and `InvokeModel` caps requests at 25,000,000 bytes — 18MiB
|
|
43
|
+
* bounds the IMAGE share of the body with headroom for text/tools.
|
|
44
|
+
*/
|
|
45
|
+
export const KIRO_IMAGE_BASE64_BUDGET = 18 * 1024 * 1024;
|
|
46
|
+
export const KIRO_MAX_IMAGES_PER_MESSAGE = 20;
|
|
47
|
+
|
|
48
|
+
const COUNT_CAP_NOTE = "[image omitted: exceeded the 20-image per-message cap; oldest images in this message were dropped]";
|
|
49
|
+
|
|
50
|
+
/** A kiro wire message that can carry images (history userInputMessage or currentMessage). */
|
|
51
|
+
interface KiroImageCarrier {
|
|
52
|
+
content?: string;
|
|
53
|
+
images?: KiroImage[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isCarrier(v: unknown): v is KiroImageCarrier {
|
|
57
|
+
return typeof v === "object" && v !== null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Collect image-bearing userInputMessages in wire order (history oldest-first, then current). */
|
|
61
|
+
function collectKiroImageCarriers(payload: unknown): KiroImageCarrier[] {
|
|
62
|
+
const state = (payload as { conversationState?: { history?: unknown[]; currentMessage?: { userInputMessage?: unknown } } })?.conversationState;
|
|
63
|
+
if (!state) return [];
|
|
64
|
+
const carriers: KiroImageCarrier[] = [];
|
|
65
|
+
for (const entry of state.history ?? []) {
|
|
66
|
+
const uim = (entry as { userInputMessage?: unknown })?.userInputMessage;
|
|
67
|
+
if (isCarrier(uim)) carriers.push(uim);
|
|
68
|
+
}
|
|
69
|
+
const current = state.currentMessage?.userInputMessage;
|
|
70
|
+
if (isCarrier(current)) carriers.push(current);
|
|
71
|
+
return carriers;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function appendNote(carrier: KiroImageCarrier, note: string): void {
|
|
75
|
+
carrier.content = carrier.content ? `${carrier.content}\n${note}` : note;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Apply the generous image pipeline to a built CodeWhisperer payload (mutates in
|
|
80
|
+
* place): per-message 20-image cap first (oldest dropped), then the shared tier
|
|
81
|
+
* machinery with the kiro budget and terminal-overflow DROP (kiro has no downstream
|
|
82
|
+
* guard). Test seams (encode/validate) forward into the core.
|
|
83
|
+
*/
|
|
84
|
+
export async function normalizeKiroImages(
|
|
85
|
+
payload: unknown,
|
|
86
|
+
opts?: Pick<NormalizeOptions, "encode" | "validate">,
|
|
87
|
+
): Promise<void> {
|
|
88
|
+
const carriers = collectKiroImageCarriers(payload);
|
|
89
|
+
if (carriers.length === 0) return;
|
|
90
|
+
|
|
91
|
+
// Pre-pass: per-message count cap (drop oldest within the message).
|
|
92
|
+
for (const carrier of carriers) {
|
|
93
|
+
const images = carrier.images;
|
|
94
|
+
if (!images || images.length <= KIRO_MAX_IMAGES_PER_MESSAGE) continue;
|
|
95
|
+
images.splice(0, images.length - KIRO_MAX_IMAGES_PER_MESSAGE);
|
|
96
|
+
appendNote(carrier, COUNT_CAP_NOTE);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Targets over the survivors, oldest→newest across carriers. Drops resolve the image
|
|
100
|
+
// by OBJECT IDENTITY at execution time (indices go stale after earlier splices) and
|
|
101
|
+
// delete an emptied images field per the builder's omission contract.
|
|
102
|
+
const targets: NormalizeTarget[] = [];
|
|
103
|
+
for (const carrier of carriers) {
|
|
104
|
+
for (const img of carrier.images ?? []) {
|
|
105
|
+
targets.push({
|
|
106
|
+
base64: typeof img.source?.bytes === "string" && img.source.bytes.length > 0 ? img.source.bytes : null,
|
|
107
|
+
mediaType: `image/${(img.format || "jpeg").toLowerCase()}`,
|
|
108
|
+
replace: (data: string, mediaType: string) => {
|
|
109
|
+
img.source.bytes = data;
|
|
110
|
+
img.format = (mediaType.split("/")[1] ?? "jpeg").toLowerCase();
|
|
111
|
+
},
|
|
112
|
+
drop: (note: string) => {
|
|
113
|
+
const arr = carrier.images;
|
|
114
|
+
if (arr) {
|
|
115
|
+
const idx = arr.indexOf(img);
|
|
116
|
+
if (idx !== -1) arr.splice(idx, 1);
|
|
117
|
+
if (arr.length === 0) delete carrier.images;
|
|
118
|
+
}
|
|
119
|
+
appendNote(carrier, note);
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
await normalizeImageTargets(targets, {
|
|
125
|
+
budget: KIRO_IMAGE_BASE64_BUDGET,
|
|
126
|
+
overflowAction: "drop",
|
|
127
|
+
...(opts ?? {}),
|
|
128
|
+
});
|
|
129
|
+
}
|
package/src/adapters/kiro.ts
CHANGED
|
@@ -25,7 +25,7 @@ import type {
|
|
|
25
25
|
} from "../types";
|
|
26
26
|
import type { ProviderAdapter } from "./base";
|
|
27
27
|
import type { AdapterFetchContext, AdapterRequest } from "./base";
|
|
28
|
-
import { extractKiroImages, type KiroImage } from "./kiro-images";
|
|
28
|
+
import { extractKiroImages, normalizeKiroImages, type KiroImage } from "./kiro-images";
|
|
29
29
|
import { fetchKiroWithRetry } from "./kiro-retry";
|
|
30
30
|
import { convertKiroToolContext } from "./kiro-tools";
|
|
31
31
|
import { neutralizeIdentity } from "./identity";
|
|
@@ -497,7 +497,7 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter
|
|
|
497
497
|
let toolNameMap: Map<string, string> | undefined;
|
|
498
498
|
return {
|
|
499
499
|
name: "kiro",
|
|
500
|
-
buildRequest(parsed: OcxParsedRequest) {
|
|
500
|
+
async buildRequest(parsed: OcxParsedRequest) {
|
|
501
501
|
if (typeof provider.apiKey !== "string" || provider.apiKey.trim() === "") {
|
|
502
502
|
throw new Error("kiro token missing — run ocx login kiro");
|
|
503
503
|
}
|
|
@@ -520,6 +520,10 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter
|
|
|
520
520
|
// fake-reasoning contract by injecting effort-derived thinking tags into only the current user turn.
|
|
521
521
|
const built = buildKiroPayload(parsed, profileArn);
|
|
522
522
|
toolNameMap = built.nameMap;
|
|
523
|
+
// Generous image pipeline (devlog/260714_image_normalization_pipeline/050):
|
|
524
|
+
// tier-normalize + cap images before serialization so bodyBytes below reflects
|
|
525
|
+
// the normalized size.
|
|
526
|
+
await normalizeKiroImages(built.payload);
|
|
523
527
|
const body = JSON.stringify(built.payload);
|
|
524
528
|
debugProviderDiagnostic("kiro", "request", {
|
|
525
529
|
region,
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { getConfigDir } from "../config";
|
|
5
|
+
import type { OcxProviderConfig, OcxParsedRequest } from "../types";
|
|
6
|
+
import { createOpenAIChatAdapter } from "./openai-chat";
|
|
7
|
+
import type { ProviderAdapter, AdapterRequest } from "./base";
|
|
8
|
+
|
|
9
|
+
const BOOTSTRAP_URL = "https://api.xiaomimimo.com/api/free-ai/bootstrap";
|
|
10
|
+
export const MIMO_CHAT_URL = "https://api.xiaomimimo.com/api/free-ai/openai/chat";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Anti-abuse gate: the free chat endpoint returns 403 "Illegal access" unless
|
|
14
|
+
* a system message contains this exact string as a substring.
|
|
15
|
+
*/
|
|
16
|
+
export const MIMO_SYSTEM_MARKER =
|
|
17
|
+
"You are MiMoCode, an interactive CLI tool that helps users with software engineering tasks.";
|
|
18
|
+
|
|
19
|
+
// Chrome-like User-Agent required by the upstream anti-abuse gate.
|
|
20
|
+
const USER_AGENTS = [
|
|
21
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
|
22
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
|
23
|
+
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const JWT_FALLBACK_TTL_MS = 3_000_000; // 50 min
|
|
27
|
+
const JWT_EXPIRY_BUFFER_MS = 300_000; // 5 min early refresh
|
|
28
|
+
const BOOTSTRAP_TIMEOUT_MS = 15_000;
|
|
29
|
+
|
|
30
|
+
// In-process JWT cache -- survives across requests, reset on restart.
|
|
31
|
+
let cachedJwt: string | null = null;
|
|
32
|
+
let jwtExpiresAt = 0;
|
|
33
|
+
// Single-flight guard: concurrent first requests share one bootstrap.
|
|
34
|
+
let inFlightJwt: Promise<string> | null = null;
|
|
35
|
+
|
|
36
|
+
function randomUserAgent(): string {
|
|
37
|
+
return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)]!;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Anonymous per-install client id for the bootstrap `client` field. A random UUID
|
|
42
|
+
* persisted under the config dir (OPENCODEX_HOME-aware) — deliberately NOT derived
|
|
43
|
+
* from machine attributes (hostname/username/CPU), which would be a stable
|
|
44
|
+
* pseudonymous device fingerprint. Delete the file to rotate the id.
|
|
45
|
+
*/
|
|
46
|
+
let cachedClientId: string | null = null;
|
|
47
|
+
export function getMimoClientId(): string {
|
|
48
|
+
if (cachedClientId) return cachedClientId;
|
|
49
|
+
const dir = getConfigDir();
|
|
50
|
+
const file = join(dir, "mimo-client-id");
|
|
51
|
+
try {
|
|
52
|
+
if (existsSync(file)) {
|
|
53
|
+
const stored = readFileSync(file, "utf8").trim();
|
|
54
|
+
if (/^[0-9a-f-]{36}$/i.test(stored)) {
|
|
55
|
+
cachedClientId = stored;
|
|
56
|
+
return stored;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
} catch { /* fall through to regenerate */ }
|
|
60
|
+
const fresh = randomUUID();
|
|
61
|
+
try {
|
|
62
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
63
|
+
writeFileSync(file, `${fresh}\n`, "utf8");
|
|
64
|
+
} catch { /* persist best-effort; still usable for this process */ }
|
|
65
|
+
cachedClientId = fresh;
|
|
66
|
+
return fresh;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Test hook: clear the in-process client-id cache (file state is the test's concern). */
|
|
70
|
+
export function resetMimoClientIdCache(): void {
|
|
71
|
+
cachedClientId = null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function parseJwtExp(jwt: string): number {
|
|
75
|
+
try {
|
|
76
|
+
const parts = jwt.split(".");
|
|
77
|
+
if (parts.length < 2) return 0;
|
|
78
|
+
const payload = JSON.parse(Buffer.from(parts[1]!, "base64").toString()) as { exp?: number };
|
|
79
|
+
if (payload.exp) return payload.exp * 1000;
|
|
80
|
+
} catch { /* ignore */ }
|
|
81
|
+
return Date.now() + JWT_FALLBACK_TTL_MS;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function resetMimoJwtCache(): void {
|
|
85
|
+
cachedJwt = null;
|
|
86
|
+
jwtExpiresAt = 0;
|
|
87
|
+
inFlightJwt = null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function fetchJwt(signal?: AbortSignal): Promise<string> {
|
|
91
|
+
// Bounded bootstrap: request-abort propagates, and a stalled bootstrap can never
|
|
92
|
+
// hang past BOOTSTRAP_TIMEOUT_MS.
|
|
93
|
+
const timeout = AbortSignal.timeout(BOOTSTRAP_TIMEOUT_MS);
|
|
94
|
+
const combined = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
95
|
+
const response = await fetch(BOOTSTRAP_URL, {
|
|
96
|
+
method: "POST",
|
|
97
|
+
headers: {
|
|
98
|
+
"Content-Type": "application/json",
|
|
99
|
+
"User-Agent": randomUserAgent(),
|
|
100
|
+
},
|
|
101
|
+
body: JSON.stringify({ client: getMimoClientId() }),
|
|
102
|
+
signal: combined,
|
|
103
|
+
});
|
|
104
|
+
if (!response.ok) {
|
|
105
|
+
try { await response.body?.cancel(); } catch { /* already consumed */ }
|
|
106
|
+
throw new Error(`MiMo bootstrap failed: ${response.status}`);
|
|
107
|
+
}
|
|
108
|
+
const data = await response.json() as { jwt?: string };
|
|
109
|
+
if (!data.jwt) throw new Error("MiMo bootstrap returned no JWT");
|
|
110
|
+
return data.jwt;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function getMimoJwt(signal?: AbortSignal): Promise<string> {
|
|
114
|
+
if (cachedJwt && Date.now() < jwtExpiresAt - JWT_EXPIRY_BUFFER_MS) {
|
|
115
|
+
return cachedJwt;
|
|
116
|
+
}
|
|
117
|
+
// Single-flight: concurrent callers await the same bootstrap instead of issuing
|
|
118
|
+
// parallel bootstraps.
|
|
119
|
+
if (!inFlightJwt) {
|
|
120
|
+
inFlightJwt = (async () => {
|
|
121
|
+
try {
|
|
122
|
+
const jwt = await fetchJwt(signal);
|
|
123
|
+
cachedJwt = jwt;
|
|
124
|
+
jwtExpiresAt = parseJwtExp(jwt);
|
|
125
|
+
return jwt;
|
|
126
|
+
} finally {
|
|
127
|
+
inFlightJwt = null;
|
|
128
|
+
}
|
|
129
|
+
})();
|
|
130
|
+
}
|
|
131
|
+
return inFlightJwt;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Idempotently prepend the MiMo anti-abuse system marker if it is not already present.
|
|
136
|
+
* The marker must appear in a system message; we prepend one if the request has none with it.
|
|
137
|
+
*/
|
|
138
|
+
export function injectMimoSystemMarker(body: unknown): unknown {
|
|
139
|
+
if (!body || typeof body !== "object") return body;
|
|
140
|
+
const parsed = body as Record<string, unknown>;
|
|
141
|
+
const messages = parsed["messages"];
|
|
142
|
+
if (!Array.isArray(messages)) return body;
|
|
143
|
+
const hasMarker = messages.some(
|
|
144
|
+
(m): m is { role: string; content: string } =>
|
|
145
|
+
m !== null &&
|
|
146
|
+
typeof m === "object" &&
|
|
147
|
+
(m as Record<string, unknown>)["role"] === "system" &&
|
|
148
|
+
typeof (m as Record<string, unknown>)["content"] === "string" &&
|
|
149
|
+
((m as Record<string, unknown>)["content"] as string).includes(MIMO_SYSTEM_MARKER),
|
|
150
|
+
);
|
|
151
|
+
if (hasMarker) return body;
|
|
152
|
+
return { ...parsed, messages: [{ role: "system", content: MIMO_SYSTEM_MARKER }, ...messages] };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Creates the MiMo Free adapter. Wraps openai-chat's request builder to inject:
|
|
157
|
+
* 1. JWT from the bootstrap endpoint (cached, auto-refreshed).
|
|
158
|
+
* 2. Anti-abuse system marker in the request body.
|
|
159
|
+
* 3. Required headers (User-Agent, X-Mimo-Source, x-session-affinity).
|
|
160
|
+
* On 401/403, flushes the JWT cache and retries once via fetchResponse.
|
|
161
|
+
*/
|
|
162
|
+
export function createMimoFreeAdapter(provider: OcxProviderConfig): ProviderAdapter {
|
|
163
|
+
const base = createOpenAIChatAdapter(provider);
|
|
164
|
+
// Per-adapter session-affinity id (random, per process instance).
|
|
165
|
+
const sessionId = `ses_${Math.random().toString(36).slice(2, 26)}`;
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
...base,
|
|
169
|
+
name: "mimo-free",
|
|
170
|
+
|
|
171
|
+
async buildRequest(parsed: OcxParsedRequest): Promise<AdapterRequest> {
|
|
172
|
+
const jwt = await getMimoJwt();
|
|
173
|
+
|
|
174
|
+
// Let the base adapter build the wire body (handles reasoning, tools, etc.)
|
|
175
|
+
// but override the URL and headers after.
|
|
176
|
+
const baseReq = base.buildRequest(parsed) as AdapterRequest;
|
|
177
|
+
const baseBody = JSON.parse(baseReq.body as string) as unknown;
|
|
178
|
+
const markedBody = injectMimoSystemMarker(baseBody);
|
|
179
|
+
|
|
180
|
+
const headers: Record<string, string> = {
|
|
181
|
+
"Content-Type": "application/json",
|
|
182
|
+
"Authorization": `Bearer ${jwt}`,
|
|
183
|
+
"X-Mimo-Source": "mimocode-cli-free",
|
|
184
|
+
"User-Agent": randomUserAgent(),
|
|
185
|
+
"x-session-affinity": sessionId,
|
|
186
|
+
"Accept": parsed.stream ? "text/event-stream" : "application/json",
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
url: MIMO_CHAT_URL,
|
|
191
|
+
method: "POST",
|
|
192
|
+
headers,
|
|
193
|
+
body: JSON.stringify(markedBody),
|
|
194
|
+
};
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
async fetchResponse(request: AdapterRequest, ctx): Promise<Response> {
|
|
198
|
+
const response = await fetch(request.url, {
|
|
199
|
+
method: request.method,
|
|
200
|
+
headers: request.headers as Record<string, string>,
|
|
201
|
+
body: request.body,
|
|
202
|
+
signal: ctx?.abortSignal,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// Retry predicate: 401 (expired/invalid JWT) retries ONCE with a fresh token.
|
|
206
|
+
// 403 is NOT retried — Xiaomi uses it for anti-abuse "Illegal access" and there is
|
|
207
|
+
// no documented token-expiry signature that would mark a 403 as retryable.
|
|
208
|
+
if (response.status === 401) {
|
|
209
|
+
// Drain the first response body before issuing the retry.
|
|
210
|
+
try { await response.body?.cancel(); } catch { /* already consumed */ }
|
|
211
|
+
resetMimoJwtCache();
|
|
212
|
+
const freshJwt = await getMimoJwt(ctx?.abortSignal);
|
|
213
|
+
const retryHeaders = {
|
|
214
|
+
...(request.headers as Record<string, string>),
|
|
215
|
+
"Authorization": `Bearer ${freshJwt}`,
|
|
216
|
+
};
|
|
217
|
+
return fetch(request.url, {
|
|
218
|
+
method: request.method,
|
|
219
|
+
headers: retryHeaders,
|
|
220
|
+
body: request.body,
|
|
221
|
+
signal: ctx?.abortSignal,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return response;
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
}
|