@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.
Files changed (36) hide show
  1. package/gui/dist/assets/index-D8ODGlXj.js +40 -0
  2. package/gui/dist/assets/index-DbIT5GLo.css +1 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +4 -2
  5. package/src/adapters/anthropic-image-guard.ts +63 -7
  6. package/src/adapters/anthropic-image-normalize.ts +383 -0
  7. package/src/adapters/anthropic.ts +7 -2
  8. package/src/adapters/base.ts +6 -0
  9. package/src/adapters/cursor/exec-policy.ts +9 -1
  10. package/src/adapters/cursor/live-transport.ts +19 -11
  11. package/src/adapters/cursor/protobuf-request.ts +7 -6
  12. package/src/adapters/kiro-images.ts +94 -0
  13. package/src/adapters/kiro.ts +6 -2
  14. package/src/adapters/mimo-free.ts +228 -0
  15. package/src/adapters/openai-chat.ts +153 -1
  16. package/src/adapters/openai-responses.ts +128 -2
  17. package/src/cli/claude.ts +3 -0
  18. package/src/codex/catalog.ts +25 -1
  19. package/src/oauth/callback-server.ts +17 -7
  20. package/src/oauth/index.ts +93 -0
  21. package/src/oauth/types.ts +1 -1
  22. package/src/providers/derive.ts +4 -0
  23. package/src/providers/registry.ts +64 -3
  24. package/src/server/adapter-resolve.ts +3 -0
  25. package/src/server/auth-cors.ts +4 -0
  26. package/src/server/claude-messages.ts +11 -0
  27. package/src/server/image-retry.ts +42 -0
  28. package/src/server/management-api.ts +57 -1
  29. package/src/server/relay.ts +6 -2
  30. package/src/server/request-log.ts +4 -1
  31. package/src/server/responses.ts +74 -22
  32. package/src/server/system-env.ts +7 -3
  33. package/src/types.ts +22 -4
  34. package/src/web-search/index.ts +8 -5
  35. package/gui/dist/assets/index-Cq8maiJf.css +0 -1
  36. package/gui/dist/assets/index-m4o3xsSn.js +0 -40
@@ -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
- /** Config-owner-selected policy; explicit mode wins, legacy boolean maps to "on". */
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
- ExaFetchRequestResponse_RejectedSchema,
18
+ ExaFetchRequestResponse_ApprovedSchema,
19
19
  ExaSearchRequestResponseSchema,
20
- ExaSearchRequestResponse_RejectedSchema,
20
+ ExaSearchRequestResponse_ApprovedSchema,
21
21
  InteractionResponseSchema,
22
22
  SwitchModeRequestResponseSchema,
23
23
  SwitchModeRequestResponse_RejectedSchema,
24
24
  WebSearchRequestResponseSchema,
25
- WebSearchRequestResponse_RejectedSchema,
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
- * - switchMode / webSearch / exaSearch / exaFetch: reject (deterministic default; web search has
185
- * its own sidecar path outside this transport).
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: "rejected", value: create(WebSearchRequestResponse_RejectedSchema, { reason: NON_INTERACTIVE_REASON }) },
251
+ result: { case: "approved", value: create(WebSearchRequestResponse_ApprovedSchema, {}) },
244
252
  }),
245
253
  }),
246
- replyCase: "webSearchRequestResponse:rejected",
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: "rejected", value: create(ExaSearchRequestResponse_RejectedSchema, { reason: NON_INTERACTIVE_REASON }) },
262
+ result: { case: "approved", value: create(ExaSearchRequestResponse_ApprovedSchema, {}) },
255
263
  }),
256
264
  }),
257
- replyCase: "exaSearchRequestResponse:rejected",
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: "rejected", value: create(ExaFetchRequestResponse_RejectedSchema, { reason: NON_INTERACTIVE_REASON }) },
273
+ result: { case: "approved", value: create(ExaFetchRequestResponse_ApprovedSchema, {}) },
266
274
  }),
267
275
  }),
268
- replyCase: "exaFetchRequestResponse:rejected",
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
- for (const part of message.content) {
112
- if (typeof part === "string" || part.type !== "toolCall") continue;
113
- const toolName = namespacedToolName(part.namespace, part.name);
114
- const callText = `[Tool Call]\ncall_id: ${part.id}\nname: ${toolName}\narguments:\n${JSON.stringify(part.arguments ?? {})}`;
115
- entries.push(storeCursorBlob(jsonBlob({ role: "assistant", content: [{ type: "text", text: callText }] })));
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
+ }
@@ -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
+ }