File size: 5,058 Bytes
cd8bd0a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
/**
 * #1934: import OAuth credentials saved by CLIProxyAPI (router-for-me/CLIProxyAPI)
 * from its config dir (`~/.cli-proxy-api/`) so users don't have to re-login every
 * account individually.
 *
 * CLIProxyAPI stores one JSON file per account in a **unified format** with a `type`
 * discriminator identifying the provider, plus the OAuth tokens and account metadata.
 * This module is the pure parse/normalize/scan layer; the connection write reuses the
 * existing `createProviderConnection` upsert path. The actual DB write + filesystem scan
 * are invoked from the local-only API route.
 */

import { promises as fs } from "fs";
import path from "path";

type JsonRecord = Record<string, unknown>;

/**
 * CLIProxyAPI `type` → OmniRoute provider id. Only OAuth-based providers OmniRoute
 * supports are mapped; unknown types are skipped during import.
 */
export const CLIPROXY_TYPE_TO_PROVIDER: Record<string, string> = {
  anthropic: "claude",
  claude: "claude",
  codex: "codex",
  antigravity: "antigravity",
  gemini: "gemini-cli",
  "gemini-cli": "gemini-cli",
  qwen: "qwen",
  kimi: "kimi",
};

export interface ParsedCliProxyAuth {
  provider: string;
  type: string;
  email: string | null;
  accessToken: string;
  refreshToken: string | null;
  expiresAt: string | null;
  projectId: string | null;
}

function asString(value: unknown): string | null {
  return typeof value === "string" && value.trim() ? value.trim() : null;
}

/**
 * Normalize CLIProxyAPI's expiry metadata to an ISO timestamp.
 * Accepts `expired` (absolute RFC3339 string or unix seconds/ms) or `expires_in`
 * (relative seconds from `now`). Returns null when neither is usable.
 */
export function resolveCliProxyExpiry(record: JsonRecord, now: number): string | null {
  const expired = record.expired;
  if (typeof expired === "string" && expired.trim()) {
    const ms = Date.parse(expired);
    if (Number.isFinite(ms)) return new Date(ms).toISOString();
  }
  if (typeof expired === "number" && Number.isFinite(expired) && expired > 0) {
    // Heuristic: < 1e12 ⇒ unix seconds, otherwise milliseconds.
    const ms = expired < 1e12 ? expired * 1000 : expired;
    return new Date(ms).toISOString();
  }
  const expiresIn = record.expires_in;
  if (typeof expiresIn === "number" && Number.isFinite(expiresIn) && expiresIn > 0) {
    return new Date(now + expiresIn * 1000).toISOString();
  }
  return null;
}

/**
 * Parse one CLIProxyAPI auth-file object into a normalized record. Returns null when
 * the file is not a supported OAuth credential (unknown `type`, or no access token).
 */
export function parseCliProxyAuthRecord(
  raw: unknown,
  now: number = 0
): ParsedCliProxyAuth | null {
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
  const record = raw as JsonRecord;
  const type = asString(record.type)?.toLowerCase();
  if (!type) return null;
  const provider = CLIPROXY_TYPE_TO_PROVIDER[type];
  if (!provider) return null;
  const accessToken = asString(record.access_token);
  if (!accessToken) return null;
  return {
    provider,
    type,
    email: asString(record.email),
    accessToken,
    refreshToken: asString(record.refresh_token),
    expiresAt: resolveCliProxyExpiry(record, now),
    projectId: asString(record.project_id) ?? asString(record.projectId),
  };
}

/**
 * Map a parsed record to the `createProviderConnection` payload shape.
 */
export function toConnectionPayload(parsed: ParsedCliProxyAuth): JsonRecord {
  return {
    provider: parsed.provider,
    authType: "oauth",
    email: parsed.email ?? undefined,
    name: parsed.email || `${parsed.provider} (CLIProxyAPI import)`,
    accessToken: parsed.accessToken,
    refreshToken: parsed.refreshToken ?? undefined,
    expiresAt: parsed.expiresAt ?? undefined,
    testStatus: "active",
    providerSpecificData: {
      ...(parsed.projectId ? { projectId: parsed.projectId } : {}),
      importedFrom: "cliproxyapi",
      importedAt: new Date().toISOString(),
    },
  };
}

/**
 * Scan a CLIProxyAPI config directory for importable auth files. Reads every `*.json`,
 * parses it, and returns the importable candidates plus a count of skipped files.
 * Filesystem/parse errors on an individual file are skipped, never thrown.
 */
export async function scanCliProxyAuthDir(
  dir: string,
  now: number = 0
): Promise<{ candidates: ParsedCliProxyAuth[]; skipped: number; scanned: number }> {
  let entries: string[];
  try {
    entries = await fs.readdir(dir);
  } catch {
    return { candidates: [], skipped: 0, scanned: 0 };
  }
  const jsonFiles = entries.filter((f) => f.toLowerCase().endsWith(".json"));
  const candidates: ParsedCliProxyAuth[] = [];
  let skipped = 0;
  for (const file of jsonFiles) {
    try {
      const text = await fs.readFile(path.join(dir, file), "utf8");
      const parsed = parseCliProxyAuthRecord(JSON.parse(text), now);
      if (parsed) candidates.push(parsed);
      else skipped++;
    } catch {
      skipped++;
    }
  }
  return { candidates, skipped, scanned: jsonFiles.length };
}