@bitkyc08/opencodex 2.7.18 → 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.
@@ -235,12 +235,16 @@ export abstract class OAuthCallbackFlow {
235
235
  while (true) {
236
236
  const result = await Promise.race([
237
237
  callbackPromise,
238
- requestManualInput()
238
+ requestManualInput(expectedState)
239
239
  .then((input): CallbackResult | null => {
240
240
  const parsed = parseCallbackInput(input);
241
241
  if (!parsed.code) return null;
242
- if (expectedState && parsed.state !== expectedState) return null;
243
- return { code: parsed.code, state: parsed.state ?? "" };
242
+ // Kind-aware state enforcement: url/query-shaped input is an authorization
243
+ // RESPONSE and must carry a matching state — missing state is rejected, not
244
+ // downgraded to raw. Only a syntactically raw code (same PKCE session) is
245
+ // exempt, so the CLI/GUI paste fallback still works.
246
+ if (parsed.kind !== "raw" && expectedState && parsed.state !== expectedState) return null;
247
+ return { code: parsed.code, state: parsed.state ?? expectedState };
244
248
  })
245
249
  .catch((): CallbackResult | null => null),
246
250
  ]);
@@ -255,14 +259,19 @@ export abstract class OAuthCallbackFlow {
255
259
  }
256
260
  }
257
261
 
258
- /** Parse a redirect URL or code string to extract code and state. */
259
- export function parseCallbackInput(input: string): { code?: string; state?: string } {
262
+ /**
263
+ * Parse a redirect URL or code string to extract code and state.
264
+ * `kind` records the syntactic shape so callers can enforce state on authorization
265
+ * responses (url/query) while exempting raw in-session codes.
266
+ */
267
+ export function parseCallbackInput(input: string): { kind: "url" | "query" | "raw"; code?: string; state?: string } {
260
268
  const value = input.trim();
261
- if (!value) return {};
269
+ if (!value) return { kind: "raw" };
262
270
 
263
271
  try {
264
272
  const url = new URL(value);
265
273
  return {
274
+ kind: "url",
266
275
  code: url.searchParams.get("code") ?? undefined,
267
276
  state: url.searchParams.get("state") ?? undefined,
268
277
  };
@@ -273,6 +282,7 @@ export function parseCallbackInput(input: string): { code?: string; state?: stri
273
282
  if (value.includes("code=")) {
274
283
  const params = new URLSearchParams(value.replace(/^[?#]/, ""));
275
284
  return {
285
+ kind: "query",
276
286
  code: params.get("code") ?? undefined,
277
287
  state: params.get("state") ?? undefined,
278
288
  };
@@ -280,5 +290,5 @@ export function parseCallbackInput(input: string): { code?: string; state?: stri
280
290
 
281
291
  // Assume raw code, possibly with state after #
282
292
  const [code, state] = value.split("#", 2);
283
- return { code, state };
293
+ return { kind: "raw", code, state };
284
294
  }
@@ -1,4 +1,5 @@
1
1
  import type { OAuthController, OAuthCredentials } from "./types";
2
+ import { parseCallbackInput } from "./callback-server";
2
3
  import type { OcxConfig, OcxProviderConfig, RefreshPolicy } from "../types";
3
4
  import { loadConfig, resolveEnvValue, saveConfig } from "../config";
4
5
  import { maskEmail } from "../lib/privacy";
@@ -362,10 +363,95 @@ export async function runLogin(provider: string, ctrl: OAuthController, opts?: L
362
363
  * GUI async login: start the flow, return the auth URL EARLY (the flow keeps running in the
363
364
  * background until the callback server captures the redirect), with a concurrency guard and an
364
365
  * error surfaced via getLoginStatus().
366
+ *
367
+ * Manual fallback: when the browser cannot reach the loopback callback (remote GUI, SSH, blocked
368
+ * localhost), the GUI can POST the final redirect URL or authorization code via
369
+ * submitManualLoginCode(), which feeds OAuthController.onManualCodeInput.
365
370
  */
366
371
  const loginState = new Map<string, { error?: string; done: boolean }>();
367
372
  const loginAbort = new Map<string, AbortController>();
368
373
 
374
+ /** Pending paste for a login in progress: either a waiter or a stashed early submission. */
375
+ interface ManualCodeSlot {
376
+ pendingInput?: string;
377
+ resolve?: (value: string) => void;
378
+ /** Registered by the callback flow so submits can validate state synchronously. */
379
+ expectedState?: string;
380
+ }
381
+ const loginManual = new Map<string, ManualCodeSlot>();
382
+
383
+ function clearManualCodeSlot(provider: string): void {
384
+ loginManual.delete(provider);
385
+ }
386
+
387
+ function ensureManualCodeSlot(provider: string): ManualCodeSlot {
388
+ let slot = loginManual.get(provider);
389
+ if (!slot) {
390
+ slot = {};
391
+ loginManual.set(provider, slot);
392
+ }
393
+ return slot;
394
+ }
395
+
396
+ /** Wait for a GUI/CLI paste of the OAuth redirect URL or code (or return a stashed early submit). */
397
+ function waitForManualLoginCode(provider: string, signal: AbortSignal, expectedState?: string): Promise<string> {
398
+ if (signal.aborted) {
399
+ return Promise.reject(new Error(`OAuth callback cancelled: ${signal.reason}`));
400
+ }
401
+ const slot = ensureManualCodeSlot(provider);
402
+ if (expectedState !== undefined) slot.expectedState = expectedState;
403
+ if (slot.pendingInput !== undefined) {
404
+ const value = slot.pendingInput;
405
+ slot.pendingInput = undefined;
406
+ return Promise.resolve(value);
407
+ }
408
+ return new Promise<string>((resolve, reject) => {
409
+ const onAbort = () => {
410
+ if (slot.resolve === resolve) slot.resolve = undefined;
411
+ reject(new Error(`OAuth callback cancelled: ${signal.reason}`));
412
+ };
413
+ signal.addEventListener("abort", onAbort, { once: true });
414
+ slot.resolve = (value: string) => {
415
+ signal.removeEventListener("abort", onAbort);
416
+ if (slot.resolve === resolve) slot.resolve = undefined;
417
+ resolve(value);
418
+ };
419
+ });
420
+ }
421
+
422
+ /**
423
+ * Feed a pasted redirect URL or authorization code into an in-progress GUI login.
424
+ * Returns ok:false when no login is waiting (or input is empty). Invalid pastes are accepted
425
+ * here and re-prompted by the OAuth callback loop if they cannot be parsed / fail state checks.
426
+ */
427
+ export function submitManualLoginCode(provider: string, input: string): { ok: true } | { ok: false; error: string } {
428
+ const trimmed = input.trim();
429
+ if (!trimmed) return { ok: false, error: "empty code" };
430
+ const st = loginState.get(provider);
431
+ if (!st || st.done) return { ok: false, error: "no login in progress" };
432
+ const slot = ensureManualCodeSlot(provider);
433
+ // Synchronous validation (validated request/ack): reject un-parseable input and
434
+ // authorization responses (url/query kind) whose state is missing or mismatched
435
+ // once the flow has registered its expected state. Raw codes stay in-session-PKCE
436
+ // protected. Early posts (flow not yet waiting, no expectedState) are stashed and
437
+ // re-validated by the callback loop.
438
+ const parsed = parseCallbackInput(trimmed);
439
+ if (!parsed.code) return { ok: false, error: "no authorization code found in input" };
440
+ if (parsed.kind !== "raw" && slot.expectedState !== undefined) {
441
+ if (parsed.state === undefined) return { ok: false, error: "redirect URL is missing the state parameter" };
442
+ if (parsed.state !== slot.expectedState) return { ok: false, error: "state mismatch — paste the redirect URL from THIS login attempt" };
443
+ }
444
+ if (slot.resolve) {
445
+ const resolve = slot.resolve;
446
+ slot.resolve = undefined;
447
+ resolve(trimmed);
448
+ } else {
449
+ // Race: GUI may POST before the flow reaches onManualCodeInput — stash for the waiter.
450
+ slot.pendingInput = trimmed;
451
+ }
452
+ return { ok: true };
453
+ }
454
+
369
455
  export interface OAuthAccountSummary { id: string; email?: string; active: boolean; needsReauth?: boolean; expiresAt?: number }
370
456
 
371
457
  export function getLoginStatus(provider: string): { loggedIn: boolean; email?: string; source?: OAuthCredentials["source"]; error?: string; done: boolean; activeAccountId?: string; accounts?: OAuthAccountSummary[] } {
@@ -400,6 +486,7 @@ export function oauthLoginSummary(): Array<{ provider: string; loggedIn: boolean
400
486
  export function clearLoginState(provider: string): void {
401
487
  loginAbort.get(provider)?.abort("cleared");
402
488
  loginAbort.delete(provider);
489
+ clearManualCodeSlot(provider);
403
490
  loginState.delete(provider);
404
491
  }
405
492
 
@@ -409,6 +496,7 @@ export function cancelLoginFlow(provider: string): boolean {
409
496
  if (!ctrl && (!existing || existing.done)) return false;
410
497
  ctrl?.abort("cancelled");
411
498
  loginAbort.delete(provider);
499
+ clearManualCodeSlot(provider);
412
500
  loginState.set(provider, { done: true, error: "Login cancelled" });
413
501
  return true;
414
502
  }
@@ -420,6 +508,7 @@ export async function startLoginFlow(provider: string, opts?: LoginOpts): Promis
420
508
  if (existing && !existing.done) {
421
509
  throw new Error(`A login for ${provider} is already in progress`);
422
510
  }
511
+ clearManualCodeSlot(provider);
423
512
  loginState.set(provider, { done: false });
424
513
  const abort = new AbortController();
425
514
  loginAbort.set(provider, abort);
@@ -431,12 +520,15 @@ export async function startLoginFlow(provider: string, opts?: LoginOpts): Promis
431
520
  resolve({ url, instructions });
432
521
  },
433
522
  onProgress: () => {},
523
+ // GUI fallback when the browser cannot hit the loopback callback server.
524
+ onManualCodeInput: (expectedState?: string) => waitForManualLoginCode(provider, abort.signal, expectedState),
434
525
  signal: abort.signal,
435
526
  };
436
527
  // Background: runLogin persists the credential + upserts the provider entry to disk config.
437
528
  runLogin(provider, ctrl, opts)
438
529
  .then(() => {
439
530
  loginAbort.delete(provider);
531
+ clearManualCodeSlot(provider);
440
532
  loginState.set(provider, { done: true });
441
533
  // Local-token import (grok-cli / Claude Code keychain) completes WITHOUT firing onAuth —
442
534
  // resolve so the GUI call returns instead of hanging.
@@ -444,6 +536,7 @@ export async function startLoginFlow(provider: string, opts?: LoginOpts): Promis
444
536
  })
445
537
  .catch((e: unknown) => {
446
538
  loginAbort.delete(provider);
539
+ clearManualCodeSlot(provider);
447
540
  const msg = e instanceof Error ? e.message : String(e);
448
541
  loginState.set(provider, { done: true, error: msg });
449
542
  if (!urlResolved) reject(e);
@@ -31,7 +31,7 @@ export interface ProviderAccountSet {
31
31
  export interface OAuthController {
32
32
  onAuth?(info: { url: string; instructions?: string }): void;
33
33
  onProgress?(message: string): void;
34
- onManualCodeInput?(): Promise<string>;
34
+ onManualCodeInput?(expectedState?: string): Promise<string>;
35
35
  signal?: AbortSignal;
36
36
  }
37
37
 
@@ -48,6 +48,7 @@ export interface DerivedProviderPreset {
48
48
  oauthProvider?: string;
49
49
  dashboardUrl?: string;
50
50
  note?: string;
51
+ keyOptional?: boolean;
51
52
  }
52
53
 
53
54
  export function listRegistryEntries(): readonly ProviderRegistryEntry[] {
@@ -69,6 +70,7 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon
69
70
  authMode: entry.authKind === "local" ? undefined : entry.authKind,
70
71
  ...(entry.keyOptional !== undefined ? { keyOptional: entry.keyOptional } : {}),
71
72
  ...(entry.modelSuffixBracketStrip !== undefined ? { modelSuffixBracketStrip: entry.modelSuffixBracketStrip } : {}),
73
+ ...(entry.staticHeaders ? { headers: { ...entry.staticHeaders } } : {}),
72
74
  ...(entry.defaultModel ? { defaultModel: entry.defaultModel } : {}),
73
75
  ...(entry.models ? { models: [...entry.models] } : {}),
74
76
  ...(entry.liveModels !== undefined ? { liveModels: entry.liveModels } : {}),
@@ -193,6 +195,7 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig
193
195
  if (prov.escapeBuiltinToolNames === undefined && seed.escapeBuiltinToolNames !== undefined) prov.escapeBuiltinToolNames = seed.escapeBuiltinToolNames;
194
196
  if (prov.keyOptional === undefined && seed.keyOptional !== undefined) prov.keyOptional = seed.keyOptional;
195
197
  if (prov.modelSuffixBracketStrip === undefined && seed.modelSuffixBracketStrip !== undefined) prov.modelSuffixBracketStrip = seed.modelSuffixBracketStrip;
198
+ if (!prov.headers && seed.headers) prov.headers = { ...seed.headers };
196
199
  }
197
200
 
198
201
  export function deriveFeaturedProviderIds(): string[] {
@@ -227,6 +230,7 @@ function entryToPreset(entry: ProviderRegistryEntry): DerivedProviderPreset {
227
230
  ...(entry.authKind === "oauth" ? { oauthProvider: entry.oauthId ?? entry.id } : {}),
228
231
  ...(entry.dashboardUrl ? { dashboardUrl: entry.dashboardUrl } : {}),
229
232
  ...(entry.note ? { note: entry.note } : {}),
233
+ ...(entry.keyOptional ? { keyOptional: true } : {}),
230
234
  };
231
235
  }
232
236
 
@@ -23,6 +23,8 @@ export interface ProviderRegistryEntry {
23
23
  allowPrivateNetworkByDefault?: boolean;
24
24
  keyOptional?: boolean;
25
25
  allowBaseUrlOverride?: boolean;
26
+ /** Static headers merged into every upstream request for this provider. */
27
+ staticHeaders?: Record<string, string>;
26
28
  modelSuffixBracketStrip?: boolean;
27
29
  featured?: boolean;
28
30
  dashboardPreset?: boolean;
@@ -66,7 +68,7 @@ export type ProviderConfigSeed = Pick<
66
68
  | "reasoningEfforts" | "modelReasoningEfforts" | "reasoningEffortMap" | "modelReasoningEffortMap"
67
69
  | "noVisionModels" | "noReasoningModels" | "noTemperatureModels" | "noTopPModels" | "noPenaltyModels"
68
70
  | "autoToolChoiceOnlyModels" | "preserveReasoningContentModels" | "thinkingToggleModels" | "thinkingBudgetModels" | "escapeBuiltinToolNames"
69
- | "googleMode" | "project" | "location"
71
+ | "googleMode" | "project" | "location" | "headers"
70
72
  >;
71
73
 
72
74
  // Shared between the OAuth (Claude account) and API-key Anthropic entries so both expose the
@@ -133,6 +135,7 @@ const THINKING_BUDGET_MODELS = [
133
135
  ];
134
136
  const OPENCODE_GO_THINKING_BUDGET_MODELS = ["qwen3.5-plus", "qwen3.6-plus", "qwen3.7-max", "qwen3.7-plus"];
135
137
  const DEEPSEEK_THINKING_MODELS = ["deepseek-v4-pro", "deepseek-v4-flash"];
138
+ const OPENCODE_FREE_DEEPSEEK_MODELS = ["deepseek-v4-flash-free"];
136
139
  // "max" is advertised too: the wire map routes xhigh->max and max->max, so the picker
137
140
  // should surface the max tier instead of hiding it behind xhigh.
138
141
  const DEEPSEEK_THINKING_EFFORTS = ["high", "xhigh", "max"];
@@ -608,8 +611,41 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
608
611
  },
609
612
  { id: "opencode-zen", label: "opencode zen", baseUrl: "https://opencode.ai/zen/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://opencode.ai/auth" },
610
613
  { id: "vercel-ai-gateway", label: "Vercel AI Gateway", baseUrl: "https://ai-gateway.vercel.sh/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://vercel.com/dashboard" },
614
+ {
615
+ id: "opencode-free",
616
+ label: "OpenCode Free",
617
+ adapter: "openai-chat",
618
+ baseUrl: "https://opencode.ai/zen/v1",
619
+ authKind: "key",
620
+ keyOptional: true,
621
+ featured: true,
622
+ liveModels: true,
623
+ note: "No key needed — public desktop tier. OpenCode currently advertises about 200 Big Pickle/free-model requests per 5 hours. Free models are discovered live from Zen. Data use: per OpenCode's Zen docs (https://opencode.ai/docs/zen/), prompts sent to free models may be retained and used for training/improvement — do not send confidential material through this provider.",
624
+ dashboardUrl: "https://opencode.ai",
625
+ staticHeaders: {
626
+ "x-opencode-client": "desktop",
627
+ },
628
+ modelReasoningEfforts: Object.fromEntries(OPENCODE_FREE_DEEPSEEK_MODELS.map(id => [id, DEEPSEEK_THINKING_EFFORTS])),
629
+ modelReasoningEffortMap: Object.fromEntries(OPENCODE_FREE_DEEPSEEK_MODELS.map(id => [id, DEEPSEEK_THINKING_REASONING_MAP])),
630
+ preserveReasoningContentModels: OPENCODE_FREE_DEEPSEEK_MODELS,
631
+ noVisionModels: OPENCODE_FREE_DEEPSEEK_MODELS,
632
+ },
611
633
  { id: "xiaomi", label: "Xiaomi MiMo", baseUrl: "https://api.xiaomimimo.com/anthropic", adapter: "anthropic", authKind: "key", dashboardUrl: "https://xiaomimimo.com", defaultModel: "mimo-v2.5-pro" },
612
634
  { id: "kilo", label: "Kilo", baseUrl: "https://api.kilo.ai/api/gateway", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://kilo.ai" },
635
+ {
636
+ id: "mimo-free",
637
+ label: "MiMo Free",
638
+ adapter: "mimo-free",
639
+ baseUrl: "https://api.xiaomimimo.com/api/free-ai/openai/chat",
640
+ authKind: "key",
641
+ keyOptional: true,
642
+ featured: true,
643
+ liveModels: true,
644
+ dashboardUrl: "https://xiaomimimo.com",
645
+ defaultModel: "mimo-auto",
646
+ models: ["mimo-auto"],
647
+ note: "No key needed — uses Xiaomi MiMo's free public tier (limited-time offer). A JWT is bootstrapped automatically with an anonymous random client id stored locally. The endpoint contract mirrors the official MiMoCode client and is not publicly documented — Xiaomi may change or restrict it at any time. Prompts may be processed/retained by Xiaomi; do not send confidential material.",
648
+ },
613
649
  { id: "cloudflare-ai-gateway", label: "Cloudflare AI Gateway", baseUrl: "https://gateway.ai.cloudflare.com/v1/{account-id}/{gateway}/anthropic", adapter: "anthropic", authKind: "key", dashboardUrl: "https://dash.cloudflare.com/?to=/:account/ai/ai-gateway" },
614
650
  // FREEZE 2026-07-10: /models is auth-gated, so ids remain unverified. Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md.
615
651
  { id: "github-copilot", label: "GitHub Copilot", baseUrl: "https://api.githubcopilot.com", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://github.com/settings/copilot" },
@@ -3,6 +3,7 @@ import { createAzureAdapter } from "../adapters/azure";
3
3
  import { createCursorAdapter } from "../adapters/cursor";
4
4
  import { createGoogleAdapter } from "../adapters/google";
5
5
  import { createKiroAdapter } from "../adapters/kiro";
6
+ import { createMimoFreeAdapter } from "../adapters/mimo-free";
6
7
  import { createOpenAIChatAdapter } from "../adapters/openai-chat";
7
8
  import { createResponsesPassthroughAdapter } from "../adapters/openai-responses";
8
9
  import type { OcxProviderConfig } from "../types";
@@ -40,6 +41,8 @@ export function resolveAdapter(providerConfig: OcxProviderConfig, cacheRetention
40
41
  return createAzureAdapter(providerConfig);
41
42
  case "cursor":
42
43
  return createCursorAdapter(providerConfig);
44
+ case "mimo-free":
45
+ return createMimoFreeAdapter(providerConfig);
43
46
  default:
44
47
  throw new Error(`Unknown adapter: ${providerConfig.adapter}`);
45
48
  }
@@ -6,6 +6,7 @@ import {
6
6
  providerHeadersConfigError,
7
7
  } from "../config";
8
8
  import { providerDestinationConfigError } from "../lib/destination-policy";
9
+ import { getProviderRegistryEntry } from "../providers/registry";
9
10
  import type { OcxConfig, OcxProviderConfig } from "../types";
10
11
 
11
12
  let _corsOrigin = "http://localhost:10100";
@@ -210,6 +211,7 @@ export function safeConfigDTO(config: OcxConfig): unknown {
210
211
  "disabled",
211
212
  "allowPrivateNetwork",
212
213
  "authMode",
214
+ "keyOptional",
213
215
  "liveModels",
214
216
  "models",
215
217
  "contextWindow",
@@ -227,6 +229,8 @@ export function safeConfigDTO(config: OcxConfig): unknown {
227
229
  ] as const) {
228
230
  copyIfDefined(dto, provider, key);
229
231
  }
232
+ const registryNote = getProviderRegistryEntry(name)?.note;
233
+ if (typeof registryNote === "string" && registryNote.trim()) dto.note = registryNote;
230
234
  providers[name] = dto;
231
235
  }
232
236
  return {
@@ -16,6 +16,7 @@ import {
16
16
  isOAuthProvider,
17
17
  listOAuthProviders,
18
18
  startLoginFlow,
19
+ submitManualLoginCode,
19
20
  upsertOAuthProvider,
20
21
  } from "../oauth";
21
22
  import { removeCredential } from "../oauth/store";
@@ -28,6 +29,7 @@ import { readUsageEntries } from "../usage/log";
28
29
  import { getUsageDebugLogEntries } from "../usage/debug";
29
30
  import { parseRange, summarizeUsage } from "../usage/summary";
30
31
  import { stripCodexRuntimeProviderFields } from "../codex/auth-context";
32
+ import { getProviderRegistryEntry } from "../providers/registry";
31
33
  import { getDebugLogEntries } from "../lib/debug-log-buffer";
32
34
  import { getInjectionDebugLogEntries } from "../lib/injection-debug-log";
33
35
  import {
@@ -430,7 +432,7 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
430
432
  // let the (possibly new) apiKey join the pool as the active entry.
431
433
  const existingPool = config.providers[name]?.apiKeyPool;
432
434
  if (existingPool && !prov.apiKeyPool) prov.apiKeyPool = existingPool;
433
- config.providers[name] = prov;
435
+ config.providers[name] = stripRegistryOnlyStaticHeaders(name, prov);
434
436
  if (body.setDefault) config.defaultProvider = name;
435
437
  save(config);
436
438
  if (prov.apiKey && prov.apiKeyPool) {
@@ -1043,6 +1045,21 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
1043
1045
  }
1044
1046
  }
1045
1047
 
1048
+ // Manual fallback for browser OAuth: paste the final redirect URL (or authorization code)
1049
+ // when the browser cannot reach the loopback callback (remote/SSH/blocked localhost).
1050
+ if (url.pathname === "/api/oauth/login/code" && req.method === "POST") {
1051
+ const body = await req.json().catch(() => ({})) as { provider?: string; input?: string; code?: string };
1052
+ const provider = (body.provider ?? "").trim().toLowerCase();
1053
+ if (!isOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
1054
+ const input = typeof body.input === "string" ? body.input : typeof body.code === "string" ? body.code : "";
1055
+ // Authorization responses are measured in hundreds of bytes; never accept the
1056
+ // generic management-body allowance here.
1057
+ if (input.length > 4096) return jsonResponse({ error: "input too long" }, 400);
1058
+ const result = submitManualLoginCode(provider, input);
1059
+ if (!result.ok) return jsonResponse({ error: result.error }, 409);
1060
+ return jsonResponse({ ok: true });
1061
+ }
1062
+
1046
1063
  if (url.pathname === "/api/oauth/status" && req.method === "GET") {
1047
1064
  const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase();
1048
1065
  return jsonResponse(getLoginStatus(provider));
@@ -1207,3 +1224,15 @@ export async function fetchAllModels(config: OcxConfig): Promise<CatalogModel[]>
1207
1224
  const { gatherRoutedModels } = await import("../codex/catalog");
1208
1225
  return gatherRoutedModels(config);
1209
1226
  }
1227
+
1228
+ function stripRegistryOnlyStaticHeaders(name: string, provider: OcxProviderConfig): OcxProviderConfig {
1229
+ const entry = getProviderRegistryEntry(name);
1230
+ if (!entry?.staticHeaders || !provider.headers) return provider;
1231
+ const headerEntries = Object.entries(provider.headers);
1232
+ const staticEntries = Object.entries(entry.staticHeaders);
1233
+ if (headerEntries.length !== staticEntries.length) return provider;
1234
+ const matchesRegistryStaticHeaders = staticEntries.every(([key, value]) => provider.headers?.[key] === value);
1235
+ if (!matchesRegistryStaticHeaders) return provider;
1236
+ const { headers: _headers, ...rest } = provider;
1237
+ return rest;
1238
+ }
@@ -212,10 +212,14 @@ export function responseWithDeferredRequestLog(
212
212
  return response;
213
213
  }
214
214
  if (!response.body || !contentType.includes("text/event-stream")) {
215
- if (response.body && contentType.includes("application/json")) {
215
+ if (response.body && (contentType.includes("application/json") || response.status >= 400)) {
216
216
  const finalizeJsonLog = async () => {
217
217
  const text = await response.text();
218
- inspectResponseLogJson(logCtx, text);
218
+ // Non-JSON error bodies: inspect/log only a bounded prefix (the stored
219
+ // upstreamError is 500 chars anyway); the FULL text is still forwarded to the
220
+ // client below, unchanged. JSON bodies keep full inspection (usage parsing).
221
+ const isJson = contentType.includes("application/json");
222
+ inspectResponseLogJson(logCtx, isJson ? text : text.slice(0, 8192));
219
223
  addFinalRequestLog(requestId, start, logCtx, response.status, { closeReason: "non_stream" }, addLog);
220
224
  return text;
221
225
  };
@@ -294,7 +294,10 @@ function captureUpstreamError(logCtx: RequestLogContext, text: string | null): v
294
294
  logCtx.upstreamError = redactSecretString(incompleteReasonLabel(reason.trim())).slice(0, 500);
295
295
  }
296
296
  } catch {
297
- /* not JSON; nothing to capture */
297
+ const trimmed = text.trim();
298
+ if (trimmed) {
299
+ logCtx.upstreamError = redactSecretString(trimmed).slice(0, 500);
300
+ }
298
301
  }
299
302
  }
300
303