@bitkyc08/opencodex 2.52.0 → 2.53.0-preview.20260913
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/gui/dist/assets/index-BBOZWGB6.css +1 -0
- package/gui/dist/assets/index-D7ynYo2K.js +128 -0
- package/gui/dist/index.html +2 -2
- package/native/remote-workspace-helper/Cargo.lock +130 -0
- package/native/remote-workspace-helper/Cargo.toml +24 -0
- package/native/remote-workspace-helper/src/main.rs +49 -0
- package/native/remote-workspace-helper/src/protocol.rs +246 -0
- package/native/remote-workspace-helper/src/sandbox/macos.rs +19 -0
- package/native/remote-workspace-helper/src/sandbox/mod.rs +77 -0
- package/native/remote-workspace-helper/src/sandbox/windows.rs +15 -0
- package/package.json +6 -1
- package/src/adapters/anthropic-image-normalize.ts +30 -2
- package/src/adapters/anthropic.ts +1 -1
- package/src/adapters/base.ts +8 -2
- package/src/adapters/cursor/cursor-errors.ts +12 -0
- package/src/adapters/cursor/thread-continuity.ts +93 -0
- package/src/adapters/cursor.ts +104 -73
- package/src/adapters/devin/cloud-direct/chat.ts +312 -23
- package/src/adapters/devin/cloud-direct/metadata.ts +31 -2
- package/src/adapters/devin/live-models.ts +70 -3
- package/src/adapters/devin.ts +281 -21
- package/src/adapters/google-wire-compiler.ts +14 -6
- package/src/adapters/google.ts +22 -8
- package/src/adapters/kiro/adapter.ts +316 -0
- package/src/adapters/kiro/conversation.ts +136 -0
- package/src/adapters/kiro/payload.ts +432 -0
- package/src/adapters/kiro/reasoning.ts +56 -0
- package/src/adapters/kiro/stream.ts +1153 -0
- package/src/adapters/kiro/usage.ts +223 -0
- package/src/adapters/kiro/wire.ts +76 -0
- package/src/adapters/kiro.ts +8 -2319
- package/src/adapters/mimo-free.ts +1 -1
- package/src/adapters/openai-chat-images.ts +101 -0
- package/src/adapters/openai-chat.ts +201 -181
- package/src/adapters/openai-responses.ts +92 -224
- package/src/adapters/registry.ts +0 -7
- package/src/adapters/run-turn-queue.ts +13 -6
- package/src/bridge.ts +14 -15
- package/src/chat/inbound.ts +29 -4
- package/src/chat/outbound.ts +145 -107
- package/src/claude/desktop-profile.ts +4 -6
- package/src/cli/account-api.ts +14 -0
- package/src/cli/account-extended.ts +1 -1
- package/src/cli/account-history.ts +60 -0
- package/src/cli/account-main.ts +80 -0
- package/src/cli/account.ts +11 -3
- package/src/cli/capabilities.ts +113 -0
- package/src/cli/catalog.ts +109 -0
- package/src/cli/dispatch.ts +9 -0
- package/src/cli/help.ts +2 -0
- package/src/cli/index.ts +2 -2
- package/src/cli/observe.ts +28 -1
- package/src/cli/opencode.ts +42 -8
- package/src/cli/provider-runtime.ts +11 -1
- package/src/cli/provider.ts +22 -2
- package/src/cli/registry.ts +21 -0
- package/src/cli/remote-workspace.ts +154 -0
- package/src/cli/status.ts +39 -7
- package/src/cli/usage-report.ts +14 -2
- package/src/client/hub-client.ts +34 -0
- package/src/client/hub-state.ts +9 -1
- package/src/codex/account-store.ts +78 -0
- package/src/codex/auth-api.ts +81 -54
- package/src/codex/auth-context.ts +45 -16
- package/src/codex/catalog/effort.ts +1 -1
- package/src/codex/catalog/metadata.ts +3 -6
- package/src/codex/catalog/native-models.ts +4 -4
- package/src/codex/catalog/parsing.ts +2 -20
- package/src/codex/catalog/provider-fetch.ts +10 -1
- package/src/codex/catalog/remote.ts +233 -0
- package/src/codex/catalog/sync.ts +403 -35
- package/src/codex/convergence.ts +1 -1
- package/src/codex/history-manifest.ts +36 -0
- package/src/codex/history-provider.ts +32 -5
- package/src/codex/inject.ts +9 -0
- package/src/codex/main-account.ts +113 -0
- package/src/codex/main-device-reauth-api.ts +89 -0
- package/src/codex/main-device-reauth.ts +217 -0
- package/src/codex/native-residue.ts +9 -2
- package/src/codex/quota-auto-refresh.ts +3 -2
- package/src/codex/quota-capacity.ts +98 -0
- package/src/codex/quota-history.ts +160 -0
- package/src/codex/quota-types.ts +8 -0
- package/src/codex/quota.ts +118 -91
- package/src/codex/refresh.ts +2 -1
- package/src/codex/routing.ts +90 -17
- package/src/codex/sync.ts +33 -4
- package/src/combos/request.ts +19 -1
- package/src/config/multi-agent-surface.ts +61 -0
- package/src/config/provider-validation.ts +176 -0
- package/src/config.ts +213 -11
- package/src/generated/compatibility-version.json +436 -168
- package/src/images/loop.ts +119 -36
- package/src/lib/admission.ts +12 -6
- package/src/lib/redact.ts +7 -0
- package/src/lib/translator-budget.ts +4 -3
- package/src/lib/windows-atomic-replace.ts +1 -0
- package/src/lib/windows-elevation.ts +1 -1
- package/src/oauth/chatgpt-device.ts +62 -5
- package/src/oauth/devin/cli-import.ts +130 -0
- package/src/oauth/devin.ts +63 -8
- package/src/oauth/index.ts +29 -14
- package/src/oauth/kiro.ts +18 -6
- package/src/oauth/login-cli.ts +9 -1
- package/src/oauth/meta-muse-device.ts +464 -0
- package/src/oauth/meta-muse.ts +123 -32
- package/src/oauth/pool-kernel.ts +9 -0
- package/src/oauth/pool-settings-capability.ts +2 -2
- package/src/oauth/store.ts +57 -0
- package/src/oauth/types.ts +31 -0
- package/src/providers/derive.ts +13 -3
- package/src/providers/devin-cli-authmode-migration.ts +57 -35
- package/src/providers/devin-provider-merge-migration.ts +240 -0
- package/src/providers/muse-key-quota.ts +117 -0
- package/src/providers/muse-subscription-usage.ts +14 -2
- package/src/providers/openai-sidecar.ts +25 -3
- package/src/providers/opencode-zen-rate-limit.ts +58 -0
- package/src/providers/provider-id-rewrite.ts +20 -5
- package/src/providers/quota-types.ts +12 -0
- package/src/providers/quota.ts +143 -102
- package/src/providers/reasoning-metadata.ts +543 -0
- package/src/providers/registry.ts +80 -49
- package/src/reasoning-effort.ts +26 -2
- package/src/remote/hub-usage.ts +32 -0
- package/src/remote-control/index.ts +192 -41
- package/src/remote-control/workspace-activation.ts +9 -0
- package/src/remote-control/workspace-agent-connection.ts +366 -0
- package/src/remote-control/workspace-claude-runtime.ts +243 -0
- package/src/remote-control/workspace-codex-runtime.ts +531 -0
- package/src/remote-control/workspace-codex-sandbox.ts +115 -0
- package/src/remote-control/workspace-command-runner.ts +748 -0
- package/src/remote-control/workspace-coordinator.ts +231 -0
- package/src/remote-control/workspace-device.ts +585 -0
- package/src/remote-control/workspace-executable.ts +43 -0
- package/src/remote-control/workspace-executor.ts +397 -0
- package/src/remote-control/workspace-hub.ts +519 -0
- package/src/remote-control/workspace-pi-runtime.ts +382 -0
- package/src/remote-control/workspace-process.ts +129 -0
- package/src/remote-control/workspace-rpc.ts +304 -0
- package/src/remote-control/workspace-runtime.ts +60 -0
- package/src/remote-control/workspace-secret-store.ts +39 -0
- package/src/remote-control/workspace-sessions.ts +799 -0
- package/src/remote-control/workspace-tool-bridge.ts +192 -0
- package/src/responses/code-mode-helper-compat.ts +22 -3
- package/src/responses/hosted-tool-policy.ts +0 -1
- package/src/responses/muse-tool-name-alias.ts +379 -0
- package/src/responses/plaintext-v2-agent-messages.ts +902 -0
- package/src/router.ts +7 -0
- package/src/routing/compatibility/behavior.ts +0 -1
- package/src/server/audio-client.ts +64 -0
- package/src/server/audio-dictation.ts +91 -0
- package/src/server/audio-live.ts +185 -0
- package/src/server/audio-transcriptions.ts +183 -0
- package/src/server/audio-upstream.ts +153 -0
- package/src/server/auth-cors.ts +61 -2
- package/src/server/chat-completions.ts +1 -1
- package/src/server/chat-native-sse.ts +92 -48
- package/src/server/chat-native.ts +37 -15
- package/src/server/hub-usage.ts +57 -0
- package/src/server/images.ts +4 -0
- package/src/server/index.ts +722 -57
- package/src/server/lifecycle.ts +5 -6
- package/src/server/live-call-bindings.ts +60 -0
- package/src/server/live.ts +12 -1
- package/src/server/management/agent-settings-routes.ts +25 -4
- package/src/server/management/api-access.ts +37 -0
- package/src/server/management/api-key-usage.ts +7 -2
- package/src/server/management/config-routes.ts +1 -18
- package/src/server/management/context.ts +15 -0
- package/src/server/management/logs-usage-routes.ts +2 -0
- package/src/server/management/oauth-account-routes.ts +39 -12
- package/src/server/management/provider-routes.ts +125 -2
- package/src/server/management/remote-workspace-routes.ts +140 -0
- package/src/server/management/route-registry.ts +15 -0
- package/src/server/management/usage-aggregate-cache.ts +14 -15
- package/src/server/management/usage-summary-cache.ts +2 -0
- package/src/server/management-api.ts +23 -0
- package/src/server/ports.ts +17 -0
- package/src/server/relay-eager.ts +4 -1
- package/src/server/relay.ts +70 -10
- package/src/server/request-decompress.ts +6 -3
- package/src/server/responses/agent-task-recovery.ts +25 -32
- package/src/server/responses/codex-auth-error.ts +11 -0
- package/src/server/responses/codex-ws-exchange.ts +52 -3
- package/src/server/responses/codex-ws-wire.ts +55 -0
- package/src/server/responses/compact.ts +9 -1
- package/src/server/responses/core.ts +337 -73
- package/src/server/responses/encrypted-payload.ts +45 -2
- package/src/server/responses/ws-upstream.ts +4 -1
- package/src/server/responses-self-named-namespace-scrub.ts +1 -3
- package/src/server/responses-undeclared-tool-guard.ts +1 -1
- package/src/server/search.ts +3 -0
- package/src/server/sse-payload-rewrite.ts +136 -51
- package/src/server/ws-bridge.ts +35 -1
- package/src/service/cli.ts +372 -0
- package/src/service/diagnostics.ts +340 -0
- package/src/service/guards.ts +303 -0
- package/src/service/health.ts +222 -0
- package/src/service/launchd.ts +853 -0
- package/src/service/orchestration.ts +617 -0
- package/src/service/repair.ts +334 -0
- package/src/service/state.ts +363 -0
- package/src/service/systemd.ts +229 -0
- package/src/service/windows-ops.ts +690 -0
- package/src/service/windows-scheduler.ts +769 -0
- package/src/service/windows-taskxml.ts +613 -0
- package/src/service.ts +22 -5550
- package/src/storage/cleanup/db.ts +258 -0
- package/src/storage/cleanup/execute.ts +358 -0
- package/src/storage/cleanup/paths.ts +189 -0
- package/src/storage/cleanup/pending.ts +140 -0
- package/src/storage/cleanup/preview.ts +292 -0
- package/src/storage/cleanup/reconcile.ts +347 -0
- package/src/storage/cleanup/restore.ts +932 -0
- package/src/storage/cleanup/satellite.ts +474 -0
- package/src/storage/cleanup/staging.ts +129 -0
- package/src/storage/cleanup/types.ts +98 -0
- package/src/storage/cleanup.ts +49 -3127
- package/src/types/accounts.ts +2 -0
- package/src/types/config.ts +13 -12
- package/src/types/provider.ts +37 -0
- package/src/types/request.ts +2 -0
- package/src/types/tools.ts +17 -5
- package/src/types.ts +1 -0
- package/src/usage/expected-prices.ts +127 -0
- package/src/usage/log.ts +58 -1
- package/src/vision/eligibility.ts +13 -2
- package/src/web-search/loop.ts +56 -3
- package/gui/dist/assets/index-CWXut3rG.js +0 -115
- package/gui/dist/assets/index-EdoPnm9_.css +0 -1
- package/src/adapters/devin-cli/acp.ts +0 -204
- package/src/adapters/devin-cli/adapter.ts +0 -345
- package/src/adapters/devin-cli/binary.ts +0 -69
- package/src/adapters/devin-cli/models.ts +0 -57
- package/src/oauth/devin-cli.ts +0 -149
- package/src/server/responses-reasoning-summary-rewrite.ts +0 -178
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merge the retired `devin-cli` provider id into `devin` at startup
|
|
3
|
+
* (devlog/_plan/260913_devin_provider_merge).
|
|
4
|
+
*
|
|
5
|
+
* The two registry entries were the same adapter, api-server, and credential
|
|
6
|
+
* shape — only the login path differed — so `devin-cli` was removed from the
|
|
7
|
+
* registry and survives as a deprecated alias. What remains is the saved
|
|
8
|
+
* state written under the old id: a `providers["devin-cli"]` config row,
|
|
9
|
+
* cross-config references, and the auth.json credential slot. All three move
|
|
10
|
+
* here because an alias can resolve lookups but cannot un-dangle a reference
|
|
11
|
+
* that validation requires to be a configured provider.
|
|
12
|
+
*
|
|
13
|
+
* Posture is Alibaba/OpenAI-tier fail-closed, not model-rename: this writes
|
|
14
|
+
* auth.json, so both files get an immutable copy-verify-link snapshot before
|
|
15
|
+
* any write, and a backup failure throws out of `startServer` rather than
|
|
16
|
+
* rewriting credentials with no rollback point. A rekey that fails AFTER the
|
|
17
|
+
* backup degrades to a warning instead — the old slot is still valid via the
|
|
18
|
+
* alias and the next boot retries.
|
|
19
|
+
*
|
|
20
|
+
* Collision posture matches the config half: a `devin` row or slot that
|
|
21
|
+
* already exists means two potentially different accounts, and choosing a
|
|
22
|
+
* survivor is a user decision, so the migration refuses and warns.
|
|
23
|
+
*/
|
|
24
|
+
import { copyFileSync, existsSync, linkSync, readFileSync, rmSync } from "node:fs";
|
|
25
|
+
import { getConfigPath, saveConfig } from "../config";
|
|
26
|
+
import { codexAccountNamespaceProviderCollisionError } from "../codex/account-namespace-match";
|
|
27
|
+
import { DEVIN_DEFAULT_API_SERVER } from "../oauth/devin/api-base";
|
|
28
|
+
import {
|
|
29
|
+
getAuthStorePath,
|
|
30
|
+
peekAuthStore,
|
|
31
|
+
rekeyProviderCredentials,
|
|
32
|
+
type ProviderCredentialRekeyOutcome,
|
|
33
|
+
} from "../oauth/store";
|
|
34
|
+
import { isRetiredDevinAcpIdentityUrl } from "./devin-cli-authmode-migration";
|
|
35
|
+
import { rewriteProviderReferences } from "./provider-id-rewrite";
|
|
36
|
+
import type { OcxConfig } from "../types";
|
|
37
|
+
|
|
38
|
+
const FROM_ID = "devin-cli";
|
|
39
|
+
const TO_ID = "devin";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* One snapshot suffix covers both files: the migration is not repeatable per
|
|
43
|
+
* file, and two suffixes would only say which file happened to snapshot first.
|
|
44
|
+
*/
|
|
45
|
+
const BACKUP_SUFFIX = ".pre-devin-provider-merge-v1.bak";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Immutable pre-migration snapshot, published by copy-verify-link.
|
|
49
|
+
*
|
|
50
|
+
* Same construction as alibaba-region-backup.ts, kept as a local copy because
|
|
51
|
+
* that module's IO seam and error type are Alibaba-specific. The reasoning
|
|
52
|
+
* carries over unchanged: a bare exclusive create can publish a truncated
|
|
53
|
+
* file after a mid-copy crash, and an existing snapshot is reused rather than
|
|
54
|
+
* compared — the earliest snapshot is the best rollback point, and demanding
|
|
55
|
+
* equality would brick an install whose config legitimately changed after an
|
|
56
|
+
* aborted run.
|
|
57
|
+
*/
|
|
58
|
+
function snapshotOnce(path: string): "absent" | "created" | "reused" {
|
|
59
|
+
if (!existsSync(path)) return "absent";
|
|
60
|
+
const backup = `${path}${BACKUP_SUFFIX}`;
|
|
61
|
+
if (existsSync(backup)) return "reused";
|
|
62
|
+
const source = readFileSync(path);
|
|
63
|
+
const temp = `${backup}.${process.pid}.tmp`;
|
|
64
|
+
try {
|
|
65
|
+
copyFileSync(path, temp);
|
|
66
|
+
if (!readFileSync(temp).equals(source)) {
|
|
67
|
+
throw new Error(`failed to write a complete backup to ${temp}`);
|
|
68
|
+
}
|
|
69
|
+
linkSync(temp, backup);
|
|
70
|
+
return "created";
|
|
71
|
+
} catch (error) {
|
|
72
|
+
// A concurrent process won the publish race with the same verified copy.
|
|
73
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") return "reused";
|
|
74
|
+
throw error;
|
|
75
|
+
} finally {
|
|
76
|
+
rmSync(temp, { force: true });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function backupConfigBeforeDevinProviderMerge(configPath = getConfigPath()): "absent" | "created" | "reused" {
|
|
81
|
+
return snapshotOnce(configPath);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function backupAuthBeforeDevinProviderMerge(authPath = getAuthStorePath()): "absent" | "created" | "reused" {
|
|
85
|
+
return snapshotOnce(authPath);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface DevinProviderMergeProjection {
|
|
89
|
+
config: OcxConfig;
|
|
90
|
+
changed: boolean;
|
|
91
|
+
warnings: string[];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Move `providers["devin-cli"]` to `providers["devin"]` and re-point every
|
|
96
|
+
* cross-config reference. Pure projection: the input is not mutated and the
|
|
97
|
+
* caller decides whether to persist the result.
|
|
98
|
+
*
|
|
99
|
+
* The moved row is normalized to the canonical entry's contract in the same
|
|
100
|
+
* pass — `authMode: "oauth"` and `adapter: "devin"` are the only values the
|
|
101
|
+
* `devin` registry entry admits, and a `cli.devin.ai` baseUrl was the retired
|
|
102
|
+
* ACP transport's identity placeholder, never a destination. This absorbs
|
|
103
|
+
* what `projectDevinCliAuthMode` used to do for the registry-id row; that
|
|
104
|
+
* projection keeps only the custom-named-row adapter repair.
|
|
105
|
+
*/
|
|
106
|
+
export function projectDevinProviderMerge(config: OcxConfig): DevinProviderMergeProjection {
|
|
107
|
+
const source = config.providers?.[FROM_ID];
|
|
108
|
+
if (!source) return { config, changed: false, warnings: [] };
|
|
109
|
+
|
|
110
|
+
if (codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, TO_ID)) {
|
|
111
|
+
return {
|
|
112
|
+
config,
|
|
113
|
+
changed: false,
|
|
114
|
+
warnings: [
|
|
115
|
+
`provider "${FROM_ID}" needs to move to "${TO_ID}", but that destination is reserved `
|
|
116
|
+
+ "by a configured Codex account namespace. Nothing was changed. Rename the account "
|
|
117
|
+
+ "selector or move the provider manually, then restart.",
|
|
118
|
+
],
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
if (config.providers[TO_ID]) {
|
|
122
|
+
return {
|
|
123
|
+
config,
|
|
124
|
+
changed: false,
|
|
125
|
+
warnings: [
|
|
126
|
+
`provider "${FROM_ID}" is merged into "${TO_ID}", but "${TO_ID}" already exists. Both `
|
|
127
|
+
+ "were left untouched: merging two provider rows can merge two different accounts, "
|
|
128
|
+
+ "which is not a decision this migration can make. Move any devin-cli-only settings "
|
|
129
|
+
+ `onto "${TO_ID}" and delete the unused entry.`,
|
|
130
|
+
],
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const projected = structuredClone(config);
|
|
135
|
+
const moved = projected.providers[FROM_ID]!;
|
|
136
|
+
delete projected.providers[FROM_ID];
|
|
137
|
+
moved.adapter = "devin";
|
|
138
|
+
moved.authMode = "oauth";
|
|
139
|
+
if (isRetiredDevinAcpIdentityUrl(moved.baseUrl)) {
|
|
140
|
+
moved.baseUrl = DEVIN_DEFAULT_API_SERVER;
|
|
141
|
+
}
|
|
142
|
+
projected.providers[TO_ID] = moved;
|
|
143
|
+
|
|
144
|
+
const { changed: rewritten, collisions } = rewriteProviderReferences(projected, FROM_ID, TO_ID);
|
|
145
|
+
// The rewriter is not transactional, so the clone is already partly
|
|
146
|
+
// rewritten here. Returning the ORIGINAL config discards it.
|
|
147
|
+
if (collisions.length > 0) {
|
|
148
|
+
return {
|
|
149
|
+
config,
|
|
150
|
+
changed: false,
|
|
151
|
+
warnings: [
|
|
152
|
+
`provider "${FROM_ID}" needs to move to "${TO_ID}", but ${collisions.join(", ")} `
|
|
153
|
+
+ "already hold values for the destination. Nothing was changed: choosing which value "
|
|
154
|
+
+ "survives is not a decision this migration can make. Resolve those entries and restart.",
|
|
155
|
+
],
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
config: projected,
|
|
161
|
+
changed: true,
|
|
162
|
+
warnings: [
|
|
163
|
+
`moved provider "${FROM_ID}" to "${TO_ID}": the two registry entries were merged and `
|
|
164
|
+
+ `"${FROM_ID}" is now a deprecated alias. ${rewritten} reference(s) were re-pointed.`,
|
|
165
|
+
],
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface DevinProviderMergeStartupDeps {
|
|
170
|
+
project: typeof projectDevinProviderMerge;
|
|
171
|
+
/** config.json snapshot; runs only when the projection changed something. */
|
|
172
|
+
backupConfig: () => void;
|
|
173
|
+
/** auth.json snapshot; runs only when a `devin-cli` credential slot exists. */
|
|
174
|
+
backupAuth: () => void;
|
|
175
|
+
save: (config: OcxConfig) => void;
|
|
176
|
+
/** Sync peek so the auth backup is not taken for a slot that is not there. */
|
|
177
|
+
hasAuthSlot: (provider: string) => boolean;
|
|
178
|
+
rekey: (from: string, to: string) => Promise<ProviderCredentialRekeyOutcome>;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const DEFAULT_DEPS: DevinProviderMergeStartupDeps = {
|
|
182
|
+
project: projectDevinProviderMerge,
|
|
183
|
+
backupConfig: () => { backupConfigBeforeDevinProviderMerge(); },
|
|
184
|
+
backupAuth: () => { backupAuthBeforeDevinProviderMerge(); },
|
|
185
|
+
save: saveConfig,
|
|
186
|
+
hasAuthSlot: provider => peekAuthStore()[provider] !== undefined,
|
|
187
|
+
rekey: rekeyProviderCredentials,
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Run the merge at startup, before `reconcileOAuthProviders` in the
|
|
192
|
+
* `startServer` chain.
|
|
193
|
+
*
|
|
194
|
+
* The config half is synchronous and fail-closed like the Alibaba migration:
|
|
195
|
+
* the snapshot is taken strictly before the save, and a backup failure throws
|
|
196
|
+
* rather than writing without a rollback point.
|
|
197
|
+
*
|
|
198
|
+
* The auth half is deliberately detached. `startServer` is synchronous — an
|
|
199
|
+
* `await` in the boot window would suspend the composition root — and
|
|
200
|
+
* `mutateStore` is async-only, so the rekey is fired after its snapshot and
|
|
201
|
+
* its outcome is logged when it lands. That is safe here: the credential is
|
|
202
|
+
* valid under either slot name while the `devin-cli` alias exists, a conflict
|
|
203
|
+
* refuses by design, and a failed rekey simply retries on the next boot.
|
|
204
|
+
*/
|
|
205
|
+
export function runDevinProviderMergeStartupMigration(
|
|
206
|
+
config: OcxConfig,
|
|
207
|
+
deps: DevinProviderMergeStartupDeps = DEFAULT_DEPS,
|
|
208
|
+
): OcxConfig {
|
|
209
|
+
const projection = deps.project(config);
|
|
210
|
+
// Warnings are emitted even on a no-op: the collision case IS the warning.
|
|
211
|
+
for (const warning of projection.warnings) console.warn(`[devin-provider-merge] ${warning}`);
|
|
212
|
+
let result = config;
|
|
213
|
+
if (projection.changed) {
|
|
214
|
+
deps.backupConfig();
|
|
215
|
+
deps.save(projection.config);
|
|
216
|
+
result = projection.config;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// The auth rekey runs even when the config half refused or had nothing to
|
|
220
|
+
// do: a `devin-cli` credential slot is orphaned state regardless of whether
|
|
221
|
+
// a provider row still points at it, and the conflict check inside the
|
|
222
|
+
// rekey is the same refuse-on-occupied rule the config half applies.
|
|
223
|
+
if (!deps.hasAuthSlot(FROM_ID)) return result;
|
|
224
|
+
deps.backupAuth();
|
|
225
|
+
void deps.rekey(FROM_ID, TO_ID).then(outcome => {
|
|
226
|
+
if (outcome === "conflict") {
|
|
227
|
+
console.warn(
|
|
228
|
+
`[devin-provider-merge] auth.json already has a "${TO_ID}" credential slot; the `
|
|
229
|
+
+ `"${FROM_ID}" slot was left in place. Merging two credential sets is not a decision `
|
|
230
|
+
+ "this migration can make — remove the unused slot manually.",
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
}).catch((error: unknown) => {
|
|
234
|
+
console.warn(
|
|
235
|
+
`[devin-provider-merge] credential rekey failed and will retry on the next start: `
|
|
236
|
+
+ `${error instanceof Error ? error.message : String(error)}`,
|
|
237
|
+
);
|
|
238
|
+
});
|
|
239
|
+
return result;
|
|
240
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-demand Muse Code quota, read from the subscription key endpoint.
|
|
3
|
+
*
|
|
4
|
+
* Until now this provider's quota could only be OBSERVED: Meta publishes no quota
|
|
5
|
+
* endpoint (17 REST paths probed, all 404 — devlog/_fin/260903_muse_spark_plan_oauth/003
|
|
6
|
+
* E), so the only measurement arrived mid-stream and a dashboard load could not refresh
|
|
7
|
+
* it without spending an inference turn (src/providers/quota.ts:1606-1613).
|
|
8
|
+
*
|
|
9
|
+
* A device-logged-in account changes that, because it holds the Meta ACCOUNT token and the
|
|
10
|
+
* key-mint response carries `subs_usage` (001 B-C). That makes this an auth-plane read,
|
|
11
|
+
* not an inference call.
|
|
12
|
+
*
|
|
13
|
+
* Three properties matter:
|
|
14
|
+
*
|
|
15
|
+
* 1. NEVER THROWS. A quota probe runs behind a dashboard poll. A failure must cost a row,
|
|
16
|
+
* not a page.
|
|
17
|
+
* 2. BACKS OFF. The mint endpoint is rate-limited, and this is the first quota source in
|
|
18
|
+
* this repository with a real failure backoff — quota.ts:2171 negative-caches by TTL,
|
|
19
|
+
* which is not the same thing.
|
|
20
|
+
* 3. DISCARDS THE KEY. The response contains `api_key`. It is never read here, never
|
|
21
|
+
* logged, and never returned.
|
|
22
|
+
*/
|
|
23
|
+
import { mintMuseApiKey } from "../oauth/meta-muse-device";
|
|
24
|
+
import { museUsageWindowsToQuota } from "./muse-subscription-usage";
|
|
25
|
+
import type { ProviderQuota } from "./quota-types";
|
|
26
|
+
|
|
27
|
+
/** Matches the reference implementation's own bound for the same endpoint. */
|
|
28
|
+
const FAILURE_BACKOFF_MS = 5 * 60_000;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* [audit fold] Minimum spacing between two SUCCESSFUL mints for one account.
|
|
32
|
+
*
|
|
33
|
+
* A failure backoff alone is not enough. Two callers bypass the ordinary quota cache:
|
|
34
|
+
* GET /api/provider-quotas?refresh=1 (src/server/management/provider-routes.ts:747-748)
|
|
35
|
+
* and the reset poller, which forces every tick (src/quota/reset-poller.ts:83). Without
|
|
36
|
+
* this, a user holding down a refresh button would drive one key-mint per click.
|
|
37
|
+
*
|
|
38
|
+
* This TTL is deliberately NOT conditioned on forceRefresh: a forced refresh may skip a
|
|
39
|
+
* display cache, but it may not spend another mint. When the TTL holds, the probe returns
|
|
40
|
+
* null and the caller serves the row this probe already wrote to the account cache.
|
|
41
|
+
*/
|
|
42
|
+
const SUCCESS_TTL_MS = 5 * 60_000;
|
|
43
|
+
|
|
44
|
+
export interface MuseKeyQuotaDeps {
|
|
45
|
+
fetchImpl?: typeof fetch;
|
|
46
|
+
now?: () => number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Keyed by account id: one account's rate limit must not silence another's. */
|
|
50
|
+
const backoffUntil = new Map<string, number>();
|
|
51
|
+
const lastSuccessAt = new Map<string, number>();
|
|
52
|
+
/**
|
|
53
|
+
* One in-flight probe per account.
|
|
54
|
+
*
|
|
55
|
+
* The TTL check alone is not atomic: `?refresh=1` and the reset poller can both pass it
|
|
56
|
+
* before either writes `lastSuccessAt`, which would spend two mints inside one window.
|
|
57
|
+
* Concurrent callers share the first request instead of racing it.
|
|
58
|
+
*/
|
|
59
|
+
const inFlight = new Map<string, Promise<ProviderQuota | null>>();
|
|
60
|
+
|
|
61
|
+
/** Test seam only. */
|
|
62
|
+
export function resetMuseKeyQuotaBackoff(): void {
|
|
63
|
+
backoffUntil.clear();
|
|
64
|
+
lastSuccessAt.clear();
|
|
65
|
+
inFlight.clear();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function museKeyQuotaBackoffRemainingMs(accountId: string, now = Date.now()): number {
|
|
69
|
+
return Math.max(0, (backoffUntil.get(accountId) ?? 0) - now);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function fetchMuseKeyQuotaSnapshot(
|
|
73
|
+
accountId: string,
|
|
74
|
+
oauthAccessToken: string,
|
|
75
|
+
deps: MuseKeyQuotaDeps = {},
|
|
76
|
+
signal?: AbortSignal,
|
|
77
|
+
): Promise<ProviderQuota | null> {
|
|
78
|
+
const now = deps.now ?? Date.now;
|
|
79
|
+
const at = now();
|
|
80
|
+
if (museKeyQuotaBackoffRemainingMs(accountId, at) > 0) return null;
|
|
81
|
+
// Success spacing, enforced even for a forced refresh. See SUCCESS_TTL_MS.
|
|
82
|
+
const last = lastSuccessAt.get(accountId);
|
|
83
|
+
if (last !== undefined && at - last < SUCCESS_TTL_MS) return null;
|
|
84
|
+
const running = inFlight.get(accountId);
|
|
85
|
+
if (running) return await running;
|
|
86
|
+
const attempt = probe(accountId, oauthAccessToken, deps, signal);
|
|
87
|
+
inFlight.set(accountId, attempt);
|
|
88
|
+
try {
|
|
89
|
+
return await attempt;
|
|
90
|
+
} finally {
|
|
91
|
+
inFlight.delete(accountId);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function probe(
|
|
96
|
+
accountId: string,
|
|
97
|
+
oauthAccessToken: string,
|
|
98
|
+
deps: MuseKeyQuotaDeps,
|
|
99
|
+
signal?: AbortSignal,
|
|
100
|
+
): Promise<ProviderQuota | null> {
|
|
101
|
+
const now = deps.now ?? Date.now;
|
|
102
|
+
try {
|
|
103
|
+
// No `onboard`: this is a read, not a login. Onboarding on a poll would be a
|
|
104
|
+
// side effect on the user's account.
|
|
105
|
+
const payload = await mintMuseApiKey(oauthAccessToken, {}, deps, signal);
|
|
106
|
+
backoffUntil.delete(accountId);
|
|
107
|
+
lastSuccessAt.set(accountId, now());
|
|
108
|
+
if (payload.isSubsActive === false) return null;
|
|
109
|
+
return museUsageWindowsToQuota(payload.subsUsage);
|
|
110
|
+
} catch {
|
|
111
|
+
// Every failure backs off, including 401/403. An expired account token cannot be
|
|
112
|
+
// refreshed (001 A), so retrying it on the next poll is pure noise; the next real
|
|
113
|
+
// request surfaces the auth problem through the existing reauth path.
|
|
114
|
+
backoffUntil.set(accountId, now() + FAILURE_BACKOFF_MS);
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -45,8 +45,8 @@ export function isMuseSubscriptionUsagePayload(payload: unknown): boolean {
|
|
|
45
45
|
* `subscription.tier` is deliberately dropped. It is an opaque numeric id, not the plan
|
|
46
46
|
* label the Muse CLI prints, so surfacing it would show a meaningless number.
|
|
47
47
|
*/
|
|
48
|
-
export function
|
|
49
|
-
const subscription = asRecord(
|
|
48
|
+
export function museUsageWindowsToQuota(usage: unknown): ProviderQuota | null {
|
|
49
|
+
const subscription = asRecord(usage);
|
|
50
50
|
if (!subscription) return null;
|
|
51
51
|
|
|
52
52
|
const quota: ProviderQuota = { updatedAt: Date.now() };
|
|
@@ -93,3 +93,15 @@ export function parseMuseSubscriptionUsage(payload: unknown): ProviderQuota | nu
|
|
|
93
93
|
// would render as a quota with no bars.
|
|
94
94
|
return sawWindow ? quota : null;
|
|
95
95
|
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The streaming-frame entry point.
|
|
99
|
+
*
|
|
100
|
+
* Kept as a thin adapter over the shared mapper: the frame nests the same two windows
|
|
101
|
+
* under `subscription`, the muse-code/key response nests them under `subs_usage`
|
|
102
|
+
* (devlog/_plan/260912_muse_device_oauth/001 C). Two parsers would drift, and the
|
|
103
|
+
* five-hour-window discrimination above is the part that must not.
|
|
104
|
+
*/
|
|
105
|
+
export function parseMuseSubscriptionUsage(payload: unknown): ProviderQuota | null {
|
|
106
|
+
return museUsageWindowsToQuota(asRecord(payload)?.subscription);
|
|
107
|
+
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
hasCallerCodexBearer,
|
|
6
6
|
isCodexAuthContextUsable,
|
|
7
7
|
resolveCodexAuthContext,
|
|
8
|
+
releaseCodexAuthContextProbeLease,
|
|
8
9
|
type CodexAccountSelectionAdmission,
|
|
9
10
|
type CodexAuthContext,
|
|
10
11
|
type CodexAuthPolicyConfig,
|
|
@@ -129,6 +130,7 @@ export async function resolveFirstUsableOpenAiSidecar(
|
|
|
129
130
|
admission?: Pick<DataPlaneAdmission, "source">;
|
|
130
131
|
codexAuthPolicy?: CodexAuthPolicyConfig;
|
|
131
132
|
beginCodexAccountSelection?: () => CodexAccountSelectionAdmission | undefined;
|
|
133
|
+
signal?: AbortSignal;
|
|
132
134
|
} = {},
|
|
133
135
|
): Promise<ResolvedOpenAiForwardSidecar | undefined> {
|
|
134
136
|
const { exactAccount } = options;
|
|
@@ -151,12 +153,21 @@ export async function resolveFirstUsableOpenAiSidecar(
|
|
|
151
153
|
modelId: exactAccount.modelId,
|
|
152
154
|
admission: options.admission,
|
|
153
155
|
beginCodexAccountSelection: options.beginCodexAccountSelection,
|
|
156
|
+
signal: options.signal,
|
|
154
157
|
});
|
|
155
|
-
|
|
158
|
+
let selectedHeaders: Headers;
|
|
159
|
+
try {
|
|
160
|
+
options.signal?.throwIfAborted();
|
|
161
|
+
selectedHeaders = headersForCodexAuthContext(incomingHeaders, authContext, policy, exactAccount.modelId, options.admission);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
releaseCodexAuthContextProbeLease(authContext);
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
156
166
|
if ((authContext.kind !== "pool" && authContext.kind !== "main-pool")
|
|
157
167
|
|| !isCodexAuthContextUsable(authContext, config)) {
|
|
158
168
|
// Exact selection is fail-closed. A generation/runtime-state race must not fall through
|
|
159
169
|
// to the caller-bearer error or let a later candidate select another account.
|
|
170
|
+
releaseCodexAuthContextProbeLease(authContext);
|
|
160
171
|
throw new CodexPoolAuthenticationError("Selected Codex account is unavailable");
|
|
161
172
|
}
|
|
162
173
|
return {
|
|
@@ -194,9 +205,20 @@ export async function resolveFirstUsableOpenAiSidecar(
|
|
|
194
205
|
codexAuthPolicy: policy,
|
|
195
206
|
admission: options.admission,
|
|
196
207
|
beginCodexAccountSelection: options.beginCodexAccountSelection,
|
|
208
|
+
signal: options.signal,
|
|
197
209
|
});
|
|
198
|
-
|
|
199
|
-
|
|
210
|
+
let selectedHeaders: Headers;
|
|
211
|
+
try {
|
|
212
|
+
options.signal?.throwIfAborted();
|
|
213
|
+
selectedHeaders = headersForCodexAuthContext(incomingHeaders, authContext, policy, undefined, options.admission);
|
|
214
|
+
} catch (error) {
|
|
215
|
+
releaseCodexAuthContextProbeLease(authContext);
|
|
216
|
+
throw error;
|
|
217
|
+
}
|
|
218
|
+
if (!isCodexAuthContextUsable(authContext, config)) {
|
|
219
|
+
releaseCodexAuthContextProbeLease(authContext);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
200
222
|
return {
|
|
201
223
|
...candidate,
|
|
202
224
|
authContext,
|
|
@@ -175,3 +175,61 @@ export function enrichOpenCodeZenUpstreamMessage(
|
|
|
175
175
|
): string {
|
|
176
176
|
return enrichOpenCodeZenFreeTierMessage(enrichOpenCodeZenRateLimitMessage(message, opts), opts);
|
|
177
177
|
}
|
|
178
|
+
|
|
179
|
+
/** The effective HTTP endpoint, never the configured row name, identifies Console. */
|
|
180
|
+
export function isConsoleGoDestination(outboundUrl: string | undefined): boolean {
|
|
181
|
+
if (!outboundUrl) return false;
|
|
182
|
+
try {
|
|
183
|
+
const url = new URL(outboundUrl);
|
|
184
|
+
return url.protocol === "https:" && url.hostname === "opencode.ai"
|
|
185
|
+
&& url.port === "" && url.username === "" && url.password === ""
|
|
186
|
+
&& url.search === "" && url.hash === ""
|
|
187
|
+
&& /^\/zen\/(?:go\/)?v1\/(?:responses|chat\/completions|messages)$/.test(url.pathname);
|
|
188
|
+
} catch {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The canonical refusal envelope, as served on both Console routes:
|
|
195
|
+
* {"error":{"param":null,"type":"invalid_request_error","message":"Error from provider
|
|
196
|
+
* (Console Go): Upstream request failed: [invalid_request_error] Invalid upload request."}}
|
|
197
|
+
*
|
|
198
|
+
* The gateway names itself Console on the Zen key route and Console Go on the Go route, so the
|
|
199
|
+
* anchor is the shared product name plus the refusal sentence. The message is matched whole: a
|
|
200
|
+
* bare string, a suffix, or any other envelope is a different refusal and must not be replayed.
|
|
201
|
+
* Being stricter than necessary is the safe direction: a missed match leaves the turn failing
|
|
202
|
+
* exactly as it does today, while a loose match spends an extra request on unrelated 400s.
|
|
203
|
+
*/
|
|
204
|
+
const CONSOLE_UPLOAD_REFUSALS = new Set([
|
|
205
|
+
"Error from provider (Console Go): Upstream request failed: [invalid_request_error] Invalid upload request.",
|
|
206
|
+
"Error from provider (Console): Upstream request failed: [invalid_request_error] Invalid upload request.",
|
|
207
|
+
]);
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* True only for the canonical Console upload refusal on a canonical Console destination.
|
|
211
|
+
* Route-gated on purpose: the message alone would let any other upstream that happens to answer
|
|
212
|
+
* with this English sentence trigger a second send from an unrelated provider.
|
|
213
|
+
*/
|
|
214
|
+
export function isTransientConsoleGoUploadRejection(opts: {
|
|
215
|
+
status: number;
|
|
216
|
+
errorBody: string | undefined;
|
|
217
|
+
outboundUrl?: string;
|
|
218
|
+
}): boolean {
|
|
219
|
+
if (opts.status !== 400 || !opts.errorBody) return false;
|
|
220
|
+
if (!isConsoleGoDestination(opts.outboundUrl)) return false;
|
|
221
|
+
let payload: unknown;
|
|
222
|
+
try {
|
|
223
|
+
payload = JSON.parse(opts.errorBody);
|
|
224
|
+
} catch {
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
const error = (payload as { error?: unknown } | null)?.error;
|
|
228
|
+
if (!error || typeof error !== "object" || Array.isArray(error)) return false;
|
|
229
|
+
// The whole envelope, not just the sentence: Console always answers this refusal as
|
|
230
|
+
// invalid_request_error with a null param, so a partial envelope is a different error.
|
|
231
|
+
const envelope = error as { param?: unknown; type?: unknown; message?: unknown };
|
|
232
|
+
if (envelope.type !== "invalid_request_error" || envelope.param !== null) return false;
|
|
233
|
+
// Matched without trimming: padding means the gateway wrapped or appended something.
|
|
234
|
+
return typeof envelope.message === "string" && CONSOLE_UPLOAD_REFUSALS.has(envelope.message);
|
|
235
|
+
}
|
|
@@ -20,11 +20,13 @@ export interface ProviderRewriteResult {
|
|
|
20
20
|
*
|
|
21
21
|
* Three shapes exist and the difference matters: routed model strings
|
|
22
22
|
* (`"<provider>/<model>"`), bare provider ids (`customModels[].provider`,
|
|
23
|
-
* `combos[*].targets[].provider`),
|
|
24
|
-
* (`providerContextCaps`,
|
|
25
|
-
* that handles only the
|
|
26
|
-
*
|
|
27
|
-
*
|
|
23
|
+
* `combos[*].targets[].provider`, `routingProfiles[*].candidates[].provider`),
|
|
24
|
+
* and keys that ARE provider ids or routes (`providerContextCaps`,
|
|
25
|
+
* `claudeCode.desktopProfile.assignments`). A rewrite that handles only the
|
|
26
|
+
* first leaves an orphaned context cap and — worse — a combo target or routing
|
|
27
|
+
* candidate naming a provider that no longer exists, which fails validation in
|
|
28
|
+
* `src/combos/types.ts` / `src/routing/profile.ts` and makes `loadConfig`
|
|
29
|
+
* discard the whole config.
|
|
28
30
|
*
|
|
29
31
|
* `providers[*].selectedModels` is deliberately NOT rewritten: those are
|
|
30
32
|
* per-provider native model ids, and upstream ids may themselves contain a
|
|
@@ -110,6 +112,19 @@ export function rewriteProviderReferences(config: OcxConfig, from: string, to: s
|
|
|
110
112
|
}
|
|
111
113
|
}
|
|
112
114
|
|
|
115
|
+
// Routing-profile candidates carry a bare provider id next to a bare model
|
|
116
|
+
// id (OcxRoutingProfileCandidate), and profile validation requires the
|
|
117
|
+
// provider to be configured — so an unrewritten candidate is the same
|
|
118
|
+
// load-failing dangling reference a stale combo target is.
|
|
119
|
+
for (const profile of Object.values(config.routingProfiles ?? {})) {
|
|
120
|
+
for (const candidate of profile.candidates ?? []) {
|
|
121
|
+
if (candidate.provider === from) {
|
|
122
|
+
candidate.provider = to;
|
|
123
|
+
changed += 1;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
113
128
|
// Keys. `providerContextCaps` is KEYED by provider id — a prefix rewrite would
|
|
114
129
|
// silently orphan the cap — and a destination key may already be occupied.
|
|
115
130
|
for (const field of ["providerContextCaps", "providerContextCapValues"] as const) {
|
|
@@ -49,4 +49,16 @@ export interface AccountQuotaFields {
|
|
|
49
49
|
quotaMode?: AccountQuotaMode;
|
|
50
50
|
quota?: ProviderQuota | null;
|
|
51
51
|
quotaUnavailable?: boolean;
|
|
52
|
+
quotaFailure?: QuotaFailureCode;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
/** Closed account-probe diagnoses; never upstream text, URLs, credentials or routing policy. */
|
|
57
|
+
export const QUOTA_FAILURE_CODES = [
|
|
58
|
+
"account_unavailable", "access_denied", "rate_limited", "upstream_error", "redirect_blocked",
|
|
59
|
+
"destination_blocked", "dns_failed", "timeout", "transport_error", "response_unusable",
|
|
60
|
+
] as const;
|
|
61
|
+
export type QuotaFailureCode = typeof QUOTA_FAILURE_CODES[number];
|
|
62
|
+
export function parseQuotaFailureCode(value: unknown): QuotaFailureCode | undefined {
|
|
63
|
+
return QUOTA_FAILURE_CODES.find(code => code === value);
|
|
52
64
|
}
|