opencode-anthropic 0.0.0-stage → 0.1.2
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/LICENSE +25 -0
- package/README.md +269 -2
- package/index.js +380 -0
- package/login.mjs +158 -0
- package/oauth.mjs +136 -0
- package/package.json +46 -4
- package/retry.mjs +46 -0
- package/tests/plugin.test.mjs +575 -0
- package/tools.mjs +174 -0
- package/transform.mjs +316 -0
package/login.mjs
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Interactive login for Claude Pro/Max OAuth.
|
|
3
|
+
//
|
|
4
|
+
// Usage:
|
|
5
|
+
// opencode-anthropic login (installed package)
|
|
6
|
+
// node /path/to/login.mjs (source checkout)
|
|
7
|
+
// node /path/to/login.mjs --no-browser (print the URL only)
|
|
8
|
+
//
|
|
9
|
+
// The script opens the browser flow, exchanges the pasted code for tokens,
|
|
10
|
+
// registers an Anthropic OAuth credential in the running OpenCode server, and
|
|
11
|
+
// saves a local token cache bound to that credential. Token values never
|
|
12
|
+
// travel through process arguments: the server is reached directly over its
|
|
13
|
+
// local HTTP API.
|
|
14
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
15
|
+
import { chmodSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { dirname } from "node:path";
|
|
17
|
+
import { createInterface as createPrompt } from "node:readline/promises";
|
|
18
|
+
import { stdin, stdout } from "node:process";
|
|
19
|
+
import { TOKENS_FILE, createAuthorizationRequest, exchangeCodeForTokens } from "./oauth.mjs";
|
|
20
|
+
|
|
21
|
+
const METHOD_ID = "claude-pro-max";
|
|
22
|
+
const LABEL = "Claude Pro/Max";
|
|
23
|
+
const noBrowser = process.argv.slice(2).includes("--no-browser");
|
|
24
|
+
|
|
25
|
+
function openBrowser(url) {
|
|
26
|
+
const [command, args] =
|
|
27
|
+
process.platform === "darwin"
|
|
28
|
+
? ["open", [url]]
|
|
29
|
+
: process.platform === "win32"
|
|
30
|
+
? ["cmd", ["/c", "start", "", url]]
|
|
31
|
+
: ["xdg-open", [url]];
|
|
32
|
+
try {
|
|
33
|
+
const child = spawn(command, args, { stdio: "ignore", detached: true });
|
|
34
|
+
child.on("error", () => {});
|
|
35
|
+
child.unref();
|
|
36
|
+
return true;
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function discoverServer() {
|
|
43
|
+
const override = process.env.OPENCODE_SERVER_URL;
|
|
44
|
+
if (override) return override.replace(/\/+$/, "");
|
|
45
|
+
const result = spawnSync("opencode", ["service", "status"], { encoding: "utf8" });
|
|
46
|
+
const match = /(https?:\/\/[^\s]+)/.exec(`${result.stdout ?? ""}${result.stderr ?? ""}`);
|
|
47
|
+
return match ? match[1].replace(/\/+$/, "") : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function api(base, method, path, body) {
|
|
51
|
+
const response = await fetch(`${base}${path}`, {
|
|
52
|
+
method,
|
|
53
|
+
headers: body === undefined ? undefined : { "content-type": "application/json" },
|
|
54
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
55
|
+
});
|
|
56
|
+
const text = await response.text();
|
|
57
|
+
let data = null;
|
|
58
|
+
try {
|
|
59
|
+
data = text ? JSON.parse(text) : null;
|
|
60
|
+
} catch {}
|
|
61
|
+
return { ok: response.ok, status: response.status, data };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function printRegistrationFailure(reason) {
|
|
65
|
+
console.log("");
|
|
66
|
+
console.log(`! Could not register the credential in OpenCode: ${reason}.`);
|
|
67
|
+
console.log(" Make sure OpenCode is running and the `opencode` CLI is on your PATH,");
|
|
68
|
+
console.log(" then run the login again. Without the credential the plugin stays inactive.");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function registerCredential(tokens) {
|
|
72
|
+
const base = discoverServer();
|
|
73
|
+
if (!base) {
|
|
74
|
+
printRegistrationFailure("could not find the OpenCode server (is the `opencode` CLI on your PATH?)");
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
const list = await api(base, "GET", "/api/credential");
|
|
78
|
+
if (!list.ok || !Array.isArray(list.data?.data)) {
|
|
79
|
+
printRegistrationFailure(`the server answered HTTP ${list.status} while listing credentials`);
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
for (const entry of list.data.data) {
|
|
83
|
+
if (entry.integrationID === "anthropic" && entry.value?.type === "oauth" && entry.value?.methodID === METHOD_ID) {
|
|
84
|
+
await api(base, "DELETE", `/api/credential/${entry.id}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const created = await api(base, "POST", "/api/credential", {
|
|
88
|
+
integrationID: "anthropic",
|
|
89
|
+
label: LABEL,
|
|
90
|
+
value: {
|
|
91
|
+
type: "oauth",
|
|
92
|
+
methodID: METHOD_ID,
|
|
93
|
+
access: tokens.access,
|
|
94
|
+
refresh: tokens.refresh,
|
|
95
|
+
expires: tokens.expires,
|
|
96
|
+
},
|
|
97
|
+
activate: true,
|
|
98
|
+
});
|
|
99
|
+
if (!created.ok) {
|
|
100
|
+
printRegistrationFailure(`the server answered HTTP ${created.status} while creating the credential`);
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
return created.data?.data?.id ?? null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function saveTokens(tokens) {
|
|
107
|
+
mkdirSync(dirname(TOKENS_FILE), { recursive: true });
|
|
108
|
+
const temp = `${TOKENS_FILE}.tmp`;
|
|
109
|
+
writeFileSync(temp, JSON.stringify(tokens, null, 2), { mode: 0o600 });
|
|
110
|
+
renameSync(temp, TOKENS_FILE);
|
|
111
|
+
chmodSync(TOKENS_FILE, 0o600);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const { url, verifier } = createAuthorizationRequest();
|
|
115
|
+
console.log("");
|
|
116
|
+
console.log("Open the URL below in your browser and authorize with your Claude Pro/Max account:");
|
|
117
|
+
console.log("");
|
|
118
|
+
console.log(` ${url}`);
|
|
119
|
+
console.log("");
|
|
120
|
+
if (!noBrowser && openBrowser(url)) {
|
|
121
|
+
console.log("(tried to open your browser — use the URL above if it did not)");
|
|
122
|
+
console.log("");
|
|
123
|
+
}
|
|
124
|
+
const prompt = createPrompt({ input: stdin, output: stdout });
|
|
125
|
+
const code = (await prompt.question("Paste the code shown by Anthropic here: ")).trim();
|
|
126
|
+
prompt.close();
|
|
127
|
+
if (!code) {
|
|
128
|
+
console.error("\nNo code provided. Nothing was saved.");
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
console.log("\nExchanging the code for tokens...");
|
|
133
|
+
let tokens;
|
|
134
|
+
try {
|
|
135
|
+
tokens = await exchangeCodeForTokens(code, verifier);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
const detail = String(error?.message ?? error);
|
|
138
|
+
if (/fetch failed|ENOTFOUND|ECONNREFUSED|EAI_AGAIN|network/i.test(detail)) {
|
|
139
|
+
console.error("\nNetwork failure while talking to Anthropic. Check your connection and try again.");
|
|
140
|
+
} else if (/Token exchange failed: 4\d\d/.test(detail)) {
|
|
141
|
+
console.error("\nAnthropic rejected the code (invalid or expired). Run the login again and paste a fresh code.");
|
|
142
|
+
} else {
|
|
143
|
+
console.error(`\nLogin failed: ${detail}`);
|
|
144
|
+
}
|
|
145
|
+
process.exit(1);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const connectionID = await registerCredential(tokens);
|
|
149
|
+
saveTokens({ ...tokens, ...(connectionID ? { connectionID } : {}) });
|
|
150
|
+
if (!connectionID) process.exit(1);
|
|
151
|
+
|
|
152
|
+
console.log("");
|
|
153
|
+
console.log("Login complete. Tokens saved to:");
|
|
154
|
+
console.log(` ${TOKENS_FILE}`);
|
|
155
|
+
console.log(`Access token expires at ${new Date(tokens.expires).toLocaleString()} (refreshed automatically).`);
|
|
156
|
+
console.log("");
|
|
157
|
+
console.log("Open a new OpenCode session and select an anthropic/claude-* model.");
|
|
158
|
+
console.log("Tip: if you just updated the plugin, restart the service first: opencode service restart");
|
package/oauth.mjs
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// OAuth PKCE helpers for the Anthropic Claude Pro/Max flow.
|
|
2
|
+
// Ported from opencode-anthropic-oauth (MIT) so the params stay identical.
|
|
3
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
export const CLIENT_ID = process.env.ANTHROPIC_CLIENT_ID || "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
|
8
|
+
export const AUTHORIZE_URL = process.env.ANTHROPIC_AUTHORIZE_URL || "https://claude.ai/oauth/authorize";
|
|
9
|
+
export const TOKEN_URL = process.env.ANTHROPIC_TOKEN_URL || "https://platform.claude.com/v1/oauth/token";
|
|
10
|
+
export const REDIRECT_URI = process.env.ANTHROPIC_REDIRECT_URI || "https://platform.claude.com/oauth/code/callback";
|
|
11
|
+
const SCOPES =
|
|
12
|
+
process.env.ANTHROPIC_SCOPES ||
|
|
13
|
+
"user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";
|
|
14
|
+
// The Claude Code profile this plugin targets. Override with
|
|
15
|
+
// ANTHROPIC_CLI_VERSION when a newer release needs different headers.
|
|
16
|
+
const CLI_VERSION = process.env.ANTHROPIC_CLI_VERSION || "2.1.289";
|
|
17
|
+
const CLI_BUILD = process.env.ANTHROPIC_CLI_BUILD || "45c";
|
|
18
|
+
export const CLI_ENTRYPOINT = process.env.ANTHROPIC_CLI_ENTRYPOINT || "cli";
|
|
19
|
+
export const USER_AGENT = process.env.ANTHROPIC_USER_AGENT || `claude-cli/${CLI_VERSION} (external, ${CLI_ENTRYPOINT})`;
|
|
20
|
+
// First system block of a Claude Code request, as captured from 2.1.289.
|
|
21
|
+
export const BILLING_HEADER =
|
|
22
|
+
process.env.ANTHROPIC_BILLING_HEADER ||
|
|
23
|
+
`x-anthropic-billing-header: cc_version=${CLI_VERSION}.${CLI_BUILD}; cc_entrypoint=${CLI_ENTRYPOINT};`;
|
|
24
|
+
// Anthropic SDK telemetry headers, as captured from Claude Code 2.1.289.
|
|
25
|
+
export const SDK_HEADERS = {
|
|
26
|
+
"x-stainless-arch": process.arch === "x64" ? "x64" : process.arch,
|
|
27
|
+
"x-stainless-lang": "js",
|
|
28
|
+
"x-stainless-os":
|
|
29
|
+
process.platform === "linux"
|
|
30
|
+
? "Linux"
|
|
31
|
+
: process.platform === "darwin"
|
|
32
|
+
? "MacOS"
|
|
33
|
+
: process.platform === "win32"
|
|
34
|
+
? "Windows"
|
|
35
|
+
: process.platform,
|
|
36
|
+
"x-stainless-package-version": process.env.ANTHROPIC_SDK_VERSION || "0.128.0",
|
|
37
|
+
"x-stainless-retry-count": "0",
|
|
38
|
+
"x-stainless-runtime": "node",
|
|
39
|
+
"x-stainless-runtime-version": process.env.ANTHROPIC_NODE_VERSION || "v26.3.0",
|
|
40
|
+
"x-stainless-timeout": "600",
|
|
41
|
+
};
|
|
42
|
+
export const BETA_FLAGS =
|
|
43
|
+
process.env.ANTHROPIC_BETA_FLAGS ||
|
|
44
|
+
"claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05";
|
|
45
|
+
|
|
46
|
+
const DATA_DIR = process.env.XDG_DATA_HOME ? join(process.env.XDG_DATA_HOME, "opencode") : join(homedir(), ".local", "share", "opencode");
|
|
47
|
+
export const TOKENS_FILE = join(DATA_DIR, "opencode-anthropic.json");
|
|
48
|
+
export const DEVICE_FILE = join(DATA_DIR, "opencode-anthropic-device.json");
|
|
49
|
+
|
|
50
|
+
function base64url(buffer) {
|
|
51
|
+
return buffer.toString("base64url").replace(/=+$/, "");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function fetchWithRetry(url, init, retries = 3) {
|
|
55
|
+
for (let attempt = 0; attempt < retries; attempt++) {
|
|
56
|
+
const response = await fetch(url, init);
|
|
57
|
+
if (response.status === 429 && attempt < retries - 1) {
|
|
58
|
+
await new Promise((resolve) => setTimeout(resolve, (attempt + 1) * 2000));
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
return response;
|
|
62
|
+
}
|
|
63
|
+
return fetch(url, init);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function createAuthorizationRequest() {
|
|
67
|
+
const verifier = base64url(randomBytes(32));
|
|
68
|
+
const challenge = base64url(createHash("sha256").update(verifier).digest());
|
|
69
|
+
const params = new URLSearchParams({
|
|
70
|
+
code: "true",
|
|
71
|
+
response_type: "code",
|
|
72
|
+
client_id: CLIENT_ID,
|
|
73
|
+
redirect_uri: REDIRECT_URI,
|
|
74
|
+
scope: SCOPES,
|
|
75
|
+
code_challenge: challenge,
|
|
76
|
+
code_challenge_method: "S256",
|
|
77
|
+
state: verifier,
|
|
78
|
+
});
|
|
79
|
+
return { url: `${AUTHORIZE_URL}?${params}`, verifier };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function parseAuthCode(raw) {
|
|
83
|
+
const hashIndex = raw.indexOf("#");
|
|
84
|
+
return hashIndex >= 0 ? raw.slice(0, hashIndex) : raw;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function exchangeCodeForTokens(rawCode, verifier) {
|
|
88
|
+
const body = new URLSearchParams({
|
|
89
|
+
grant_type: "authorization_code",
|
|
90
|
+
code: parseAuthCode(rawCode.trim()),
|
|
91
|
+
code_verifier: verifier,
|
|
92
|
+
client_id: CLIENT_ID,
|
|
93
|
+
redirect_uri: REDIRECT_URI,
|
|
94
|
+
state: verifier,
|
|
95
|
+
});
|
|
96
|
+
const response = await fetchWithRetry(TOKEN_URL, {
|
|
97
|
+
method: "POST",
|
|
98
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": USER_AGENT },
|
|
99
|
+
body: body.toString(),
|
|
100
|
+
});
|
|
101
|
+
if (!response.ok) {
|
|
102
|
+
const detail = await response.text().catch(() => "");
|
|
103
|
+
throw new Error(`Token exchange failed: ${response.status} ${response.statusText}${detail ? ` — ${detail}` : ""}`);
|
|
104
|
+
}
|
|
105
|
+
const data = await response.json();
|
|
106
|
+
if (!data.access_token || !data.refresh_token) throw new Error("Token exchange returned no tokens");
|
|
107
|
+
return {
|
|
108
|
+
access: data.access_token,
|
|
109
|
+
refresh: data.refresh_token,
|
|
110
|
+
expires: Date.now() + (data.expires_in ?? 36000) * 1000,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function refreshTokens(refreshToken) {
|
|
115
|
+
const body = new URLSearchParams({
|
|
116
|
+
grant_type: "refresh_token",
|
|
117
|
+
refresh_token: refreshToken,
|
|
118
|
+
client_id: CLIENT_ID,
|
|
119
|
+
});
|
|
120
|
+
const response = await fetchWithRetry(TOKEN_URL, {
|
|
121
|
+
method: "POST",
|
|
122
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": USER_AGENT },
|
|
123
|
+
body: body.toString(),
|
|
124
|
+
});
|
|
125
|
+
if (!response.ok) {
|
|
126
|
+
const detail = await response.text().catch(() => "");
|
|
127
|
+
throw new Error(`Token refresh failed: ${response.status} ${response.statusText}${detail ? ` — ${detail}` : ""}`);
|
|
128
|
+
}
|
|
129
|
+
const data = await response.json();
|
|
130
|
+
if (!data.access_token) throw new Error("Token refresh returned no access token");
|
|
131
|
+
return {
|
|
132
|
+
access: data.access_token,
|
|
133
|
+
refresh: data.refresh_token || refreshToken,
|
|
134
|
+
expires: Date.now() + (data.expires_in ?? 36000) * 1000,
|
|
135
|
+
};
|
|
136
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,48 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-anthropic",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "OpenCode V2 plugin that restores Claude Pro/Max OAuth for the Anthropic provider",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"opencode",
|
|
8
|
+
"opencode-plugin",
|
|
9
|
+
"anthropic",
|
|
10
|
+
"claude",
|
|
11
|
+
"oauth"
|
|
12
|
+
],
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/aiev/opencode-anthropic.git"
|
|
16
|
+
},
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"import": "./index.js"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"bin": {
|
|
27
|
+
"opencode-anthropic": "login.mjs"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"index.js",
|
|
31
|
+
"oauth.mjs",
|
|
32
|
+
"transform.mjs",
|
|
33
|
+
"tools.mjs",
|
|
34
|
+
"retry.mjs",
|
|
35
|
+
"login.mjs",
|
|
36
|
+
"tests",
|
|
37
|
+
"README.md",
|
|
38
|
+
"LICENSE"
|
|
39
|
+
],
|
|
40
|
+
"scripts": {
|
|
41
|
+
"test": "node --test",
|
|
42
|
+
"capture": "node scripts/claude-capture.mjs",
|
|
43
|
+
"prepublishOnly": "node --test"
|
|
44
|
+
},
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=20"
|
|
47
|
+
}
|
|
48
|
+
}
|
package/retry.mjs
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Rate-limit handling.
|
|
2
|
+
//
|
|
3
|
+
// Anthropic reports a `retry-after` header on 429 (and sometimes 503/529)
|
|
4
|
+
// responses. OpenCode's retry hook can veto or reshape a retry decision, so
|
|
5
|
+
// the http.response hook records the last hint here and the retry hook hands
|
|
6
|
+
// it back as the delay instead of letting the client hammer the endpoint.
|
|
7
|
+
const RETRY_STATUSES = new Set([429, 503, 529]);
|
|
8
|
+
const DEFAULT_TTL_MS = 5 * 60 * 1000;
|
|
9
|
+
const DEFAULT_CAP_MS = 15 * 60 * 1000;
|
|
10
|
+
const DEFAULT_FLOOR_MS = 1000;
|
|
11
|
+
|
|
12
|
+
// `retry-after` is either a number of seconds or an HTTP date.
|
|
13
|
+
export function parseRetryAfter(value, now = Date.now()) {
|
|
14
|
+
if (value === null || value === undefined || value === "") return null;
|
|
15
|
+
const seconds = Number(value);
|
|
16
|
+
if (Number.isFinite(seconds) && seconds >= 0) return Math.round(seconds * 1000);
|
|
17
|
+
const date = Date.parse(value);
|
|
18
|
+
if (!Number.isNaN(date)) return Math.max(0, date - now);
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createRateLimitState({ ttlMs = DEFAULT_TTL_MS, capMs = DEFAULT_CAP_MS, floorMs = DEFAULT_FLOOR_MS } = {}) {
|
|
23
|
+
let last = null;
|
|
24
|
+
return {
|
|
25
|
+
// Record the hint for a retryable failure; any other status clears it.
|
|
26
|
+
note(response, now = Date.now()) {
|
|
27
|
+
if (!RETRY_STATUSES.has(response?.status)) {
|
|
28
|
+
last = null;
|
|
29
|
+
return last;
|
|
30
|
+
}
|
|
31
|
+
const hint = parseRetryAfter(response.headers?.get?.("retry-after"), now);
|
|
32
|
+
last = { at: now, delayMs: hint };
|
|
33
|
+
return last;
|
|
34
|
+
},
|
|
35
|
+
clear() {
|
|
36
|
+
last = null;
|
|
37
|
+
},
|
|
38
|
+
// Null when there is no fresh, usable hint: the caller should leave the
|
|
39
|
+
// retry decision alone.
|
|
40
|
+
delayMsFor(status, now = Date.now()) {
|
|
41
|
+
if (!RETRY_STATUSES.has(status)) return null;
|
|
42
|
+
if (!last || now - last.at > ttlMs || last.delayMs === null) return null;
|
|
43
|
+
return Math.min(Math.max(last.delayMs, floorMs), capMs);
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|