@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.
- package/README.md +25 -17
- package/gui/dist/assets/index-B7_K1Hsj.js +115 -0
- package/gui/dist/assets/index-ltx3L-WS.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/command-code.ts +23 -2
- package/src/adapters/cursor/native-exec.ts +63 -11
- package/src/adapters/cursor/tool-definitions.ts +1 -1
- package/src/adapters/cursor/tool-schemas.ts +59 -0
- package/src/adapters/kiro.ts +54 -8
- package/src/adapters/openai-chat.ts +23 -6
- package/src/adapters/openai-responses.ts +3 -1
- package/src/adapters/opencode-go.ts +35 -0
- package/src/claude/agents-inject.ts +12 -5
- package/src/claude/desktop-3p-library.ts +89 -0
- package/src/claude/desktop-3p.ts +166 -95
- package/src/claude/desktop-discovery-inputs.ts +44 -0
- package/src/claude/desktop-profile.ts +1 -1
- package/src/claude/desktop-remote-store-artifact.ts +185 -0
- package/src/claude/desktop-remote-store-io.ts +91 -0
- package/src/claude/desktop-remote-store-state.ts +127 -0
- package/src/claude/desktop-remote-store.ts +269 -0
- package/src/claude/inbound-model-options.ts +10 -2
- package/src/claude/inbound-records.ts +7 -0
- package/src/claude/inbound.ts +1 -1
- package/src/claude/model-info.ts +16 -3
- package/src/cli/account-api.ts +7 -1
- package/src/cli/aside-profiles.ts +17 -0
- package/src/cli/capabilities.ts +39 -0
- package/src/cli/claude-agent-startup-sync.ts +3 -1
- package/src/cli/claude-desktop.ts +127 -18
- package/src/cli/connect.ts +27 -14
- package/src/cli/dispatch.ts +30 -16
- package/src/cli/export-command.ts +3 -1
- package/src/cli/index.ts +28 -16
- package/src/cli/integrations.ts +46 -12
- package/src/cli/opencode.ts +11 -4
- package/src/cli/runtime-api.ts +7 -2
- package/src/cli/uninstall-client-state.ts +78 -0
- package/src/client/connect.ts +476 -223
- package/src/client/hub-client.ts +81 -0
- package/src/client/hub-relay.ts +38 -6
- package/src/client/lifecycle-lock.ts +129 -0
- package/src/client/state.ts +71 -42
- package/src/clients/aside-profiles.ts +224 -0
- package/src/codex/account-lifecycle.ts +5 -9
- package/src/codex/auth-api.ts +50 -4
- package/src/codex/catalog/metadata.ts +0 -2
- package/src/codex/catalog/provider-fetch.ts +7 -5
- package/src/codex/catalog/sync.ts +88 -24
- package/src/codex/convergence.ts +2 -0
- package/src/codex/inject.ts +45 -14
- package/src/codex/journal.ts +6 -6
- package/src/codex/quota-refresh-outcome.ts +27 -0
- package/src/codex/shim.ts +7 -1
- package/src/combos/resolve.ts +10 -7
- package/src/config.ts +18 -5
- package/src/generated/compatibility-version.json +209 -105
- package/src/images/loop.ts +5 -2
- package/src/integrations/aside-profile-context.ts +270 -0
- package/src/integrations/aside-profile-journal.ts +229 -0
- package/src/integrations/aside-profiles.ts +176 -0
- package/src/integrations/catalog-refresh.ts +40 -0
- package/src/integrations/config-io.ts +16 -1
- package/src/integrations/owned-refresh.ts +14 -2
- package/src/integrations/state.ts +3 -1
- package/src/integrations/writer.ts +3 -1
- package/src/lab/fabric/producer-isolate.ts +84 -46
- package/src/lib/account-selection-events.ts +32 -0
- package/src/lib/errors.ts +9 -1
- package/src/lib/provider-outbound.ts +2 -45
- package/src/lib/proxy-env.ts +67 -0
- package/src/oauth/account-quota-rank.ts +9 -1
- package/src/oauth/anthropic-routing.ts +91 -23
- package/src/oauth/generic-account-failover.ts +15 -53
- package/src/oauth/index.ts +67 -68
- package/src/oauth/pool-settings-capability.ts +2 -1
- package/src/oauth/store.ts +91 -7
- package/src/oauth/types.ts +8 -0
- package/src/providers/api-key-selection.ts +110 -0
- package/src/providers/api-keys.ts +38 -31
- package/src/providers/context-cap.ts +27 -3
- package/src/providers/key-failover.ts +27 -17
- package/src/providers/provider-id-rewrite.ts +10 -8
- package/src/providers/registry.ts +1 -0
- package/src/responses/custom-tool-compat.ts +10 -0
- package/src/responses/function-call-compat.ts +173 -0
- package/src/responses/namespace-tool-compat.ts +27 -4
- package/src/responses/parser.ts +8 -0
- package/src/responses/task-input.ts +36 -0
- package/src/responses/tool-name-aliases.ts +79 -0
- package/src/router.ts +2 -0
- package/src/routing/capability.ts +10 -3
- package/src/routing/compatibility/assemble.ts +16 -1
- package/src/routing/evaluator.ts +5 -1
- package/src/server/auth-cors.ts +3 -0
- package/src/server/chat-native.ts +44 -9
- package/src/server/claude-messages.ts +71 -36
- package/src/server/grok-responses-snapshot-repair.ts +333 -0
- package/src/server/index.ts +26 -10
- package/src/server/management/account-selection-stream.ts +70 -0
- package/src/server/management/aside-profile-routes.ts +231 -0
- package/src/server/management/config-routes.ts +36 -41
- package/src/server/management/context.ts +3 -0
- package/src/server/management/integration-routes.ts +61 -3
- package/src/server/management/logs-usage-routes.ts +1 -0
- package/src/server/management/model-routes.ts +20 -9
- package/src/server/management/model-rows.ts +10 -2
- package/src/server/management/oauth-account-routes.ts +7 -0
- package/src/server/management/provider-routes.ts +10 -8
- package/src/server/management/route-registry.ts +20 -2
- package/src/server/management-auth.ts +16 -0
- package/src/server/relay-eager.ts +17 -5
- package/src/server/relay.ts +43 -4
- package/src/server/request-log.ts +29 -1
- package/src/server/responses/agent-task-recovery-cache.ts +11 -0
- package/src/server/responses/agent-task-recovery.ts +24 -4
- package/src/server/responses/codex-ws-pool.ts +4 -4
- package/src/server/responses/codex-ws-session.ts +2 -2
- package/src/server/responses/collaboration.ts +2 -1
- package/src/server/responses/combo-stream-preflight.ts +26 -7
- package/src/server/responses/core.ts +668 -151
- package/src/server/responses/fetch-helpers.ts +6 -1
- package/src/server/responses/ws-upstream.ts +8 -3
- package/src/server/responses-custom-tool-repair.ts +29 -9
- package/src/server/responses-function-tool-repair.ts +183 -0
- package/src/server/responses-snapshot-codec.ts +14 -0
- package/src/server/responses-snapshot-repair.ts +1 -13
- package/src/server/responses-undeclared-tool-guard.ts +1 -68
- package/src/server/sse-payload-rewrite.ts +3 -1
- package/src/service.ts +6 -2
- package/src/types/config.ts +23 -16
- package/src/types/provider.ts +14 -3
- package/src/types/request.ts +2 -0
- package/src/usage/log.ts +9 -0
- package/src/web-search/loop.ts +5 -2
- package/gui/dist/assets/index-DS1NE4Jn.css +0 -1
- package/gui/dist/assets/index-Djowl68T.js +0 -112
package/src/oauth/store.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
883
|
-
set.activeAccountId
|
|
884
|
-
|
|
885
|
-
|
|
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> {
|
package/src/oauth/types.ts
CHANGED
|
@@ -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
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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)
|
|
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 {
|
|
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
|
-
| {
|
|
198
|
+
| { failedId?: string; candidateId?: string }
|
|
197
199
|
| { exhaustedCount: number; failedId?: string };
|
|
198
|
-
const outcome =
|
|
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 =
|
|
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: {
|
|
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"
|
|
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.
|
|
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
|
|
116
|
-
|
|
117
|
-
if (Object.hasOwn(caps,
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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",
|