@bitkyc08/opencodex 2.43.0 → 2.44.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.
Files changed (138) hide show
  1. package/README.md +25 -17
  2. package/gui/dist/assets/index-B7_K1Hsj.js +115 -0
  3. package/gui/dist/assets/index-ltx3L-WS.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/command-code.ts +23 -2
  7. package/src/adapters/cursor/native-exec.ts +63 -11
  8. package/src/adapters/cursor/tool-definitions.ts +1 -1
  9. package/src/adapters/cursor/tool-schemas.ts +59 -0
  10. package/src/adapters/kiro.ts +54 -8
  11. package/src/adapters/openai-chat.ts +23 -6
  12. package/src/adapters/openai-responses.ts +3 -1
  13. package/src/adapters/opencode-go.ts +35 -0
  14. package/src/claude/agents-inject.ts +12 -5
  15. package/src/claude/desktop-3p-library.ts +89 -0
  16. package/src/claude/desktop-3p.ts +166 -95
  17. package/src/claude/desktop-discovery-inputs.ts +44 -0
  18. package/src/claude/desktop-profile.ts +1 -1
  19. package/src/claude/desktop-remote-store-artifact.ts +185 -0
  20. package/src/claude/desktop-remote-store-io.ts +91 -0
  21. package/src/claude/desktop-remote-store-state.ts +127 -0
  22. package/src/claude/desktop-remote-store.ts +269 -0
  23. package/src/claude/inbound-model-options.ts +10 -2
  24. package/src/claude/inbound-records.ts +7 -0
  25. package/src/claude/inbound.ts +1 -1
  26. package/src/claude/model-info.ts +16 -3
  27. package/src/cli/account-api.ts +7 -1
  28. package/src/cli/aside-profiles.ts +17 -0
  29. package/src/cli/capabilities.ts +39 -0
  30. package/src/cli/claude-agent-startup-sync.ts +3 -1
  31. package/src/cli/claude-desktop.ts +127 -18
  32. package/src/cli/connect.ts +27 -14
  33. package/src/cli/dispatch.ts +30 -16
  34. package/src/cli/export-command.ts +3 -1
  35. package/src/cli/index.ts +28 -16
  36. package/src/cli/integrations.ts +46 -12
  37. package/src/cli/opencode.ts +11 -4
  38. package/src/cli/runtime-api.ts +7 -2
  39. package/src/cli/uninstall-client-state.ts +78 -0
  40. package/src/client/connect.ts +476 -223
  41. package/src/client/hub-client.ts +81 -0
  42. package/src/client/hub-relay.ts +38 -6
  43. package/src/client/lifecycle-lock.ts +129 -0
  44. package/src/client/state.ts +71 -42
  45. package/src/clients/aside-profiles.ts +224 -0
  46. package/src/codex/account-lifecycle.ts +5 -9
  47. package/src/codex/auth-api.ts +50 -4
  48. package/src/codex/catalog/metadata.ts +0 -2
  49. package/src/codex/catalog/provider-fetch.ts +7 -5
  50. package/src/codex/catalog/sync.ts +88 -24
  51. package/src/codex/convergence.ts +2 -0
  52. package/src/codex/inject.ts +45 -14
  53. package/src/codex/journal.ts +6 -6
  54. package/src/codex/quota-refresh-outcome.ts +27 -0
  55. package/src/codex/shim.ts +7 -1
  56. package/src/combos/resolve.ts +10 -7
  57. package/src/config.ts +18 -5
  58. package/src/generated/compatibility-version.json +209 -105
  59. package/src/images/loop.ts +5 -2
  60. package/src/integrations/aside-profile-context.ts +270 -0
  61. package/src/integrations/aside-profile-journal.ts +229 -0
  62. package/src/integrations/aside-profiles.ts +176 -0
  63. package/src/integrations/catalog-refresh.ts +40 -0
  64. package/src/integrations/config-io.ts +16 -1
  65. package/src/integrations/owned-refresh.ts +14 -2
  66. package/src/integrations/state.ts +3 -1
  67. package/src/integrations/writer.ts +3 -1
  68. package/src/lab/fabric/producer-isolate.ts +84 -46
  69. package/src/lib/account-selection-events.ts +32 -0
  70. package/src/lib/errors.ts +9 -1
  71. package/src/lib/provider-outbound.ts +2 -45
  72. package/src/lib/proxy-env.ts +67 -0
  73. package/src/oauth/account-quota-rank.ts +9 -1
  74. package/src/oauth/anthropic-routing.ts +91 -23
  75. package/src/oauth/generic-account-failover.ts +15 -53
  76. package/src/oauth/index.ts +67 -68
  77. package/src/oauth/pool-settings-capability.ts +2 -1
  78. package/src/oauth/store.ts +91 -7
  79. package/src/oauth/types.ts +8 -0
  80. package/src/providers/api-key-selection.ts +110 -0
  81. package/src/providers/api-keys.ts +38 -31
  82. package/src/providers/context-cap.ts +27 -3
  83. package/src/providers/key-failover.ts +27 -17
  84. package/src/providers/provider-id-rewrite.ts +10 -8
  85. package/src/providers/registry.ts +1 -0
  86. package/src/responses/custom-tool-compat.ts +10 -0
  87. package/src/responses/function-call-compat.ts +173 -0
  88. package/src/responses/namespace-tool-compat.ts +27 -4
  89. package/src/responses/parser.ts +8 -0
  90. package/src/responses/task-input.ts +36 -0
  91. package/src/responses/tool-name-aliases.ts +79 -0
  92. package/src/router.ts +2 -0
  93. package/src/routing/capability.ts +10 -3
  94. package/src/routing/compatibility/assemble.ts +16 -1
  95. package/src/routing/evaluator.ts +5 -1
  96. package/src/server/auth-cors.ts +3 -0
  97. package/src/server/chat-native.ts +44 -9
  98. package/src/server/claude-messages.ts +71 -36
  99. package/src/server/grok-responses-snapshot-repair.ts +333 -0
  100. package/src/server/index.ts +26 -10
  101. package/src/server/management/account-selection-stream.ts +70 -0
  102. package/src/server/management/aside-profile-routes.ts +231 -0
  103. package/src/server/management/config-routes.ts +36 -41
  104. package/src/server/management/context.ts +3 -0
  105. package/src/server/management/integration-routes.ts +61 -3
  106. package/src/server/management/logs-usage-routes.ts +1 -0
  107. package/src/server/management/model-routes.ts +20 -9
  108. package/src/server/management/model-rows.ts +10 -2
  109. package/src/server/management/oauth-account-routes.ts +7 -0
  110. package/src/server/management/provider-routes.ts +10 -8
  111. package/src/server/management/route-registry.ts +20 -2
  112. package/src/server/management-auth.ts +16 -0
  113. package/src/server/relay-eager.ts +17 -5
  114. package/src/server/relay.ts +43 -4
  115. package/src/server/request-log.ts +29 -1
  116. package/src/server/responses/agent-task-recovery-cache.ts +11 -0
  117. package/src/server/responses/agent-task-recovery.ts +24 -4
  118. package/src/server/responses/codex-ws-pool.ts +4 -4
  119. package/src/server/responses/codex-ws-session.ts +2 -2
  120. package/src/server/responses/collaboration.ts +2 -1
  121. package/src/server/responses/combo-stream-preflight.ts +26 -7
  122. package/src/server/responses/core.ts +668 -151
  123. package/src/server/responses/fetch-helpers.ts +6 -1
  124. package/src/server/responses/ws-upstream.ts +8 -3
  125. package/src/server/responses-custom-tool-repair.ts +29 -9
  126. package/src/server/responses-function-tool-repair.ts +183 -0
  127. package/src/server/responses-snapshot-codec.ts +14 -0
  128. package/src/server/responses-snapshot-repair.ts +1 -13
  129. package/src/server/responses-undeclared-tool-guard.ts +1 -68
  130. package/src/server/sse-payload-rewrite.ts +3 -1
  131. package/src/service.ts +6 -2
  132. package/src/types/config.ts +23 -16
  133. package/src/types/provider.ts +14 -3
  134. package/src/types/request.ts +2 -0
  135. package/src/usage/log.ts +9 -0
  136. package/src/web-search/loop.ts +5 -2
  137. package/gui/dist/assets/index-DS1NE4Jn.css +0 -1
  138. package/gui/dist/assets/index-Djowl68T.js +0 -112
@@ -23,12 +23,13 @@ import { getConfigDir, atomicWriteFile, backupInvalidConfig, hardenConfigDir, ha
23
23
  import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
24
24
  import { recordOwnedConfigPath } from "../lib/config-ownership";
25
25
  import { MAX_PENDING_OAUTH_MUTATIONS } from "../lib/translator-budget";
26
+ import { publishAccountSelection } from "../lib/account-selection-events";
26
27
  import {
27
28
  captureConfigGeneration,
28
29
  type GenerationContext,
29
30
  } from "../lib/state-store-sweeper";
30
31
  import { validateCopilotApiBaseUrl } from "./github-copilot";
31
- import type { OAuthCredentialSource, OAuthCredentials, ProviderAccount, ProviderAccountSet } from "./types";
32
+ import type { OAuthAccountSelection, OAuthCredentialSource, OAuthCredentials, ProviderAccount, ProviderAccountSet } from "./types";
32
33
 
33
34
  export type AuthStore = Record<string, ProviderAccountSet>;
34
35
 
@@ -536,7 +537,15 @@ function normalizeAccountSet(raw: unknown): { set: ProviderAccountSet | null; wa
536
537
  const active = typeof candidate.activeAccountId === "string" && accounts.some(a => a.id === candidate.activeAccountId)
537
538
  ? candidate.activeAccountId
538
539
  : accounts[0]!.id;
539
- return { set: { activeAccountId: active, accounts }, wasLegacy: false };
540
+ const set: ProviderAccountSet = { activeAccountId: active, accounts };
541
+ // Healing a dangling active id invalidates its old selection generation. Reads stay
542
+ // deterministic; only the serialized writer creates new revisions.
543
+ if (active === candidate.activeAccountId
544
+ && typeof candidate.selectionRevision === "string"
545
+ && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(candidate.selectionRevision)) {
546
+ set.selectionRevision = candidate.selectionRevision;
547
+ }
548
+ return { set, wasLegacy: false };
540
549
  }
541
550
  // Legacy single-credential value.
542
551
  const cred = normalizeCredential(raw);
@@ -670,9 +679,35 @@ function serializeMutation<T>(work: () => Promise<T>, retainedValues: readonly u
670
679
  export function mutateStore<T>(fn:(store:AuthStore)=>T|Promise<T>, retainedValues: readonly unknown[] = [], options?: { waitMs?: number; assertBeforePersist?: () => void }):Promise<T>{return serializeMutation(async()=>{const guard=await createOAuthFileLock({path:getAuthStoreLockPath(),staleAfterMs:30000}).acquire();try{
671
680
  const { store, hadLegacy } = loadAuthStoreInternal();
672
681
  if (hadLegacy) backupLegacyOnce();
682
+ const selections = new Map(Object.entries(store).map(([provider, set]) => [provider, {
683
+ set,
684
+ accountId: set.activeAccountId,
685
+ revision: set.selectionRevision,
686
+ accountIds: set.accounts.map(account => account.id),
687
+ }]));
673
688
  const result = await fn(store);
674
689
  options?.assertBeforePersist?.();
690
+ const changedProviders: string[] = [];
691
+ for (const provider of new Set([...selections.keys(), ...Object.keys(store)])) {
692
+ const before = selections.get(provider);
693
+ const after = store[provider];
694
+ if (!after) {
695
+ if (before) changedProviders.push(provider);
696
+ continue;
697
+ }
698
+ const replaced = before?.set !== after;
699
+ const removed = before?.accountIds.some(id => !after.accounts.some(account => account.id === id));
700
+ if (replaced || removed || before?.accountId !== after.activeAccountId) {
701
+ // Replacement/rollback must never restore a previous generation. A common
702
+ // selection commit already assigned its revision before forming its result.
703
+ if (replaced || before?.revision === after.selectionRevision) after.selectionRevision = randomUUID();
704
+ }
705
+ if (!before || before.accountId !== after.activeAccountId || before.revision !== after.selectionRevision) {
706
+ changedProviders.push(provider);
707
+ }
708
+ }
675
709
  persist(store);
710
+ for (const provider of changedProviders) publishAccountSelection(provider, "oauth");
676
711
  return result;
677
712
  }finally{guard.release();}}, retainedValues, options?.waitMs);
678
713
  }
@@ -700,6 +735,8 @@ export async function saveCredential(
700
735
  if (!safe) return;
701
736
  await mutateStore(store => {
702
737
  const set = store[provider];
738
+ // Login explicitly selects an account, including a re-login to the same slot.
739
+ if (set) set.selectionRevision = randomUUID();
703
740
  const identity = safe.accountId ?? safe.email;
704
741
  if (!set || SINGLE_SLOT_PROVIDERS.has(provider)) {
705
742
  const id = newAccountId(safe);
@@ -876,13 +913,60 @@ export async function saveAccountCredential(
876
913
  }, [provider, accountId, safe], { assertBeforePersist: opts.assertBeforePersist });
877
914
  }
878
915
 
879
- export async function setActiveAccount(provider: string, accountId: string): Promise<boolean> {
916
+ function accountSelection(set: ProviderAccountSet): OAuthAccountSelection {
917
+ return {
918
+ accountId: set.activeAccountId,
919
+ ...(set.selectionRevision !== undefined ? { revision: set.selectionRevision } : {}),
920
+ };
921
+ }
922
+
923
+ export function captureOAuthAccountSelection(provider: string): OAuthAccountSelection | null {
924
+ const set = getAccountSet(provider);
925
+ return set ? accountSelection(set) : null;
926
+ }
927
+
928
+ /**
929
+ * Shared manual/automatic selection owner. An expected snapshot marks an automatic
930
+ * proposal: validating an unchanged selection preserves its revision. An unconditional
931
+ * (manual) selection always advances it, even when reselecting the current account.
932
+ */
933
+ export async function commitOAuthAccountSelection(
934
+ provider: string,
935
+ accountId: string,
936
+ options: {
937
+ expectedSelection?: OAuthAccountSelection;
938
+ expectedCredentialGeneration?: string;
939
+ requireUsableAccount?: boolean;
940
+ } = {},
941
+ ): Promise<OAuthAccountSelection | null> {
942
+ // Snapshot caller-owned options before waiting for the serialized writer.
943
+ const expected = options.expectedSelection ? { ...options.expectedSelection } : undefined;
944
+ const { expectedCredentialGeneration, requireUsableAccount } = options;
945
+ const valid = (set: ProviderAccountSet): boolean => {
946
+ if (expected && (set.activeAccountId !== expected.accountId || set.selectionRevision !== expected.revision)) return false;
947
+ const account = set.accounts.find(account => account.id === accountId);
948
+ if (!account || (requireUsableAccount && account.needsReauth === true)) return false;
949
+ return expectedCredentialGeneration === undefined || credentialGeneration(account.credential) === expectedCredentialGeneration;
950
+ };
951
+ if (expected?.accountId === accountId) {
952
+ // Ordinary admission is one synchronous read/validation, with no await, writer
953
+ // queue, or persistence. A changed selection must still take the guarded writer.
954
+ const set = getAccountSet(provider);
955
+ return set && valid(set) ? accountSelection(set) : null;
956
+ }
880
957
  return await mutateStore(store => {
881
958
  const set = store[provider];
882
- if (!set || !set.accounts.some(a => a.id === accountId)) return false;
883
- set.activeAccountId = accountId;
884
- return true;
885
- }, [provider, accountId]);
959
+ if (!set || !valid(set)) return null;
960
+ if (!expected || set.activeAccountId !== accountId) {
961
+ set.activeAccountId = accountId;
962
+ set.selectionRevision = randomUUID();
963
+ }
964
+ return accountSelection(set);
965
+ }, [provider, accountId, expected, expectedCredentialGeneration]);
966
+ }
967
+
968
+ export async function setActiveAccount(provider: string, accountId: string): Promise<boolean> {
969
+ return (await commitOAuthAccountSelection(provider, accountId)) !== null;
886
970
  }
887
971
 
888
972
  export async function setAccountAlias(provider: string, accountId: string, alias: string | undefined): Promise<boolean> {
@@ -59,9 +59,17 @@ export interface ProviderAccount {
59
59
  /** auth.json value per provider: N accounts + which one requests use. */
60
60
  export interface ProviderAccountSet {
61
61
  activeAccountId: string;
62
+ /** Opaque selection generation; absent in legacy stores, independent of token refresh. */
63
+ selectionRevision?: string;
62
64
  accounts: ProviderAccount[];
63
65
  }
64
66
 
67
+ /** Non-secret snapshot used to condition a selection on the choice that started a request. */
68
+ export interface OAuthAccountSelection {
69
+ accountId: string;
70
+ revision?: string;
71
+ }
72
+
65
73
  export interface OAuthController {
66
74
  onAuth?(info: { url: string; instructions?: string; deviceCode?: string }): void;
67
75
  onProgress?(message: string): void;
@@ -0,0 +1,110 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mutatePersistedConfig } from "../config";
3
+ import { publishAccountSelection } from "../lib/account-selection-events";
4
+ import type { OcxConfig, OcxProviderConfig } from "../types";
5
+ import type { ProviderApiKeySelection } from "../types/provider";
6
+ import { routedProviderConfig } from "../router";
7
+ import { OPENCODE_GO_SESSION_HEADER } from "./opencode-go-transport";
8
+ import { resolveProviderTransport, XAI_GROK_COMPATIBILITY, type OcxProviderTransport } from "./xai-transport";
9
+
10
+ export function captureProviderApiKeySelection(provider: OcxProviderConfig): ProviderApiKeySelection {
11
+ return {
12
+ entryId: provider.apiKeyPool?.find(entry => entry.key === provider.apiKey)?.id,
13
+ reference: provider.apiKey,
14
+ revision: provider.apiKeySelectionRevision,
15
+ };
16
+ }
17
+
18
+ function matchesSelection(provider: OcxProviderConfig, expected: ProviderApiKeySelection): boolean {
19
+ const current = captureProviderApiKeySelection(provider);
20
+ return current.entryId === expected.entryId && current.reference === expected.reference
21
+ && current.revision === expected.revision;
22
+ }
23
+
24
+ function currentKeyProvider(config: OcxConfig, name: string): OcxProviderConfig | null {
25
+ const configured = config.providers[name];
26
+ if (!configured || configured.disabled) return null;
27
+ const current = routedProviderConfig(name, { ...configured, _apiKeyAttempt: undefined });
28
+ if (current.authMode === "oauth" || current.authMode === "forward") return null;
29
+ if (current.authMode === "key" && !current.keyOptional && !current.apiKey?.trim()) return null;
30
+ return current;
31
+ }
32
+
33
+ /** Physical-send check; stored references alone do not detect a changed env/keychain value. */
34
+ export function providerApiKeySelectionIsCurrent(
35
+ config: OcxConfig,
36
+ name: string,
37
+ routedProvider: OcxProviderConfig,
38
+ ): boolean {
39
+ const current = currentKeyProvider(config, name);
40
+ const expected = routedProvider._apiKeyAttempt;
41
+ return current !== null && expected !== undefined
42
+ && matchesSelection(config.providers[name]!, expected)
43
+ && current.apiKey === routedProvider.apiKey
44
+ && current.authMode === routedProvider.authMode
45
+ && current.baseUrl === routedProvider.baseUrl;
46
+ }
47
+
48
+ /** Rebuild transport from the already committed choice; never allocate or publish a selection. */
49
+ export function resolveCurrentProviderApiKeyTransport(
50
+ config: OcxConfig,
51
+ name: string,
52
+ routedProvider: OcxProviderConfig,
53
+ ): OcxProviderConfig | null {
54
+ const current = currentKeyProvider(config, name);
55
+ if (!current) return null;
56
+ const runtime = routedProvider as OcxProviderTransport;
57
+ const headers = { ...current.headers };
58
+ const affinityHeaders = name === "xai"
59
+ ? [XAI_GROK_COMPATIBILITY.headers.conversationId, XAI_GROK_COMPATIBILITY.headers.sessionId]
60
+ : [OPENCODE_GO_SESSION_HEADER];
61
+ for (const header of affinityHeaders) {
62
+ const configured = Object.keys(headers).some(key => key.toLowerCase() === header.toLowerCase());
63
+ const value = Object.entries(runtime.headers ?? {}).find(([key]) => key.toLowerCase() === header.toLowerCase())?.[1];
64
+ if (!configured && value !== undefined) headers[header] = value;
65
+ }
66
+ const fetch = (current as OcxProviderTransport).fetch ?? runtime.fetch;
67
+ return resolveProviderTransport(name, {
68
+ ...current,
69
+ ...(Object.keys(headers).length ? { headers } : {}),
70
+ ...(fetch ? { fetch } : {}),
71
+ });
72
+ }
73
+
74
+ type SelectionMutation<T> = { changed: boolean; value: T; selectionChanged?: boolean };
75
+ export type ProviderApiKeyCommit<T> =
76
+ | { status: "committed"; provider: OcxProviderConfig; value: T }
77
+ | { status: "superseded"; provider: OcxProviderConfig }
78
+ | { status: "unavailable" };
79
+
80
+ /** GUI and recovery share one persisted selection transaction and post-commit notification. */
81
+ export function commitProviderApiKeySelection<T>(
82
+ config: OcxConfig,
83
+ name: string,
84
+ mutation: (provider: OcxProviderConfig) => SelectionMutation<T>,
85
+ expectedSelection?: ProviderApiKeySelection,
86
+ ): ProviderApiKeyCommit<T> {
87
+ const outcome = mutatePersistedConfig<ProviderApiKeyCommit<T> & { notify?: boolean }>(fresh => {
88
+ const provider = fresh.providers[name];
89
+ if (!provider || provider.authMode === "oauth" || provider.authMode === "forward") {
90
+ return { changed: false, value: { status: "unavailable" } };
91
+ }
92
+ if (expectedSelection && !matchesSelection(provider, expectedSelection)) {
93
+ return { changed: false, value: { status: "superseded", provider: structuredClone(provider) } };
94
+ }
95
+ const before = provider.apiKey;
96
+ const result = mutation(provider);
97
+ const notify = result.selectionChanged === true || before !== provider.apiKey;
98
+ if (notify) provider.apiKeySelectionRevision = randomUUID();
99
+ delete provider._apiKeyAttempt;
100
+ return {
101
+ changed: result.changed || notify,
102
+ value: { status: "committed", provider: structuredClone(provider), value: result.value, notify },
103
+ };
104
+ });
105
+ if (outcome.status === "unavailable") return { status: "unavailable" };
106
+ const committed = outcome.value;
107
+ if (committed.status !== "unavailable") config.providers[name] = structuredClone(committed.provider);
108
+ if (committed.status === "committed" && committed.notify) publishAccountSelection(name, "api-key");
109
+ return committed;
110
+ }
@@ -10,6 +10,7 @@ import { createHash } from "node:crypto";
10
10
  import { saveConfigPreservingClaudeCode } from "../config";
11
11
  import type { OcxConfig, OcxProviderConfig } from "../types";
12
12
  import type { AccountQuotaFields } from "./quota-types";
13
+ import { commitProviderApiKeySelection } from "./api-key-selection";
13
14
 
14
15
  export interface ProviderApiKeyInfo extends AccountQuotaFields {
15
16
  id: string;
@@ -85,28 +86,33 @@ export function addProviderApiKey(config: OcxConfig, name: string, key: string,
85
86
  if (typeof key !== "string" || !key.trim()) return { error: "key is required" };
86
87
  const trimmed = sanitizeApiKeyValue(key);
87
88
  if (!trimmed) return { error: "key must not include line breaks" };
88
- const pool = ensurePool(provider);
89
89
  const id = apiKeyPoolEntryId(trimmed);
90
- const existing = pool.find(e => e.id === id);
91
- if (existing) {
92
- if (label?.trim()) existing.label = label.trim();
93
- } else {
94
- pool.push({ id, key: trimmed, ...(label?.trim() ? { label: label.trim() } : {}), addedAt: Date.now() });
95
- }
96
- provider.apiKey = trimmed;
97
- saveConfigPreservingClaudeCode(config);
98
- return { id };
90
+ const committed = commitProviderApiKeySelection(config, name, fresh => {
91
+ const pool = ensurePool(fresh);
92
+ const existing = pool.find(e => e.id === id);
93
+ if (existing) {
94
+ if (label?.trim()) existing.label = label.trim();
95
+ } else {
96
+ pool.push({ id, key: trimmed, ...(label?.trim() ? { label: label.trim() } : {}), addedAt: Date.now() });
97
+ }
98
+ fresh.apiKey = trimmed;
99
+ return { changed: true, selectionChanged: true, value: id };
100
+ });
101
+ return committed.status === "committed" ? { id } : { error: "provider selection unavailable" };
99
102
  }
100
103
 
101
104
  /** Switch the ACTIVE key (mirrors into `provider.apiKey`). Persists config. */
102
105
  export function setActiveProviderApiKey(config: OcxConfig, name: string, id: string): boolean {
103
- const provider = config.providers[name];
104
- if (!provider || !isKeyAuthProvider(provider)) return false;
105
- const entry = ensurePool(provider).find(e => e.id === id);
106
- if (!entry) return false;
107
- provider.apiKey = entry.key;
108
- saveConfigPreservingClaudeCode(config);
109
- return true;
106
+ const committed = commitProviderApiKeySelection(config, name, provider => {
107
+ const entry = provider.apiKeyPool?.find(e => e.id === id)
108
+ ?? (!provider.apiKeyPool?.length && provider.apiKey && apiKeyPoolEntryId(provider.apiKey) === id
109
+ ? { id, key: provider.apiKey } : undefined);
110
+ if (!entry) return { changed: false, value: false };
111
+ ensurePool(provider);
112
+ provider.apiKey = entry.key;
113
+ return { changed: true, selectionChanged: true, value: true };
114
+ });
115
+ return committed.status === "committed" && committed.value;
110
116
  }
111
117
 
112
118
  /** Rename a key slot without changing its id, secret, or active routing state. */
@@ -123,18 +129,19 @@ export function setProviderApiKeyLabel(config: OcxConfig, name: string, id: stri
123
129
 
124
130
  /** Remove one key; removing the active one promotes the first remaining. Persists config. */
125
131
  export function removeProviderApiKey(config: OcxConfig, name: string, id: string): boolean {
126
- const provider = config.providers[name];
127
- if (!provider || !isKeyAuthProvider(provider)) return false;
128
- const pool = ensurePool(provider);
129
- const entry = pool.find(e => e.id === id);
130
- if (!entry) return false;
131
- provider.apiKeyPool = pool.filter(e => e.id !== id);
132
- if (provider.apiKey === entry.key) {
133
- const next = provider.apiKeyPool[0];
134
- if (next) provider.apiKey = next.key;
135
- else delete provider.apiKey;
136
- }
137
- if (provider.apiKeyPool.length === 0) delete provider.apiKeyPool;
138
- saveConfigPreservingClaudeCode(config);
139
- return true;
132
+ const committed = commitProviderApiKeySelection(config, name, provider => {
133
+ const pool = provider.apiKeyPool?.length ? provider.apiKeyPool
134
+ : provider.apiKey ? [{ id: apiKeyPoolEntryId(provider.apiKey), key: provider.apiKey }] : [];
135
+ const entry = pool.find(e => e.id === id);
136
+ if (!entry) return { changed: false, value: false };
137
+ provider.apiKeyPool = pool.filter(e => e.id !== id);
138
+ if (provider.apiKey === entry.key) {
139
+ const next = provider.apiKeyPool[0];
140
+ if (next) provider.apiKey = next.key;
141
+ else delete provider.apiKey;
142
+ }
143
+ if (provider.apiKeyPool.length === 0) delete provider.apiKeyPool;
144
+ return { changed: true, value: true };
145
+ });
146
+ return committed.status === "committed" && committed.value;
140
147
  }
@@ -43,13 +43,22 @@ export function globalContextCapValue(config: Pick<OcxConfig, "contextCapValue">
43
43
  return isValidContextCap(value) ? Math.floor(value) : DEFAULT_PROVIDER_CONTEXT_CAP;
44
44
  }
45
45
 
46
+ /** Active caps win over remembered values from an earlier switch-off. */
47
+ export function selectedProviderContextCaps(config: Pick<OcxConfig, "providerContextCaps" | "providerContextCapValues">): Record<string, number> {
48
+ return { ...providerContextCaps({ providerContextCaps: config.providerContextCapValues }), ...providerContextCaps(config) };
49
+ }
50
+
46
51
  export function setProviderContextCap(config: OcxConfig, provider: string, enabled: boolean, value?: number): void {
47
52
  const next = providerContextCaps(config);
53
+ const selected = selectedProviderContextCaps(config);
48
54
  if (enabled) {
49
- next[provider] = isValidContextCap(value) ? Math.floor(value) : globalContextCapValue(config);
55
+ const remembered = Object.hasOwn(selected, provider) ? selected[provider] : undefined;
56
+ next[provider] = isValidContextCap(value) ? Math.floor(value) : (isValidContextCap(remembered) ? remembered : globalContextCapValue(config));
57
+ selected[provider] = next[provider];
50
58
  } else {
51
59
  delete next[provider];
52
60
  }
61
+ if (Object.keys(selected).length > 0) config.providerContextCapValues = selected;
53
62
  if (Object.keys(next).length > 0) config.providerContextCaps = next;
54
63
  else deleteConfigTopLevelKey(config, "providerContextCaps");
55
64
  }
@@ -66,18 +75,33 @@ export function setGlobalContextCapValue(config: OcxConfig, value: number, apply
66
75
  if (!applyToAll) return;
67
76
  const caps = providerContextCaps(config);
68
77
  for (const provider of Object.keys(caps)) caps[provider] = next;
69
- if (Object.keys(caps).length > 0) config.providerContextCaps = caps;
78
+ if (Object.keys(caps).length > 0) {
79
+ config.providerContextCaps = caps;
80
+ config.providerContextCapValues = { ...selectedProviderContextCaps(config), ...caps };
81
+ }
70
82
  }
71
83
 
72
84
  /** Enable the cap for every named provider at the current value, or clear all caps. */
73
85
  export function setAllProviderContextCaps(config: OcxConfig, providerNames: string[], enabled: boolean): void {
86
+ const selected = selectedProviderContextCaps(config);
74
87
  if (!enabled) {
88
+ if (Object.keys(selected).length > 0) config.providerContextCapValues = selected;
75
89
  deleteConfigTopLevelKey(config, "providerContextCaps");
76
90
  return;
77
91
  }
78
92
  const value = globalContextCapValue(config);
79
93
  const next: Record<string, number> = {};
80
- for (const name of providerNames) next[name] = value;
94
+ for (const name of providerNames) { next[name] = value; selected[name] = value; }
95
+ if (Object.keys(selected).length > 0) config.providerContextCapValues = selected;
81
96
  if (Object.keys(next).length > 0) config.providerContextCaps = next;
82
97
  else deleteConfigTopLevelKey(config, "providerContextCaps");
83
98
  }
99
+
100
+ /** Provider removal clears both the active limit and its remembered selection. */
101
+ export function forgetProviderContextCap(config: OcxConfig, provider: string): void {
102
+ setProviderContextCap(config, provider, false);
103
+ const values = { ...config.providerContextCapValues };
104
+ delete values[provider];
105
+ if (Object.keys(values).length > 0) config.providerContextCapValues = values;
106
+ else deleteConfigTopLevelKey(config, "providerContextCapValues");
107
+ }
@@ -8,7 +8,8 @@
8
8
  *
9
9
  * Modelled after src/codex/routing.ts cooldown logic but scoped to plain API-key pools.
10
10
  */
11
- import { mutatePersistedConfig } from "../config";
11
+ import { commitProviderApiKeySelection } from "./api-key-selection";
12
+ import type { ProviderApiKeySelection } from "../types/provider";
12
13
  import { routedProviderConfig } from "../router";
13
14
  import type { OcxConfig, OcxProviderConfig, RateLimitRetryPolicy, TransientRetryPolicy } from "../types";
14
15
  import { OPENCODE_GO_SESSION_HEADER } from "./opencode-go-transport";
@@ -186,33 +187,32 @@ function rotateKeyAfterFailure(
186
187
  retryAfterHeader: string | null | undefined,
187
188
  now = Date.now(),
188
189
  attemptedKey?: string,
190
+ attemptedSelection?: ProviderApiKeySelection,
189
191
  ): OcxProviderConfig | null {
190
192
  const provider = config.providers[providerName];
191
193
  if (!provider) return null;
192
194
  if (provider.authMode === "oauth" || provider.authMode === "forward") return null;
193
195
 
194
- const failedKey = attemptedKey ?? provider.apiKey;
196
+ const failedKey = attemptedSelection?.reference ?? attemptedKey ?? provider.apiKey;
195
197
  type Rotation =
196
- | { provider: OcxProviderConfig; failedId?: string; candidateId?: string }
198
+ | { failedId?: string; candidateId?: string }
197
199
  | { exhaustedCount: number; failedId?: string };
198
- const outcome = mutatePersistedConfig<Rotation | null>(fresh => {
199
- const freshProvider = fresh.providers[providerName];
200
- if (!freshProvider || freshProvider.authMode === "oauth" || freshProvider.authMode === "forward") {
201
- return { changed: false, value: null };
202
- }
200
+ const outcome = commitProviderApiKeySelection<Rotation | null>(config, providerName, freshProvider => {
203
201
  const pool = freshProvider.apiKeyPool;
204
202
  if (!pool || pool.length < 2) return { changed: false, value: null };
205
203
 
206
204
  // The callback can be rerun after rebasing, so identify the failed key here but
207
205
  // defer the in-memory cooldown side effect until persistence has succeeded.
208
- const failedEntry = pool.find(entry => entry.key === failedKey);
206
+ const failedEntry = attemptedSelection?.entryId
207
+ ? pool.find(entry => entry.id === attemptedSelection.entryId && entry.key === failedKey)
208
+ : pool.find(entry => entry.key === failedKey);
209
209
 
210
210
  if (freshProvider.apiKey !== failedKey) {
211
211
  const activeEntry = pool.find(entry => entry.key === freshProvider.apiKey);
212
212
  if (activeEntry && !isKeyInCooldown(providerName, activeEntry.id, now)) {
213
213
  return {
214
214
  changed: false,
215
- value: { provider: structuredClone(freshProvider), failedId: failedEntry?.id },
215
+ value: { failedId: failedEntry?.id },
216
216
  };
217
217
  }
218
218
  }
@@ -226,15 +226,20 @@ function rotateKeyAfterFailure(
226
226
  return {
227
227
  changed: true,
228
228
  value: {
229
- provider: structuredClone(freshProvider),
230
229
  failedId: failedEntry?.id,
231
230
  candidateId: candidate.id,
232
231
  },
233
232
  };
234
233
  }
235
234
  return { changed: false, value: { exhaustedCount: pool.length, failedId: failedEntry?.id } };
236
- });
237
- if (outcome.status === "unavailable" || outcome.value === null) return null;
235
+ }, attemptedSelection);
236
+ if (outcome.status === "unavailable") return null;
237
+ if (outcome.status === "superseded") {
238
+ // A newer manual selection (including A→B→A) owns subsequent dispatch. Reusing the
239
+ // same failed key here would loop forever; preserve its original failure instead.
240
+ return outcome.provider.apiKey !== failedKey ? structuredClone(outcome.provider) : null;
241
+ }
242
+ if (outcome.value === null) return null;
238
243
  if (outcome.value.failedId) {
239
244
  // A 401 is a verdict about the credential itself, not a timing signal: the key is rejected
240
245
  // until an operator replaces it, and upstreams send no Retry-After for it. Hold it for the
@@ -250,7 +255,7 @@ function rotateKeyAfterFailure(
250
255
  return null;
251
256
  }
252
257
 
253
- const committed = structuredClone(outcome.value.provider);
258
+ const committed = structuredClone(outcome.provider);
254
259
  config.providers[providerName] = committed;
255
260
  if (outcome.value.candidateId) {
256
261
  console.warn(
@@ -267,8 +272,9 @@ export function rotateKeyOn429(
267
272
  retryAfterHeader: string | null | undefined,
268
273
  now = Date.now(),
269
274
  attemptedKey?: string,
275
+ attemptedSelection?: ProviderApiKeySelection,
270
276
  ): OcxProviderConfig | null {
271
- return rotateKeyAfterFailure(config, providerName, 429, retryAfterHeader, now, attemptedKey);
277
+ return rotateKeyAfterFailure(config, providerName, 429, retryAfterHeader, now, attemptedKey, attemptedSelection);
272
278
  }
273
279
 
274
280
  /**
@@ -284,8 +290,9 @@ export function rotateKeyOn401(
284
290
  providerName: string,
285
291
  now = Date.now(),
286
292
  attemptedKey?: string,
293
+ attemptedSelection?: ProviderApiKeySelection,
287
294
  ): OcxProviderConfig | null {
288
- return rotateKeyAfterFailure(config, providerName, 401, null, now, attemptedKey);
295
+ return rotateKeyAfterFailure(config, providerName, 401, null, now, attemptedKey, attemptedSelection);
289
296
  }
290
297
 
291
298
  export function sweepExpiredApiKeyCooldowns(now = Date.now()): number {
@@ -302,6 +309,7 @@ interface RotateProviderTransportOptions {
302
309
  retryAfter?: string | null;
303
310
  now?: number;
304
311
  attemptedKey?: string;
312
+ attemptedSelection?: ProviderApiKeySelection;
305
313
  promptCacheKey?: string;
306
314
  }
307
315
 
@@ -323,6 +331,7 @@ export function rotateProviderTransportOn429(
323
331
  options.retryAfter,
324
332
  options.now,
325
333
  options.attemptedKey,
334
+ options.attemptedSelection ?? routedProvider._apiKeyAttempt,
326
335
  );
327
336
  if (!rotated) return null;
328
337
  return applyRotatedTransport(providerName, routedProvider, rotated, options.promptCacheKey);
@@ -335,7 +344,8 @@ export function rotateProviderTransportOn401(
335
344
  routedProvider: OcxProviderTransport,
336
345
  options: Omit<RotateProviderTransportOptions, "retryAfter"> = {},
337
346
  ): OcxProviderTransport | null {
338
- const rotated = rotateKeyOn401(config, providerName, options.now, options.attemptedKey);
347
+ const rotated = rotateKeyOn401(config, providerName, options.now, options.attemptedKey,
348
+ options.attemptedSelection ?? routedProvider._apiKeyAttempt);
339
349
  if (!rotated) return null;
340
350
  return applyRotatedTransport(providerName, routedProvider, rotated, options.promptCacheKey);
341
351
  }
@@ -112,14 +112,16 @@ export function rewriteProviderReferences(config: OcxConfig, from: string, to: s
112
112
 
113
113
  // Keys. `providerContextCaps` is KEYED by provider id — a prefix rewrite would
114
114
  // silently orphan the cap — and a destination key may already be occupied.
115
- const caps = config.providerContextCaps;
116
- if (caps && Object.hasOwn(caps, from)) {
117
- if (Object.hasOwn(caps, to)) {
118
- collisions.push(`providerContextCaps.${to}`);
119
- } else {
120
- caps[to] = caps[from]!;
121
- delete caps[from];
122
- changed += 1;
115
+ for (const field of ["providerContextCaps", "providerContextCapValues"] as const) {
116
+ const caps = config[field];
117
+ if (caps && Object.hasOwn(caps, from)) {
118
+ if (Object.hasOwn(caps, to)) {
119
+ collisions.push(`${field}.${to}`);
120
+ } else {
121
+ caps[to] = caps[from]!;
122
+ delete caps[from];
123
+ changed += 1;
124
+ }
123
125
  }
124
126
  }
125
127
 
@@ -2175,6 +2175,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
2175
2175
  liveModels: true,
2176
2176
  preserveCustomDestination: true,
2177
2177
  defaultModel: "deepseek/deepseek-v4-flash",
2178
+ promptCacheKey: true,
2178
2179
  // The default is also the cold-start seed: live discovery failure must not empty the catalog
2179
2180
  // for a freshly configured provider with no stale cache (issue #308 pattern).
2180
2181
  models: ["deepseek/deepseek-v4-flash"],
@@ -287,6 +287,16 @@ export function restoreRoutedCustomCalls(
287
287
  const helper = aliased && sourceInput !== ""
288
288
  ? item.name
289
289
  : resolveCodeModeHelperName(undefined, targetName, sourceInput, itemNamespace, declaredNames);
290
+ // Native custom input is already the tool's raw grammar. Only a recognized
291
+ // helper/envelope may reinterpret it; a JSON-looking native body is not a wrapper.
292
+ if (item.type === "custom_tool_call" && !aliased && !helper) {
293
+ const input = repairNames.has(wireName) && typeof sourceInput === "string"
294
+ ? normalizeApplyPatchDelimiters(sourceInput)
295
+ : sourceInput;
296
+ return input !== sourceInput
297
+ ? { value: { ...item, input }, changed: true }
298
+ : { value: item, changed: false };
299
+ }
290
300
  const restored: Record<string, unknown> = {
291
301
  ...item,
292
302
  type: "custom_tool_call",