copillm 0.1.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.
Files changed (38) hide show
  1. package/README.md +52 -0
  2. package/dist/agentconfig/apply.js +53 -0
  3. package/dist/agentconfig/load.js +163 -0
  4. package/dist/agentconfig/markerBlock.js +76 -0
  5. package/dist/agentconfig/render.js +317 -0
  6. package/dist/agentconfig/schema.js +65 -0
  7. package/dist/auth/copilotToken.js +122 -0
  8. package/dist/auth/credentials.js +221 -0
  9. package/dist/auth/deviceFlow.js +89 -0
  10. package/dist/auth/ensureAuthenticated.js +55 -0
  11. package/dist/auth/githubIdentity.js +42 -0
  12. package/dist/auth/interactivePrompt.js +135 -0
  13. package/dist/claude/cache.js +20 -0
  14. package/dist/claude/settingsConflict.js +85 -0
  15. package/dist/cli/agentEnv.js +56 -0
  16. package/dist/cli/configCommands.js +149 -0
  17. package/dist/cli/envBlock.js +43 -0
  18. package/dist/cli/launchAgent.js +59 -0
  19. package/dist/cli/resolveAgent.js +361 -0
  20. package/dist/cli.js +1178 -0
  21. package/dist/codex/init.js +93 -0
  22. package/dist/config/config.js +51 -0
  23. package/dist/config/fsSecurity.js +39 -0
  24. package/dist/config/home.js +62 -0
  25. package/dist/config/logging.js +33 -0
  26. package/dist/config/upstream.js +38 -0
  27. package/dist/models/anthropicDefaults.js +138 -0
  28. package/dist/models/discovery.js +208 -0
  29. package/dist/pi/init.js +174 -0
  30. package/dist/server/anthropicModelsResponse.js +151 -0
  31. package/dist/server/codexSchema.js +100 -0
  32. package/dist/server/debugInfo.js +48 -0
  33. package/dist/server/lock.js +150 -0
  34. package/dist/server/proxy.js +715 -0
  35. package/dist/translation/openaiAnthropic.js +391 -0
  36. package/dist/translation/streamingOpenAIToAnthropic.js +290 -0
  37. package/dist/types/index.js +1 -0
  38. package/package.json +50 -0
@@ -0,0 +1,93 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { CopilotTokenManager } from "../auth/copilotToken.js";
4
+ import { loadStoredCredential } from "../auth/credentials.js";
5
+ import { loadConfig } from "../config/config.js";
6
+ import { listModelsUnion } from "../models/discovery.js";
7
+ import { ensureSecureDirectory, writeFileSecureAtomic } from "../config/fsSecurity.js";
8
+ import { buildCodexCatalog } from "../server/codexSchema.js";
9
+ import { inspectLock } from "../server/lock.js";
10
+ export async function generateCodexHome(options) {
11
+ const config = loadConfig();
12
+ const creds = await loadStoredCredential();
13
+ if (!creds) {
14
+ throw new Error("Not authenticated. Run `copillm login` first.");
15
+ }
16
+ const tokenManager = new CopilotTokenManager(creds.token);
17
+ await tokenManager.ensureToken(false);
18
+ const discovery = await listModelsUnion(config.accountType, creds.token, 3);
19
+ const catalog = buildCodexCatalog(discovery.models);
20
+ if (catalog.models.length === 0) {
21
+ throw new Error("No Codex-eligible models found in the live catalog.");
22
+ }
23
+ const port = options.port;
24
+ const proxyUrl = `http://127.0.0.1:${port}/codex/v1`;
25
+ const baseUrl = proxyUrl;
26
+ const defaultModel = options.model ?? pickDefaultModel(catalog.models.map((model) => model.slug));
27
+ if (!catalog.models.some((model) => model.slug === defaultModel)) {
28
+ throw new Error(`Requested model "${defaultModel}" is not in the eligible catalog. Available: ${catalog.models.map((model) => model.slug).join(", ")}`);
29
+ }
30
+ const absOutDir = path.resolve(options.outDir);
31
+ ensureSecureDirectory(absOutDir);
32
+ const configPath = path.join(absOutDir, "config.toml");
33
+ const reasoningEffort = options.reasoningEffort ?? "medium";
34
+ const tomlBody = renderConfigToml({
35
+ model: defaultModel,
36
+ reasoningEffort,
37
+ providerId: options.providerId,
38
+ baseUrl
39
+ });
40
+ writeFileSecureAtomic(configPath, tomlBody, 0o600);
41
+ return {
42
+ outDir: absOutDir,
43
+ configPath,
44
+ modelCount: catalog.models.length,
45
+ defaultModel,
46
+ proxyUrl,
47
+ exportCommand: `CODEX_HOME=${absOutDir} codex`
48
+ };
49
+ }
50
+ function pickDefaultModel(slugs) {
51
+ const preferred = ["gpt-5.3-codex", "gpt-5.2-codex", "gpt-5.4", "gpt-5.2", "claude-opus-4.5", "claude-sonnet-4.6"];
52
+ for (const candidate of preferred) {
53
+ if (slugs.includes(candidate)) {
54
+ return candidate;
55
+ }
56
+ }
57
+ return slugs[0];
58
+ }
59
+ function renderConfigToml(input) {
60
+ return [
61
+ `# Generated by \`copillm start\` on ${new Date().toISOString()}`,
62
+ `# Use with: CODEX_HOME=<this directory> codex`,
63
+ ``,
64
+ `model = "${input.model}"`,
65
+ `model_provider = "${input.providerId}"`,
66
+ `model_reasoning_effort = "${input.reasoningEffort}"`,
67
+ `approvals_reviewer = "user"`,
68
+ ``,
69
+ `[model_providers.${input.providerId}]`,
70
+ `name = "copillm"`,
71
+ `base_url = "${input.baseUrl}"`,
72
+ `wire_api = "responses"`,
73
+ `requires_openai_auth = false`,
74
+ ``
75
+ ].join("\n");
76
+ }
77
+ export function defaultOutputDir(home) {
78
+ return path.join(home, "codex");
79
+ }
80
+ export function listExistingCodexHomes(home) {
81
+ const dir = path.join(home, "codex");
82
+ if (!fs.existsSync(dir)) {
83
+ return [];
84
+ }
85
+ return [dir];
86
+ }
87
+ export function proxyPortFromLock() {
88
+ const inspection = inspectLock();
89
+ if (inspection.state === "running") {
90
+ return inspection.lock.port;
91
+ }
92
+ return null;
93
+ }
@@ -0,0 +1,51 @@
1
+ import fs from "node:fs";
2
+ import { readFileSync } from "node:fs";
3
+ import YAML from "yaml";
4
+ import { z } from "zod";
5
+ import { configPath, configReadPath, getCopillmHome } from "./home.js";
6
+ import { ensureSecureDirectory, writeFileSecureAtomic } from "./fsSecurity.js";
7
+ const ConfigSchema = z.object({
8
+ preferredPort: z.number().int().min(1).max(65535).default(4141),
9
+ requireCallerSecret: z.boolean().default(false),
10
+ selectedModels: z.array(z.string()).default([]),
11
+ accountType: z.enum(["individual", "business", "enterprise"]).default("individual")
12
+ });
13
+ const DEFAULT_CONFIG = {
14
+ preferredPort: 4141,
15
+ requireCallerSecret: false,
16
+ selectedModels: [],
17
+ accountType: "individual"
18
+ };
19
+ export function ensureAppHome() {
20
+ ensureSecureDirectory(getCopillmHome());
21
+ }
22
+ export function loadConfig() {
23
+ const file = configReadPath();
24
+ if (!fs.existsSync(file)) {
25
+ saveConfig(DEFAULT_CONFIG);
26
+ return DEFAULT_CONFIG;
27
+ }
28
+ const raw = readFileSync(file, "utf8");
29
+ let parsed;
30
+ try {
31
+ parsed = YAML.parse(raw);
32
+ }
33
+ catch (error) {
34
+ throw new Error(`Invalid YAML in config file: ${file}`, { cause: error });
35
+ }
36
+ return parseConfigValue(parsed, file);
37
+ }
38
+ export function saveConfig(config) {
39
+ ensureAppHome();
40
+ const normalized = parseConfigValue(config, "runtime");
41
+ writeFileSecureAtomic(configPath(), YAML.stringify(normalized), 0o600);
42
+ }
43
+ function parseConfigValue(value, source) {
44
+ const candidate = value ?? {};
45
+ const result = ConfigSchema.safeParse(candidate);
46
+ if (result.success) {
47
+ return result.data;
48
+ }
49
+ const issues = result.error.issues.map((issue) => `${issue.path.join(".") || "<root>"}: ${issue.message}`).join("; ");
50
+ throw new Error(`Invalid config schema in ${source}: ${issues}`);
51
+ }
@@ -0,0 +1,39 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ export function ensureSecureDirectory(dirPath) {
4
+ if (!fs.existsSync(dirPath)) {
5
+ fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 });
6
+ }
7
+ applyModeIfSupported(dirPath, 0o700);
8
+ }
9
+ export function writeFileSecureAtomic(filePath, content, mode) {
10
+ ensureSecureDirectory(path.dirname(filePath));
11
+ const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
12
+ fs.writeFileSync(tempPath, content, { mode });
13
+ applyModeIfSupported(tempPath, mode);
14
+ replaceFile(tempPath, filePath);
15
+ applyModeIfSupported(filePath, mode);
16
+ }
17
+ export function applyModeIfSupported(targetPath, mode) {
18
+ if (process.platform === "win32") {
19
+ return;
20
+ }
21
+ try {
22
+ fs.chmodSync(targetPath, mode);
23
+ }
24
+ catch (error) {
25
+ if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
26
+ throw error;
27
+ }
28
+ }
29
+ }
30
+ function replaceFile(sourcePath, destinationPath) {
31
+ if (process.platform !== "win32") {
32
+ fs.renameSync(sourcePath, destinationPath);
33
+ return;
34
+ }
35
+ if (fs.existsSync(destinationPath)) {
36
+ fs.unlinkSync(destinationPath);
37
+ }
38
+ fs.renameSync(sourcePath, destinationPath);
39
+ }
@@ -0,0 +1,62 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ export function getCopillmHome() {
5
+ const overridden = process.env.COPILLM_HOME;
6
+ if (overridden && overridden.trim().length > 0) {
7
+ return path.resolve(overridden.trim());
8
+ }
9
+ return path.join(os.homedir(), ".copillm");
10
+ }
11
+ export function configPath() {
12
+ return path.join(getCopillmHome(), "config.yaml");
13
+ }
14
+ export function configReadPath() {
15
+ return resolveReadablePath("config.yaml");
16
+ }
17
+ export function credentialsPath() {
18
+ return path.join(getCopillmHome(), "credentials.json");
19
+ }
20
+ export function credentialsReadPath() {
21
+ return resolveReadablePath("credentials.json");
22
+ }
23
+ export function lockPath() {
24
+ return path.join(getCopillmHome(), "copillm.pid");
25
+ }
26
+ export function lockReadPath() {
27
+ return resolveReadablePath("copillm.pid");
28
+ }
29
+ export function modelsCachePath() {
30
+ return path.join(getCopillmHome(), "models.cache.json");
31
+ }
32
+ export function modelsCacheReadPath() {
33
+ return resolveReadablePath("models.cache.json");
34
+ }
35
+ function resolveReadablePath(fileName) {
36
+ const canonical = path.join(getCopillmHome(), fileName);
37
+ if (fs.existsSync(canonical)) {
38
+ return canonical;
39
+ }
40
+ if (!process.env.COPILLM_HOME) {
41
+ const legacy = legacyHome();
42
+ if (legacy) {
43
+ const fallback = path.join(legacy, fileName);
44
+ if (fs.existsSync(fallback)) {
45
+ return fallback;
46
+ }
47
+ }
48
+ }
49
+ return canonical;
50
+ }
51
+ function legacyHome() {
52
+ if (process.platform === "darwin") {
53
+ return path.join(os.homedir(), "Library", "Application Support", "copillm");
54
+ }
55
+ if (process.platform === "win32") {
56
+ const appData = process.env.APPDATA;
57
+ if (appData && appData.trim().length > 0) {
58
+ return path.join(appData, "copillm");
59
+ }
60
+ }
61
+ return null;
62
+ }
@@ -0,0 +1,33 @@
1
+ import pino from "pino";
2
+ export function createLogger() {
3
+ return pino({
4
+ level: process.env.COPILLM_LOG_LEVEL ?? "info",
5
+ redact: {
6
+ paths: [
7
+ "req.headers.authorization",
8
+ "req.headers.cookie",
9
+ "headers.cookie",
10
+ "headers.authorization",
11
+ "*.headers.authorization",
12
+ "*.headers.cookie",
13
+ "authorization",
14
+ "access_token",
15
+ "refresh_token",
16
+ "bearer",
17
+ "token",
18
+ "githubToken",
19
+ "github_token",
20
+ "responseBodySnippet",
21
+ "body",
22
+ "req.body",
23
+ "response.body",
24
+ "body.messages",
25
+ "body.input"
26
+ ],
27
+ remove: true
28
+ },
29
+ transport: process.env.COPILLM_LOG_PRETTY === "1"
30
+ ? { target: "pino-pretty", options: { colorize: true } }
31
+ : undefined
32
+ });
33
+ }
@@ -0,0 +1,38 @@
1
+ const PROD_COPILOT_BASE_URLS = {
2
+ individual: "https://api.githubcopilot.com",
3
+ business: "https://api.business.githubcopilot.com",
4
+ enterprise: "https://api.enterprise.githubcopilot.com"
5
+ };
6
+ const PROD_TOKEN_EXCHANGE_URL = "https://api.github.com/copilot_internal/v2/token";
7
+ const PROD_GITHUB_USER_URL = "https://api.github.com/user";
8
+ export function copilotBaseUrl(accountType) {
9
+ const override = readEnv("COPILLM_UPSTREAM_BASE_URL");
10
+ if (override) {
11
+ return stripTrailingSlash(override);
12
+ }
13
+ return PROD_COPILOT_BASE_URLS[accountType];
14
+ }
15
+ export function tokenExchangeUrl() {
16
+ const override = readEnv("COPILLM_TOKEN_EXCHANGE_URL");
17
+ if (override) {
18
+ return override;
19
+ }
20
+ return PROD_TOKEN_EXCHANGE_URL;
21
+ }
22
+ export function githubUserUrl() {
23
+ const override = readEnv("COPILLM_GITHUB_USER_URL");
24
+ if (override) {
25
+ return override;
26
+ }
27
+ return PROD_GITHUB_USER_URL;
28
+ }
29
+ function readEnv(name) {
30
+ const value = process.env[name];
31
+ if (typeof value !== "string" || value.trim().length === 0) {
32
+ return null;
33
+ }
34
+ return value.trim();
35
+ }
36
+ function stripTrailingSlash(value) {
37
+ return value.endsWith("/") ? value.slice(0, -1) : value;
38
+ }
@@ -0,0 +1,138 @@
1
+ import fs from "node:fs";
2
+ import { z } from "zod";
3
+ import { modelsCacheReadPath } from "../config/home.js";
4
+ export const ANTHROPIC_FAMILIES = ["opus", "sonnet", "haiku"];
5
+ const SUFFIX_BLOCKLIST = [
6
+ "-high",
7
+ "-xhigh",
8
+ "-low",
9
+ "-min",
10
+ "-1m",
11
+ "-internal",
12
+ "-preview",
13
+ "-beta",
14
+ "-experimental",
15
+ "-canary"
16
+ ];
17
+ const CachedSchema = z.object({
18
+ models: z.array(z.object({ id: z.string() }).passthrough())
19
+ });
20
+ export function computeAnthropicDefaults(modelIds) {
21
+ const byFamily = { opus: [], sonnet: [], haiku: [] };
22
+ for (const id of modelIds) {
23
+ const family = detectFamily(id);
24
+ if (family) {
25
+ byFamily[family].push(id);
26
+ }
27
+ }
28
+ return {
29
+ opus: pickPlainLatest(byFamily.opus),
30
+ sonnet: pickPlainLatest(byFamily.sonnet),
31
+ haiku: pickPlainLatest(byFamily.haiku)
32
+ };
33
+ }
34
+ export function readModelIdsFromCache() {
35
+ const file = modelsCacheReadPath();
36
+ if (!fs.existsSync(file)) {
37
+ return [];
38
+ }
39
+ try {
40
+ const raw = JSON.parse(fs.readFileSync(file, "utf8"));
41
+ const parsed = CachedSchema.safeParse(raw);
42
+ if (!parsed.success) {
43
+ return [];
44
+ }
45
+ return parsed.data.models.map((model) => model.id);
46
+ }
47
+ catch {
48
+ return [];
49
+ }
50
+ }
51
+ export function buildClaudeExportCommand(input) {
52
+ const token = input.callerSecret ?? "copillm-local";
53
+ const parts = [
54
+ `ANTHROPIC_BASE_URL=http://127.0.0.1:${input.port}/anthropic`,
55
+ `ANTHROPIC_AUTH_TOKEN=${token}`
56
+ ];
57
+ if (input.defaults.opus) {
58
+ parts.push(`ANTHROPIC_DEFAULT_OPUS_MODEL=${input.defaults.opus}`);
59
+ }
60
+ if (input.defaults.sonnet) {
61
+ parts.push(`ANTHROPIC_DEFAULT_SONNET_MODEL=${input.defaults.sonnet}`);
62
+ }
63
+ if (input.defaults.haiku) {
64
+ parts.push(`ANTHROPIC_DEFAULT_HAIKU_MODEL=${input.defaults.haiku}`);
65
+ }
66
+ if (input.enableGatewayDiscovery) {
67
+ parts.push(`CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1`);
68
+ }
69
+ parts.push(`claude`);
70
+ return parts.join(" ");
71
+ }
72
+ function detectFamily(modelId) {
73
+ if (typeof modelId !== "string" || modelId.length === 0) {
74
+ return null;
75
+ }
76
+ const lower = modelId.toLowerCase();
77
+ if (!lower.startsWith("claude")) {
78
+ return null;
79
+ }
80
+ if (lower.includes("opus")) {
81
+ return "opus";
82
+ }
83
+ if (lower.includes("sonnet")) {
84
+ return "sonnet";
85
+ }
86
+ if (lower.includes("haiku")) {
87
+ return "haiku";
88
+ }
89
+ return null;
90
+ }
91
+ function pickPlainLatest(ids) {
92
+ if (ids.length === 0) {
93
+ return null;
94
+ }
95
+ const plain = ids.filter((id) => !hasBlockedSuffix(id));
96
+ const candidates = plain.length > 0 ? plain.slice() : ids.slice();
97
+ candidates.sort((a, b) => compareVersionDescending(a, b));
98
+ return candidates[0];
99
+ }
100
+ function hasBlockedSuffix(modelId) {
101
+ const lower = modelId.toLowerCase();
102
+ return SUFFIX_BLOCKLIST.some((suffix) => lower.includes(suffix));
103
+ }
104
+ function compareVersionDescending(a, b) {
105
+ const va = parseVersionInfo(a);
106
+ const vb = parseVersionInfo(b);
107
+ const length = Math.max(va.version.length, vb.version.length);
108
+ for (let i = 0; i < length; i += 1) {
109
+ const left = va.version[i] ?? 0;
110
+ const right = vb.version[i] ?? 0;
111
+ if (left !== right) {
112
+ return right - left;
113
+ }
114
+ }
115
+ if (va.date !== vb.date) {
116
+ return vb.date - va.date;
117
+ }
118
+ return b.localeCompare(a);
119
+ }
120
+ function parseVersionInfo(modelId) {
121
+ let stripped = modelId;
122
+ let date = 0;
123
+ const dateMatch = modelId.match(/-(\d{4})-?(\d{2})-?(\d{2})$/);
124
+ if (dateMatch && typeof dateMatch.index === "number") {
125
+ date = Number.parseInt(`${dateMatch[1]}${dateMatch[2]}${dateMatch[3]}`, 10);
126
+ stripped = modelId.slice(0, dateMatch.index);
127
+ }
128
+ const versionMatches = stripped.match(/(\d+(?:[.\-_]\d+)*)/g);
129
+ if (!versionMatches || versionMatches.length === 0) {
130
+ return { version: [], date };
131
+ }
132
+ const last = versionMatches[versionMatches.length - 1];
133
+ const version = last.split(/[.\-_]/).map((part) => {
134
+ const parsed = Number.parseInt(part, 10);
135
+ return Number.isNaN(parsed) ? 0 : parsed;
136
+ });
137
+ return { version, date };
138
+ }
@@ -0,0 +1,208 @@
1
+ import fs from "node:fs";
2
+ import { z } from "zod";
3
+ import { modelsCachePath, modelsCacheReadPath } from "../config/home.js";
4
+ import { writeFileSecureAtomic } from "../config/fsSecurity.js";
5
+ import { copilotBaseUrl } from "../config/upstream.js";
6
+ const ModelSchema = z
7
+ .object({
8
+ id: z.string().min(1)
9
+ })
10
+ .passthrough();
11
+ const ModelsCacheSchema = z.object({
12
+ version: z.literal(1),
13
+ accountType: z.enum(["individual", "business", "enterprise"]),
14
+ savedAtIso: z.string(),
15
+ models: z.array(ModelSchema)
16
+ });
17
+ const MODEL_RESOLUTION_RULES = [
18
+ { id: "exact", normalize: (value) => value },
19
+ { id: "case-insensitive", normalize: (value) => value.toLowerCase() },
20
+ { id: "separator-normalized", normalize: (value) => normalizeModelId(value) },
21
+ { id: "snapshot-trimmed", normalize: (value) => trimDateSnapshot(normalizeModelId(value)) }
22
+ ];
23
+ export function accountBaseUrl(accountType) {
24
+ return copilotBaseUrl(accountType);
25
+ }
26
+ export async function listModels(accountType, bearerToken) {
27
+ try {
28
+ const response = await fetch(`${accountBaseUrl(accountType)}/models`, {
29
+ method: "GET",
30
+ headers: {
31
+ Authorization: `Bearer ${bearerToken}`,
32
+ "Content-Type": "application/json",
33
+ "User-Agent": "copillm/0.1.0"
34
+ }
35
+ });
36
+ if (!response.ok) {
37
+ throw new ModelDiscoveryHttpError(response.status);
38
+ }
39
+ const payload = (await response.json());
40
+ const candidateModels = extractModelArray(payload);
41
+ const parsed = z.array(ModelSchema).safeParse(candidateModels);
42
+ if (!parsed.success) {
43
+ throw new Error("Model discovery response is invalid.");
44
+ }
45
+ saveModelCache(accountType, parsed.data);
46
+ return {
47
+ models: parsed.data,
48
+ source: "live",
49
+ stale: false,
50
+ cacheAgeSeconds: 0,
51
+ warning: null
52
+ };
53
+ }
54
+ catch (error) {
55
+ if (!canUseCacheFallback(error)) {
56
+ throw error;
57
+ }
58
+ const cached = readModelCache(accountType);
59
+ if (!cached) {
60
+ const detail = error instanceof Error ? error.message : "unknown error";
61
+ throw new Error(`Model discovery failed and no cache snapshot is available: ${detail}`);
62
+ }
63
+ return {
64
+ models: cached.models,
65
+ source: "cache",
66
+ stale: true,
67
+ cacheAgeSeconds: Math.max(0, Math.floor((Date.now() - Date.parse(cached.savedAtIso)) / 1_000)),
68
+ warning: "Using stale model snapshot because upstream discovery is unreachable."
69
+ };
70
+ }
71
+ }
72
+ export async function listModelsUnion(accountType, bearerToken, attempts = 3) {
73
+ const seen = new Map();
74
+ let lastResult = null;
75
+ let lastError;
76
+ for (let i = 0; i < attempts; i += 1) {
77
+ try {
78
+ const result = await listModels(accountType, bearerToken);
79
+ lastResult = result;
80
+ for (const model of result.models) {
81
+ if (typeof model.id === "string" && !seen.has(model.id)) {
82
+ seen.set(model.id, model);
83
+ }
84
+ }
85
+ }
86
+ catch (error) {
87
+ lastError = error;
88
+ }
89
+ }
90
+ if (lastResult === null) {
91
+ throw lastError ?? new Error("Model discovery failed across all attempts.");
92
+ }
93
+ return {
94
+ ...lastResult,
95
+ models: Array.from(seen.values())
96
+ };
97
+ }
98
+ function extractModelArray(payload) {
99
+ if (Array.isArray(payload)) {
100
+ return payload;
101
+ }
102
+ if (!payload || typeof payload !== "object") {
103
+ return payload;
104
+ }
105
+ const candidate = payload;
106
+ if (Array.isArray(candidate.data)) {
107
+ return candidate.data;
108
+ }
109
+ if (Array.isArray(candidate.models)) {
110
+ return candidate.models;
111
+ }
112
+ if (Array.isArray(candidate.value)) {
113
+ return candidate.value;
114
+ }
115
+ if (Array.isArray(candidate.available_models)) {
116
+ return candidate.available_models;
117
+ }
118
+ if (candidate.data && typeof candidate.data === "object") {
119
+ const nested = candidate.data;
120
+ if (Array.isArray(nested.models)) {
121
+ return nested.models;
122
+ }
123
+ if (Array.isArray(nested.value)) {
124
+ return nested.value;
125
+ }
126
+ }
127
+ return payload;
128
+ }
129
+ export function resolveModelId(inputModelId, availableModelIds) {
130
+ const direct = availableModelIds.find((id) => id === inputModelId);
131
+ if (direct) {
132
+ return { id: direct, rule: "exact" };
133
+ }
134
+ for (const rule of MODEL_RESOLUTION_RULES.slice(1)) {
135
+ const normalizedInput = rule.normalize(inputModelId);
136
+ const matches = availableModelIds.filter((candidate) => rule.normalize(candidate) === normalizedInput);
137
+ if (matches.length === 1) {
138
+ return { id: matches[0], rule: rule.id };
139
+ }
140
+ if (matches.length > 1) {
141
+ throw new Error(`Model "${inputModelId}" is ambiguous under "${rule.id}" rule: ${matches.join(", ")}. Select an exact model id.`);
142
+ }
143
+ }
144
+ return null;
145
+ }
146
+ export function resolveModelSelections(requestedModelIds, models) {
147
+ const availableModelIds = models.map((model) => model.id);
148
+ const resolved = [];
149
+ const unresolved = [];
150
+ for (const input of requestedModelIds) {
151
+ const match = resolveModelId(input, availableModelIds);
152
+ if (!match) {
153
+ unresolved.push(input);
154
+ continue;
155
+ }
156
+ resolved.push({ input, resolvedId: match.id, rule: match.rule });
157
+ }
158
+ return { resolved, unresolved };
159
+ }
160
+ class ModelDiscoveryHttpError extends Error {
161
+ status;
162
+ constructor(status) {
163
+ super(`Model discovery failed (${status}).`);
164
+ this.status = status;
165
+ }
166
+ }
167
+ function canUseCacheFallback(error) {
168
+ if (error instanceof ModelDiscoveryHttpError) {
169
+ return error.status === 429 || error.status >= 500;
170
+ }
171
+ return true;
172
+ }
173
+ function saveModelCache(accountType, models) {
174
+ const payload = {
175
+ version: 1,
176
+ accountType,
177
+ savedAtIso: new Date().toISOString(),
178
+ models
179
+ };
180
+ writeFileSecureAtomic(modelsCachePath(), JSON.stringify(payload, null, 2), 0o600);
181
+ }
182
+ function readModelCache(accountType) {
183
+ const filePath = modelsCacheReadPath();
184
+ if (!fs.existsSync(filePath)) {
185
+ return null;
186
+ }
187
+ let raw;
188
+ try {
189
+ raw = JSON.parse(fs.readFileSync(filePath, "utf8"));
190
+ }
191
+ catch {
192
+ return null;
193
+ }
194
+ const parsed = ModelsCacheSchema.safeParse(raw);
195
+ if (!parsed.success || parsed.data.accountType !== accountType) {
196
+ return null;
197
+ }
198
+ return {
199
+ savedAtIso: parsed.data.savedAtIso,
200
+ models: parsed.data.models
201
+ };
202
+ }
203
+ function normalizeModelId(value) {
204
+ return value.trim().toLowerCase().replace(/[\s._]+/g, "-");
205
+ }
206
+ function trimDateSnapshot(value) {
207
+ return value.replace(/-\d{4}-\d{2}-\d{2}$/, "");
208
+ }