@timo972/cc-router 0.11.0 → 0.12.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +33 -4
  3. package/dist/cli/cmd-accounts.js +91 -13
  4. package/dist/cli/cmd-cli-targets.js +177 -0
  5. package/dist/cli/cmd-client.js +3 -2
  6. package/dist/cli/cmd-configure.js +18 -4
  7. package/dist/cli/cmd-status.js +4 -1
  8. package/dist/cli/cmd-stop.js +18 -0
  9. package/dist/cli/index.js +10 -3
  10. package/dist/config/manager.js +19 -3
  11. package/dist/config/paths.js +1 -0
  12. package/dist/protocol/anthropic-to-openai.js +31 -19
  13. package/dist/protocol/model-ref.js +1 -1
  14. package/dist/protocol/openai-function-call.js +16 -0
  15. package/dist/protocol/openai-response-to-anthropic.js +34 -6
  16. package/dist/protocol/openai-stream-to-anthropic.js +116 -16
  17. package/dist/protocol/openai-to-anthropic.js +54 -29
  18. package/dist/providers/openai/account-state.js +4 -1
  19. package/dist/providers/openai/usage.js +14 -1
  20. package/dist/providers/xai/account-record.js +42 -0
  21. package/dist/providers/xai/device-oauth.js +116 -0
  22. package/dist/providers/xai/import-auth.js +70 -0
  23. package/dist/providers/xai/overview.js +279 -0
  24. package/dist/providers/xai/subscription-fetch.js +56 -0
  25. package/dist/proxy/allowance.js +142 -0
  26. package/dist/proxy/logger.js +4 -3
  27. package/dist/proxy/messages-cross-route.js +113 -8
  28. package/dist/proxy/models-server.js +30 -2
  29. package/dist/proxy/openai-ingress.js +7 -1
  30. package/dist/proxy/openai-routing.js +12 -7
  31. package/dist/proxy/server.js +110 -6
  32. package/dist/ui/Dashboard.js +383 -124
  33. package/dist/ui/accountsApi.js +3 -1
  34. package/dist/utils/cli-routing.js +80 -0
  35. package/dist/utils/codex-config.js +337 -15
  36. package/package.json +3 -2
@@ -110,9 +110,11 @@ export function renameAccountRecordById(oldId, newId) {
110
110
  return target;
111
111
  }
112
112
  function normalizeAccountProvider(record) {
113
- return record.provider === "openai_subscription"
114
- ? "openai_subscription"
115
- : "anthropic_subscription";
113
+ if (record.provider === "openai_subscription")
114
+ return "openai_subscription";
115
+ if (record.provider === "xai_subscription")
116
+ return "xai_subscription";
117
+ return "anthropic_subscription";
116
118
  }
117
119
  export function migrateLegacyAccountProviders(path = ACCOUNTS_PATH) {
118
120
  const records = readRawFromPath(path);
@@ -184,6 +186,20 @@ export function saveOpenAIAccountsToPath(accounts, path) {
184
186
  export function saveOpenAIAccounts(accounts) {
185
187
  saveOpenAIAccountsToPath(accounts, ACCOUNTS_PATH);
186
188
  }
189
+ export function loadXaiAccounts(path) {
190
+ const records = readRawFromPath(path ?? ACCOUNTS_PATH);
191
+ return records
192
+ .filter(a => a.provider === "xai_subscription")
193
+ .map(a => ({
194
+ id: a.id,
195
+ provider: "xai_subscription",
196
+ accessToken: a.accessToken,
197
+ refreshToken: a.refreshToken,
198
+ expiresAt: a.expiresAt,
199
+ enabled: a.enabled !== false,
200
+ ...(Array.isArray(a.scopes) ? { scopes: a.scopes } : {}),
201
+ }));
202
+ }
187
203
  function parseProxyConfig(raw) {
188
204
  const parsed = JSON.parse(raw);
189
205
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
@@ -5,6 +5,7 @@ export const CONFIG_DIR = path.join(os.homedir(), ".cc-router");
5
5
  export const ACCOUNTS_PATH = process.env["ACCOUNTS_PATH"] ??
6
6
  path.join(CONFIG_DIR, "accounts.json");
7
7
  export const CLAUDE_SETTINGS_PATH = path.join(os.homedir(), ".claude", "settings.json");
8
+ export const CODEX_CONFIG_PATH = path.join(os.homedir(), ".codex", "config.toml");
8
9
  export const PROXY_PORT = parseInt(process.env["PORT"] ?? "3456", 10);
9
10
  export const LITELLM_PORT = 4000;
10
11
  // When set, the server forwards to LiteLLM instead of Anthropic directly
@@ -6,39 +6,51 @@ function stringifySystem(system) {
6
6
  return system;
7
7
  return system.map(block => block.text).join("\n");
8
8
  }
9
- function contentToOpenAI(content) {
10
- if (typeof content === "string")
11
- return [{ type: "input_text", text: content }];
12
- return content.map(block => {
13
- if (block.type === "text")
14
- return { type: "input_text", text: block.text };
9
+ function messageToOpenAIItems(role, content) {
10
+ const textType = role === "assistant" ? "output_text" : "input_text";
11
+ if (typeof content === "string") {
12
+ return [{ role, content: [{ type: textType, text: content }] }];
13
+ }
14
+ const items = [];
15
+ let pendingText = [];
16
+ const flushText = () => {
17
+ if (pendingText.length === 0)
18
+ return;
19
+ items.push({ role, content: pendingText });
20
+ pendingText = [];
21
+ };
22
+ for (const block of content) {
23
+ if (block.type === "text") {
24
+ pendingText.push({ type: textType, text: block.text });
25
+ continue;
26
+ }
27
+ flushText();
15
28
  if (block.type === "tool_use") {
16
- return {
29
+ items.push({
17
30
  type: "function_call",
18
31
  call_id: block.id,
19
32
  name: block.name,
20
33
  arguments: JSON.stringify(block.input ?? {}),
21
- };
34
+ });
35
+ continue;
22
36
  }
23
- const output = typeof block.content === "string"
24
- ? block.content
25
- : block.content.map(item => item.text).join("\n");
26
- return {
37
+ items.push({
27
38
  type: "function_call_output",
28
39
  call_id: block.tool_use_id,
29
- output,
30
- };
31
- });
40
+ output: typeof block.content === "string"
41
+ ? block.content
42
+ : block.content.map(item => item.text).join("\n"),
43
+ });
44
+ }
45
+ flushText();
46
+ return items;
32
47
  }
33
48
  export function anthropicToOpenAIResponses(req, modelRouting = {}) {
34
49
  const parsed = parseModelRef(req.model, modelRouting);
35
50
  return {
36
51
  model: parsed.upstreamModel,
37
52
  instructions: stringifySystem(req.system),
38
- input: req.messages.map(message => ({
39
- role: message.role,
40
- content: contentToOpenAI(message.content),
41
- })),
53
+ input: req.messages.flatMap(message => messageToOpenAIItems(message.role, message.content)),
42
54
  tools: req.tools?.map(tool => ({
43
55
  type: "function",
44
56
  name: tool.name,
@@ -20,7 +20,7 @@ function cleanModel(model) {
20
20
  * costs the Anthropic path nothing. Everything else unprefixed still goes to
21
21
  * Anthropic, which is what existing setups rely on.
22
22
  */
23
- function isBareOpenAIModel(publicModel) {
23
+ export function isBareOpenAIModel(publicModel) {
24
24
  return publicModel.toLowerCase().startsWith("gpt-");
25
25
  }
26
26
  export function parseModelRef(model, config = {}) {
@@ -0,0 +1,16 @@
1
+ export class OpenAIProtocolError extends Error {
2
+ name = "OpenAIProtocolError";
3
+ }
4
+ export function parseOpenAIFunctionArguments(argumentsJson) {
5
+ let parsed;
6
+ try {
7
+ parsed = JSON.parse(argumentsJson);
8
+ }
9
+ catch {
10
+ throw new OpenAIProtocolError("Invalid OpenAI function call arguments");
11
+ }
12
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
13
+ throw new OpenAIProtocolError("Invalid OpenAI function call arguments");
14
+ }
15
+ return parsed;
16
+ }
@@ -1,3 +1,4 @@
1
+ import { OpenAIProtocolError, parseOpenAIFunctionArguments } from "./openai-function-call.js";
1
2
  /**
2
3
  * Anthropic stop reason for a terminal Responses payload. Shared by both
3
4
  * translation paths — this module for a collected response, and the streaming
@@ -14,18 +15,45 @@ export function anthropicStopReasonForResponse(response) {
14
15
  return response?.incomplete_details?.reason === "max_output_tokens" ? "max_tokens" : "end_turn";
15
16
  }
16
17
  export function openAIResponseToAnthropicMessage(response) {
17
- const content = (response.output ?? [])
18
- .filter(item => item.type === "message")
19
- .flatMap(item => item.content)
20
- .filter(item => item.type === "output_text")
21
- .map(item => ({ type: "text", text: item.text }));
18
+ const content = [];
19
+ let sawRefusal = false;
20
+ for (const item of response.output ?? []) {
21
+ if (item.type === "message") {
22
+ for (const part of item.content) {
23
+ if (part.type === "output_text") {
24
+ content.push({ type: "text", text: part.text });
25
+ }
26
+ else if (part.type === "refusal") {
27
+ sawRefusal = true;
28
+ content.push({ type: "text", text: part.refusal });
29
+ }
30
+ }
31
+ continue;
32
+ }
33
+ if (item.type !== "function_call")
34
+ continue;
35
+ if (!item.call_id?.trim() || !item.name?.trim()) {
36
+ throw new OpenAIProtocolError("Invalid OpenAI function call metadata");
37
+ }
38
+ content.push({
39
+ type: "tool_use",
40
+ id: item.call_id,
41
+ name: item.name,
42
+ input: parseOpenAIFunctionArguments(item.arguments),
43
+ });
44
+ }
45
+ const stopReason = content.some(block => block.type === "tool_use")
46
+ ? "tool_use"
47
+ : sawRefusal
48
+ ? "refusal"
49
+ : anthropicStopReasonForResponse(response);
22
50
  return {
23
51
  id: response.id,
24
52
  type: "message",
25
53
  role: "assistant",
26
54
  model: response.model ?? "",
27
55
  content,
28
- stop_reason: anthropicStopReasonForResponse(response),
56
+ stop_reason: stopReason,
29
57
  stop_sequence: null,
30
58
  usage: {
31
59
  input_tokens: response.usage?.input_tokens ?? 0,
@@ -1,25 +1,39 @@
1
1
  import { anthropicStopReasonForResponse } from "./openai-response-to-anthropic.js";
2
+ import { OpenAIProtocolError, parseOpenAIFunctionArguments } from "./openai-function-call.js";
2
3
  import { terminalResponsePayload } from "./openai-responses-collect.js";
3
4
  export function createOpenAIStreamToAnthropicNormalizer() {
4
- let textBlockStarted = false;
5
- const ensureTextBlockStarted = () => {
6
- if (textBlockStarted)
5
+ let blocks = new Map();
6
+ let nextIndex = 0;
7
+ let sawToolUse = false;
8
+ let sawRefusal = false;
9
+ const openTextBlock = (outputIndex) => {
10
+ if (blocks.has(outputIndex))
7
11
  return [];
8
- textBlockStarted = true;
9
- return [
10
- {
12
+ const block = { index: nextIndex++, kind: "text", sentArguments: false, argumentsJson: "" };
13
+ blocks.set(outputIndex, block);
14
+ return [{
11
15
  type: "content_block_start",
12
- index: 0,
16
+ index: block.index,
13
17
  content_block: { type: "text", text: "" },
14
- },
15
- ];
18
+ }];
19
+ };
20
+ const closeBlock = (outputIndex) => {
21
+ const block = blocks.get(outputIndex);
22
+ if (!block)
23
+ return [];
24
+ blocks.delete(outputIndex);
25
+ return [{ type: "content_block_stop", index: block.index }];
16
26
  };
17
27
  const reset = () => {
18
- textBlockStarted = false;
28
+ blocks = new Map();
29
+ nextIndex = 0;
30
+ sawToolUse = false;
31
+ sawRefusal = false;
19
32
  };
20
33
  return {
21
34
  reset,
22
35
  convert(event) {
36
+ const outputIndex = event.output_index ?? 0;
23
37
  if (event.type === "response.created") {
24
38
  reset();
25
39
  return [
@@ -38,16 +52,96 @@ export function createOpenAIStreamToAnthropicNormalizer() {
38
52
  },
39
53
  ];
40
54
  }
55
+ if (event.type === "response.output_item.added") {
56
+ if (event.item?.type !== "function_call" || blocks.has(outputIndex))
57
+ return [];
58
+ if (!event.item.call_id?.trim() || !event.item.name?.trim()) {
59
+ throw new OpenAIProtocolError("Invalid OpenAI function call metadata");
60
+ }
61
+ const block = {
62
+ index: nextIndex++,
63
+ kind: "tool_use",
64
+ sentArguments: false,
65
+ argumentsJson: "",
66
+ };
67
+ blocks.set(outputIndex, block);
68
+ sawToolUse = true;
69
+ return [{
70
+ type: "content_block_start",
71
+ index: block.index,
72
+ content_block: {
73
+ type: "tool_use",
74
+ id: event.item.call_id,
75
+ name: event.item.name,
76
+ input: {},
77
+ },
78
+ }];
79
+ }
41
80
  if (event.type === "response.output_text.delta") {
81
+ const prefix = openTextBlock(outputIndex);
82
+ const block = blocks.get(outputIndex);
42
83
  return [
43
- ...ensureTextBlockStarted(),
84
+ ...prefix,
44
85
  {
45
86
  type: "content_block_delta",
46
- index: 0,
87
+ index: block?.index ?? 0,
47
88
  delta: { type: "text_delta", text: event.delta ?? "" },
48
89
  },
49
90
  ];
50
91
  }
92
+ if (event.type === "response.refusal.delta") {
93
+ sawRefusal = true;
94
+ const prefix = openTextBlock(outputIndex);
95
+ const block = blocks.get(outputIndex);
96
+ return [...prefix, {
97
+ type: "content_block_delta",
98
+ index: block?.index ?? 0,
99
+ delta: { type: "text_delta", text: event.delta ?? "" },
100
+ }];
101
+ }
102
+ if (event.type === "response.function_call_arguments.delta") {
103
+ const block = blocks.get(outputIndex);
104
+ if (!block || block.kind !== "tool_use")
105
+ return [];
106
+ block.sentArguments = true;
107
+ block.argumentsJson += event.delta ?? "";
108
+ return [{
109
+ type: "content_block_delta",
110
+ index: block.index,
111
+ delta: { type: "input_json_delta", partial_json: event.delta ?? "" },
112
+ }];
113
+ }
114
+ if (event.type === "response.function_call_arguments.done") {
115
+ const block = blocks.get(outputIndex);
116
+ if (!block || block.kind !== "tool_use" || block.sentArguments || !event.arguments)
117
+ return [];
118
+ block.sentArguments = true;
119
+ block.argumentsJson = event.arguments;
120
+ return [{
121
+ type: "content_block_delta",
122
+ index: block.index,
123
+ delta: { type: "input_json_delta", partial_json: event.arguments },
124
+ }];
125
+ }
126
+ if (event.type === "response.output_item.done") {
127
+ const block = blocks.get(outputIndex);
128
+ const atomicArguments = block?.kind === "tool_use" && !block.sentArguments
129
+ ? event.item?.arguments ?? ""
130
+ : "";
131
+ if (block?.kind === "tool_use") {
132
+ if (atomicArguments)
133
+ block.argumentsJson = atomicArguments;
134
+ parseOpenAIFunctionArguments(block.argumentsJson);
135
+ }
136
+ const argumentEvent = block?.kind === "tool_use" && atomicArguments
137
+ ? [{
138
+ type: "content_block_delta",
139
+ index: block.index,
140
+ delta: { type: "input_json_delta", partial_json: atomicArguments },
141
+ }]
142
+ : [];
143
+ return [...argumentEvent, ...closeBlock(outputIndex)];
144
+ }
51
145
  // Both terminal Responses events must close the Anthropic message.
52
146
  // Emitting nothing for `response.incomplete` would end the HTTP stream
53
147
  // without `message_stop`, leaving the client waiting on a turn that is
@@ -63,10 +157,16 @@ export function createOpenAIStreamToAnthropicNormalizer() {
63
157
  // a stream that never reached a terminal event looks like, and what
64
158
  // clients already detect and surface as an error.
65
159
  if (terminalResponsePayload(event) !== undefined) {
160
+ if ([...blocks.values()].some(block => block.kind === "tool_use")) {
161
+ throw new OpenAIProtocolError("OpenAI function call ended before completion");
162
+ }
66
163
  const usage = event.response?.usage ?? {};
67
- const prefix = textBlockStarted
68
- ? [{ type: "content_block_stop", index: 0 }]
69
- : [];
164
+ const prefix = [...blocks.keys()].flatMap(closeBlock);
165
+ const stopReason = sawToolUse
166
+ ? "tool_use"
167
+ : sawRefusal
168
+ ? "refusal"
169
+ : anthropicStopReasonForResponse(event.response);
70
170
  reset();
71
171
  return [
72
172
  ...prefix,
@@ -74,7 +174,7 @@ export function createOpenAIStreamToAnthropicNormalizer() {
74
174
  type: "message_delta",
75
175
  // Same helper the collected-response translator uses, so an
76
176
  // incomplete turn reports the same stop reason on both paths.
77
- delta: { stop_reason: anthropicStopReasonForResponse(event.response), stop_sequence: null },
177
+ delta: { stop_reason: stopReason, stop_sequence: null },
78
178
  usage: { output_tokens: usage.output_tokens ?? 0 },
79
179
  },
80
180
  { type: "message_stop" },
@@ -8,48 +8,73 @@ function parseArguments(args) {
8
8
  }
9
9
  }
10
10
  function textFromOpenAI(block) {
11
- if (block.type === "input_text" || block.type === "output_text")
12
- return block.text;
13
- return null;
11
+ return block.text;
14
12
  }
15
13
  function messageContentToAnthropic(message) {
16
- const blocks = message.content.map((block) => {
17
- const text = textFromOpenAI(block);
18
- if (text !== null)
19
- return { type: "text", text };
20
- if (block.type === "function_call") {
21
- return {
22
- type: "tool_use",
23
- id: block.call_id,
24
- name: block.name,
25
- input: parseArguments(block.arguments),
26
- };
27
- }
28
- if (block.type === "function_call_output") {
29
- return {
30
- type: "tool_result",
31
- tool_use_id: block.call_id,
32
- content: block.output,
33
- };
34
- }
35
- return null;
36
- }).filter((block) => block !== null);
37
- if (blocks.length === 1 && blocks[0].type === "text")
14
+ const blocks = message.content.map((block) => ({
15
+ type: "text",
16
+ text: textFromOpenAI(block),
17
+ }));
18
+ if (blocks.length === 1)
38
19
  return blocks[0].text;
39
20
  return blocks;
40
21
  }
41
22
  function normalizeRole(role) {
42
23
  return role === "assistant" ? "assistant" : "user";
43
24
  }
25
+ function isFunctionCall(item) {
26
+ return "type" in item && item.type === "function_call";
27
+ }
28
+ function isFunctionCallOutput(item) {
29
+ return "type" in item && item.type === "function_call_output";
30
+ }
31
+ function inputItemsToAnthropicMessages(input) {
32
+ const messages = [];
33
+ const append = (role, blocks) => {
34
+ if (blocks.length === 0)
35
+ return;
36
+ const last = messages.at(-1);
37
+ if (last?.role === role) {
38
+ const existing = typeof last.content === "string"
39
+ ? [{ type: "text", text: last.content }]
40
+ : last.content;
41
+ last.content = [...existing, ...blocks];
42
+ return;
43
+ }
44
+ messages.push({ role, content: blocks });
45
+ };
46
+ for (const item of input) {
47
+ if (isFunctionCall(item)) {
48
+ append("assistant", [{
49
+ type: "tool_use",
50
+ id: item.call_id,
51
+ name: item.name,
52
+ input: parseArguments(item.arguments),
53
+ }]);
54
+ }
55
+ else if (isFunctionCallOutput(item)) {
56
+ append("user", [{ type: "tool_result", tool_use_id: item.call_id, content: item.output }]);
57
+ }
58
+ else {
59
+ const content = messageContentToAnthropic(item);
60
+ append(normalizeRole(item.role), typeof content === "string"
61
+ ? [{ type: "text", text: content }]
62
+ : content);
63
+ }
64
+ }
65
+ return messages.map(message => {
66
+ if (Array.isArray(message.content) && message.content.length === 1 && message.content[0].type === "text") {
67
+ return { ...message, content: message.content[0].text };
68
+ }
69
+ return message;
70
+ });
71
+ }
44
72
  export function openAIResponsesToAnthropic(req) {
45
73
  const parsed = parseModelRef(req.model);
46
74
  return {
47
75
  model: parsed.upstreamModel,
48
76
  system: req.instructions,
49
- messages: req.input.map(message => ({
50
- role: normalizeRole(message.role),
51
- content: messageContentToAnthropic(message),
52
- })),
77
+ messages: inputItemsToAnthropicMessages(req.input),
53
78
  tools: req.tools?.map(tool => ({
54
79
  name: tool.name,
55
80
  description: tool.description,
@@ -132,8 +132,11 @@ export function applyCodexRateLimits(account, update, nowMs) {
132
132
  }
133
133
  if (update.credits)
134
134
  limits.credits = update.credits;
135
- if (update.buckets.length > 0 || update.credits)
135
+ if (update.resetCredits)
136
+ limits.resetCredits = update.resetCredits;
137
+ if (update.buckets.length > 0 || update.credits || update.resetCredits) {
136
138
  limits.lastUpdated = nowMs;
139
+ }
137
140
  }
138
141
  function normalizeModelSlug(model) {
139
142
  const normalized = model?.trim().toLowerCase();
@@ -246,7 +246,20 @@ export function parseCodexUsagePayload(value, nowMs) {
246
246
  };
247
247
  }
248
248
  }
249
- return { buckets, ...(credits ? { credits } : {}) };
249
+ let resetCredits;
250
+ const rawResetCredits = payload["rate_limit_reset_credits"];
251
+ if (typeof rawResetCredits === "object" && rawResetCredits !== null) {
252
+ const record = rawResetCredits;
253
+ const count = usageNumber(record["available_count"]) ?? usageNumber(record["available"]);
254
+ if (count !== undefined) {
255
+ resetCredits = { available: Math.max(0, Math.min(99, Math.floor(count))) };
256
+ }
257
+ }
258
+ return {
259
+ buckets,
260
+ ...(credits ? { credits } : {}),
261
+ ...(resetCredits ? { resetCredits } : {}),
262
+ };
250
263
  }
251
264
  export function resolveActiveLimit(headers) {
252
265
  const raw = headerString(headers, "x-codex-active-limit")?.trim();
@@ -0,0 +1,42 @@
1
+ export const XAI_PROVIDER = "xai_subscription";
2
+ export const XAI_DEFAULT_SCOPES = [
3
+ "openid",
4
+ "profile",
5
+ "email",
6
+ "offline_access",
7
+ "grok-cli:access",
8
+ "api:access",
9
+ ];
10
+ function parseExpiresAt(value) {
11
+ const parsed = typeof value === "number" ? value : Number(value);
12
+ if (!Number.isFinite(parsed) || parsed <= 0) {
13
+ throw new Error("expiresAt must be a positive Unix timestamp in milliseconds");
14
+ }
15
+ return parsed;
16
+ }
17
+ function parseScopes(value) {
18
+ if (Array.isArray(value))
19
+ return value.filter(Boolean);
20
+ if (typeof value === "string")
21
+ return value.split(/\s+/).filter(Boolean);
22
+ return [...XAI_DEFAULT_SCOPES];
23
+ }
24
+ export function createXaiAccountRecord(input) {
25
+ const id = input.id.trim();
26
+ if (!/^[a-zA-Z0-9_-]+$/.test(id)) {
27
+ throw new Error("Only letters, numbers, _ and - allowed in account ID");
28
+ }
29
+ if (!input.accessToken.trim())
30
+ throw new Error("Access token is required");
31
+ if (!input.refreshToken.trim())
32
+ throw new Error("Refresh token is required");
33
+ return {
34
+ id,
35
+ provider: XAI_PROVIDER,
36
+ accessToken: input.accessToken.trim(),
37
+ refreshToken: input.refreshToken.trim(),
38
+ expiresAt: parseExpiresAt(input.expiresAt),
39
+ scopes: parseScopes(input.scopes),
40
+ enabled: input.enabled ?? true,
41
+ };
42
+ }