@jacobbd/relay-ai 0.2.7 → 0.2.8

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/dist/cli.js CHANGED
@@ -31,6 +31,94 @@ import { wrapLanguageModel, extractReasoningMiddleware } from "ai";
31
31
  // src/constants.ts
32
32
  import { homedir } from "os";
33
33
  import { join } from "path";
34
+
35
+ // package.json
36
+ var package_default = {
37
+ name: "@jacobbd/relay-ai",
38
+ version: "0.2.8",
39
+ publishConfig: {
40
+ access: "public"
41
+ },
42
+ description: "Relay any model into any coding agent \u2014 launch Claude Code, Codex, and more with multi-provider gateways",
43
+ author: "jacob-bd",
44
+ license: "MIT",
45
+ repository: {
46
+ type: "git",
47
+ url: "git+https://github.com/jacob-bd/relay-ai.git"
48
+ },
49
+ homepage: "https://github.com/jacob-bd/relay-ai#readme",
50
+ keywords: [
51
+ "claude",
52
+ "claude-code",
53
+ "codex",
54
+ "ai",
55
+ "llm",
56
+ "cli",
57
+ "gateway",
58
+ "relay",
59
+ "vertex"
60
+ ],
61
+ type: "module",
62
+ bin: {
63
+ "relay-ai": "dist/cli.js"
64
+ },
65
+ engines: {
66
+ node: ">=18"
67
+ },
68
+ scripts: {
69
+ build: "tsup",
70
+ dev: "tsup --watch",
71
+ test: "vitest run",
72
+ "test:watch": "vitest",
73
+ typecheck: "tsc --noEmit",
74
+ "refresh:models-dev": "node scripts/refresh-models-dev-cache.mjs",
75
+ prepublishOnly: `node -e "if (require('./package.json').version !== require('./package-lock.json').version) { console.error('Error: package.json and package-lock.json versions are out of sync! Run npm install to sync.'); process.exit(1); }" && npm run build`
76
+ },
77
+ dependencies: {
78
+ "@ai-sdk/alibaba": "^1.0.26",
79
+ "@ai-sdk/amazon-bedrock": "^4.0.113",
80
+ "@ai-sdk/azure": "^3.0.70",
81
+ "@ai-sdk/cerebras": "^2.0.54",
82
+ "@ai-sdk/cohere": "^3.0.36",
83
+ "@ai-sdk/deepinfra": "^2.0.52",
84
+ "@ai-sdk/gateway": "^3.0.125",
85
+ "@ai-sdk/google": "^3.0.80",
86
+ "@ai-sdk/google-vertex": "^4.0.142",
87
+ "@ai-sdk/groq": "^3.0.39",
88
+ "@ai-sdk/mistral": "^3.0.37",
89
+ "@ai-sdk/openai": "^3.0.68",
90
+ "@ai-sdk/openai-compatible": "^2.0.48",
91
+ "@ai-sdk/perplexity": "^3.0.33",
92
+ "@ai-sdk/togetherai": "^2.0.53",
93
+ "@ai-sdk/vercel": "^2.0.50",
94
+ "@ai-sdk/xai": "^3.0.93",
95
+ "@clack/prompts": "^0.9.1",
96
+ "@openrouter/ai-sdk-provider": "^2.9.0",
97
+ ai: "^6.0.197",
98
+ "gitlab-ai-provider": "^6.8.0",
99
+ "ipaddr.js": "^2.4.0",
100
+ open: "^11.0.0",
101
+ picocolors: "^1.1.1",
102
+ "smol-toml": "^1.6.1",
103
+ "venice-ai-sdk-provider": "^2.0.2",
104
+ zod: "^3.25.76"
105
+ },
106
+ devDependencies: {
107
+ "@types/node": "^22.0.0",
108
+ "@vitest/coverage-v8": "^2.1.9",
109
+ tsup: "^8.0.0",
110
+ typescript: "^5.5.0",
111
+ vitest: "^2.0.0"
112
+ },
113
+ optionalDependencies: {
114
+ "@napi-rs/keyring": "^1.3.0"
115
+ },
116
+ overrides: {
117
+ ws: "^8.21.0"
118
+ }
119
+ };
120
+
121
+ // src/constants.ts
34
122
  var BACKENDS = {
35
123
  zen: {
36
124
  id: "zen",
@@ -76,7 +164,7 @@ function classifyModelFormat(modelId, providerNpm) {
76
164
  if (lower.startsWith("gemini-")) return "unsupported";
77
165
  return "openai";
78
166
  }
79
- var VERSION = "0.2.7";
167
+ var VERSION = package_default.version;
80
168
 
81
169
  // src/oauth/pkce.ts
82
170
  function positiveSecondsToMs(value, defaultMs) {
@@ -889,6 +977,7 @@ function providerTagColor(providerId) {
889
977
  case "openai":
890
978
  return pc.white;
891
979
  case "xai":
980
+ case "xai-oauth":
892
981
  return pc.white;
893
982
  case "groq":
894
983
  return pc.red;
@@ -1050,6 +1139,20 @@ function findClaudeBinary() {
1050
1139
  }
1051
1140
  return null;
1052
1141
  }
1142
+ function getInstalledClaudeVersion() {
1143
+ try {
1144
+ const claudePath = findClaudeBinary();
1145
+ if (!claudePath) return "2.1.183";
1146
+ const result = execSync(`${isWindows ? `"${claudePath}"` : claudePath} --version`, {
1147
+ encoding: "utf8",
1148
+ stdio: ["pipe", "pipe", "pipe"]
1149
+ });
1150
+ const match = result.match(/(\d+\.\d+\.\d+)/);
1151
+ if (match) return match[1];
1152
+ } catch {
1153
+ }
1154
+ return "2.1.183";
1155
+ }
1053
1156
  function buildClaudeArgs(model, extraArgs) {
1054
1157
  return ["--model", model, ...extraArgs];
1055
1158
  }
@@ -1334,7 +1437,7 @@ function accessTokenIsExpiring(token, skewMs = OAUTH_REFRESH_SKEW_MS) {
1334
1437
  return false;
1335
1438
  }
1336
1439
  }
1337
- var NATIVE_OAUTH_PROVIDER_IDS = ["xai", "openai", "github-copilot"];
1440
+ var NATIVE_OAUTH_PROVIDER_IDS = ["xai", "xai-oauth", "openai", "openai-oauth", "github-copilot"];
1338
1441
  function supportsNativeOAuth(providerId) {
1339
1442
  return NATIVE_OAUTH_PROVIDER_IDS.includes(providerId);
1340
1443
  }
@@ -1554,8 +1657,7 @@ async function runXaiDeviceCodeFlow(onDeviceCode, opts) {
1554
1657
  // src/oauth/refresh.ts
1555
1658
  function oauthCredentialShouldRefresh(cred, providerId) {
1556
1659
  if (oauthCredentialNeedsRefresh(cred)) return true;
1557
- if (providerId === "xai" && accessTokenIsExpiring(cred.access)) return true;
1558
- if (providerId === "github-copilot" && accessTokenIsExpiring(cred.access)) return true;
1660
+ if (NATIVE_OAUTH_PROVIDER_IDS.includes(providerId) && accessTokenIsExpiring(cred.access)) return true;
1559
1661
  return false;
1560
1662
  }
1561
1663
  async function refreshStoredOAuthCredential(providerId, cred) {
@@ -1563,9 +1665,9 @@ async function refreshStoredOAuthCredential(providerId, cred) {
1563
1665
  throw new Error(`${providerId}: OAuth refresh token missing \u2014 run relay-ai providers auth ${providerId}`);
1564
1666
  }
1565
1667
  let tokens;
1566
- if (providerId === "openai") {
1668
+ if (providerId === "openai" || providerId === "openai-oauth") {
1567
1669
  tokens = await refreshOpenAiAccessToken(cred.refresh);
1568
- } else if (providerId === "xai") {
1670
+ } else if (providerId === "xai" || providerId === "xai-oauth") {
1569
1671
  tokens = await refreshXaiAccessToken(cred.refresh);
1570
1672
  } else if (providerId === "github-copilot") {
1571
1673
  tokens = await refreshGithubCopilotToken(cred.refresh);
@@ -2459,6 +2561,36 @@ function migrateLegacyCloudProviders(registry) {
2459
2561
  }
2460
2562
  return changed;
2461
2563
  }
2564
+ function migrateOAuthOpenAiProvider(registry) {
2565
+ if (registry.providers.some((p19) => p19.id === "openai-oauth")) return false;
2566
+ const idx = registry.providers.findIndex(
2567
+ (p19) => p19.id === "openai" && p19.authType === "oauth"
2568
+ );
2569
+ if (idx < 0) return false;
2570
+ const existing = registry.providers[idx];
2571
+ registry.providers[idx] = {
2572
+ ...existing,
2573
+ id: "openai-oauth",
2574
+ templateId: existing.templateId || "openai",
2575
+ name: existing.name === "OpenAI" ? "OpenAI (ChatGPT)" : existing.name
2576
+ };
2577
+ return true;
2578
+ }
2579
+ function migrateOAuthXaiProvider(registry) {
2580
+ if (registry.providers.some((p19) => p19.id === "xai-oauth")) return false;
2581
+ const idx = registry.providers.findIndex(
2582
+ (p19) => p19.id === "xai" && p19.authType === "oauth"
2583
+ );
2584
+ if (idx < 0) return false;
2585
+ const existing = registry.providers[idx];
2586
+ registry.providers[idx] = {
2587
+ ...existing,
2588
+ id: "xai-oauth",
2589
+ templateId: existing.templateId || "xai",
2590
+ name: existing.name === "xAI" ? "xAI Grok (SuperGrok)" : existing.name
2591
+ };
2592
+ return true;
2593
+ }
2462
2594
 
2463
2595
  // src/registry/validate.ts
2464
2596
  var PROVIDER_ID_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
@@ -2566,7 +2698,10 @@ function loadRegistry(path = getProvidersPath()) {
2566
2698
  try {
2567
2699
  const raw = JSON.parse(readFileSync3(path, "utf8"));
2568
2700
  const registry = parseRegistry(raw);
2569
- if (migrateLegacyCloudProviders(registry)) {
2701
+ let migrated = migrateLegacyCloudProviders(registry);
2702
+ if (migrateOAuthOpenAiProvider(registry)) migrated = true;
2703
+ if (migrateOAuthXaiProvider(registry)) migrated = true;
2704
+ if (migrated) {
2570
2705
  try {
2571
2706
  saveRegistry(registry, path);
2572
2707
  } catch {
@@ -2603,6 +2738,7 @@ var TEMPLATE_TO_PRICING_PLATFORM = {
2603
2738
  cerebras: "cerebras",
2604
2739
  deepinfra: "deepinfra",
2605
2740
  xai: "xai",
2741
+ "xai-oauth": "xai",
2606
2742
  perplexity: "perplexity",
2607
2743
  cohere: "cohere",
2608
2744
  openai: "openai",
@@ -2782,6 +2918,7 @@ var REGISTRY_TO_MODELS_DEV = {
2782
2918
  cerebras: "cerebras",
2783
2919
  deepinfra: "deepinfra",
2784
2920
  xai: "xai",
2921
+ "xai-oauth": "xai",
2785
2922
  perplexity: "perplexity",
2786
2923
  cohere: "cohere",
2787
2924
  alibaba: "alibaba",
@@ -3307,6 +3444,11 @@ async function resolveRefreshCredential(provider, resolveKey) {
3307
3444
  function oauthAuthRef(providerId) {
3308
3445
  return `keyring:oauth:provider:${providerId}`;
3309
3446
  }
3447
+ function toOAuthRegistryId(id) {
3448
+ if (id === "openai") return "openai-oauth";
3449
+ if (id === "xai") return "xai-oauth";
3450
+ return id;
3451
+ }
3310
3452
  function normalizeImportProviderIdentity(provider) {
3311
3453
  if (provider.id === "opencode") {
3312
3454
  return { ...provider, id: "zen", name: "OpenCode Zen" };
@@ -3336,8 +3478,10 @@ function buildImportProviderList(raw, authEntries) {
3336
3478
  { includeOAuthPlaceholders: true }
3337
3479
  );
3338
3480
  if (oauthProviders.length === 0) continue;
3339
- oauthByProviderId.set(provider.id, authEntry);
3340
- merged.push({ ...oauthProviders[0], apiKey: "" });
3481
+ const registryId = toOAuthRegistryId(provider.id);
3482
+ oauthByProviderId.set(registryId, authEntry);
3483
+ merged.push({ ...oauthProviders[0], id: registryId, apiKey: "" });
3484
+ covered.add(registryId);
3341
3485
  covered.add(provider.id);
3342
3486
  }
3343
3487
  return { providers: merged, oauth: { oauthByProviderId } };
@@ -4772,13 +4916,13 @@ function upstreamModelId(model) {
4772
4916
  const id = model.upstreamModelId ?? model.id;
4773
4917
  return id.replace(/\[1m\]$/i, "");
4774
4918
  }
4775
- function isOpenAIChatCompletionsModel(model) {
4919
+ function supportsDirectOpenAIChatCompletions(model) {
4776
4920
  return model.modelFormat === "openai" && (!!model.completionsUrl || model.sourceBackend === "zen" || model.sourceBackend === "go");
4777
4921
  }
4778
4922
  function formatOpenAIModels(models) {
4779
4923
  return {
4780
4924
  object: "list",
4781
- data: models.filter(isOpenAIChatCompletionsModel).map((model) => ({
4925
+ data: models.map((model) => ({
4782
4926
  id: model.id,
4783
4927
  object: "model",
4784
4928
  created: CREATED_AT_UNIX,
@@ -4893,6 +5037,9 @@ async function relayAnthropicMessages(res, messagesUrl, body, apiKey, clientWant
4893
5037
  res.end(text5);
4894
5038
  }
4895
5039
 
5040
+ // src/proxy.ts
5041
+ import { randomUUID } from "crypto";
5042
+
4896
5043
  // src/sdk-adapter.ts
4897
5044
  import { streamText, generateText, tool, jsonSchema } from "ai";
4898
5045
 
@@ -4957,9 +5104,9 @@ function serializeToolResultContent(content) {
4957
5104
 
4958
5105
  // src/tool-search.ts
4959
5106
  var TOOL_SEARCH_TYPE_PREFIX = "tool_search_tool";
4960
- function isToolSearchTool(tool3) {
4961
- if (typeof tool3.type === "string" && tool3.type.startsWith(TOOL_SEARCH_TYPE_PREFIX)) return true;
4962
- const name = tool3.name ?? "";
5107
+ function isToolSearchTool(tool4) {
5108
+ if (typeof tool4.type === "string" && tool4.type.startsWith(TOOL_SEARCH_TYPE_PREFIX)) return true;
5109
+ const name = tool4.name ?? "";
4963
5110
  return name.includes("tool_search") || name === "ToolSearch";
4964
5111
  }
4965
5112
  function extractReferencedToolNames(messages) {
@@ -4998,16 +5145,16 @@ function resolveUpstreamTools(tools, messages) {
4998
5145
  if (!tools?.length) return [];
4999
5146
  const referenced = extractReferencedToolNames(messages);
5000
5147
  const upstream = [];
5001
- for (const tool3 of tools) {
5002
- if (isToolSearchTool(tool3)) {
5003
- upstream.push(tool3);
5148
+ for (const tool4 of tools) {
5149
+ if (isToolSearchTool(tool4)) {
5150
+ upstream.push(tool4);
5004
5151
  continue;
5005
5152
  }
5006
- if (tool3.defer_loading === true) {
5007
- if (referenced.has(tool3.name)) upstream.push(tool3);
5153
+ if (tool4.defer_loading === true) {
5154
+ if (referenced.has(tool4.name)) upstream.push(tool4);
5008
5155
  continue;
5009
5156
  }
5010
- upstream.push(tool3);
5157
+ upstream.push(tool4);
5011
5158
  }
5012
5159
  return upstream;
5013
5160
  }
@@ -5149,13 +5296,15 @@ function translateRequest(body, npm, options) {
5149
5296
  messages
5150
5297
  );
5151
5298
  const effort = anthropicEffortFromRequest(body) ?? options?.defaultEffort;
5152
- const providerOptions = deepMergeProviderOptions(
5153
- deepMergeProviderOptions(
5154
- thinkingProviderOptions(npm),
5155
- effortProviderOptions(npm, effort, body.model, options?.reasoningMetadata)
5156
- ),
5157
- options?.openAiOAuth && systemText ? { openai: { instructions: systemText } } : void 0
5299
+ let providerOptions = deepMergeProviderOptions(
5300
+ thinkingProviderOptions(npm),
5301
+ effortProviderOptions(npm, effort, body.model, options?.reasoningMetadata)
5158
5302
  );
5303
+ if (options?.openAiOAuth && systemText) {
5304
+ providerOptions = deepMergeProviderOptions(providerOptions, {
5305
+ openai: { instructions: systemText }
5306
+ });
5307
+ }
5159
5308
  return {
5160
5309
  system: options?.openAiOAuth ? void 0 : systemText,
5161
5310
  messages: translateMessages(messages, npm),
@@ -5395,6 +5544,7 @@ function lookupRoute(byAlias, id) {
5395
5544
  return void 0;
5396
5545
  }
5397
5546
  function startProxyCatalog(routes, defaultAliasId, debug = false) {
5547
+ const proxyToken = randomUUID();
5398
5548
  silenceSdkWarnings();
5399
5549
  if (routes.length === 0) {
5400
5550
  return Promise.reject(new Error("Proxy catalog requires at least one route"));
@@ -5442,6 +5592,10 @@ function startProxyCatalog(routes, defaultAliasId, debug = false) {
5442
5592
  }
5443
5593
  if (req.method === "POST" && req.url?.startsWith("/v1/messages")) {
5444
5594
  const inboundKey = extractApiKey(req);
5595
+ if (inboundKey !== proxyToken) {
5596
+ anthropicError(res, 401, "Invalid proxy token");
5597
+ return;
5598
+ }
5445
5599
  let anthropicBody;
5446
5600
  try {
5447
5601
  const raw = await readBody(req);
@@ -5453,7 +5607,7 @@ function startProxyCatalog(routes, defaultAliasId, debug = false) {
5453
5607
  const originalModel = anthropicBody.model;
5454
5608
  const clientWantsStream = Boolean(anthropicBody.stream);
5455
5609
  const route = lookupRoute(byAlias, originalModel) ?? defaultRoute;
5456
- const apiKey = route.apiKey || inboundKey || "";
5610
+ const apiKey = route.apiKey;
5457
5611
  const upstreamUrl = route.upstreamUrl;
5458
5612
  plog(
5459
5613
  () => `POST /v1/messages - alias=${originalModel} route=${route.realModelId} format=${route.modelFormat} key=${apiKey ? `len:${apiKey.length}` : "MISSING"}`
@@ -5515,7 +5669,8 @@ function startProxyCatalog(routes, defaultAliasId, debug = false) {
5515
5669
  }
5516
5670
  } catch (err) {
5517
5671
  const message = err instanceof Error ? err.message : String(err);
5518
- plog(() => `sdk error: ${message}`);
5672
+ const body = err && typeof err === "object" && "responseBody" in err ? err.responseBody : void 0;
5673
+ plog(() => `sdk error: ${message}${body ? ` \u2014 body: ${body}` : ""}`);
5519
5674
  if (!res.headersSent) {
5520
5675
  anthropicError(res, 502, message);
5521
5676
  } else {
@@ -5544,6 +5699,7 @@ data: ${JSON.stringify({ type: "error", error: { type: "api_error", message } })
5544
5699
  plog(() => `started on port ${addr.port}, catalog=${routes.length} model(s), default=${defaultRoute.aliasId}`);
5545
5700
  resolve({
5546
5701
  port: addr.port,
5702
+ token: proxyToken,
5547
5703
  close: () => {
5548
5704
  process.off("unhandledRejection", onRejection);
5549
5705
  process.off("uncaughtException", onException);
@@ -5553,7 +5709,7 @@ data: ${JSON.stringify({ type: "error", error: { type: "api_error", message } })
5553
5709
  });
5554
5710
  });
5555
5711
  }
5556
- function startProxy(completionsUrl, modelId, debug = false, contextWindow, sdk) {
5712
+ function startProxy(completionsUrl, modelId, debug = false, contextWindow, sdk, apiKey) {
5557
5713
  const bareModelId = stripOneMContextSuffix(modelId);
5558
5714
  const clientModelId = claudeCodeClientModelId(modelId, contextWindow);
5559
5715
  return startProxyCatalog([{
@@ -5561,8 +5717,7 @@ function startProxy(completionsUrl, modelId, debug = false, contextWindow, sdk)
5561
5717
  realModelId: sdk?.upstreamModelId ?? bareModelId,
5562
5718
  displayName: bareModelId,
5563
5719
  upstreamUrl: completionsUrl,
5564
- apiKey: "",
5565
- // '' → use inbound bearer from Claude Code (single-model compat)
5720
+ apiKey: apiKey ?? "",
5566
5721
  modelFormat: "openai",
5567
5722
  contextWindow,
5568
5723
  npm: sdk?.npm,
@@ -5664,7 +5819,7 @@ function ensureAppHomeMigrated() {
5664
5819
  if (existsSync9(configPath)) return;
5665
5820
  const legacyConfig = join9(getLegacyAppHome(), "config.json");
5666
5821
  if (!existsSync9(legacyConfig)) return;
5667
- mkdirSync5(getAppHome(), { recursive: true });
5822
+ mkdirSync5(getAppHome(), { recursive: true, mode: 448 });
5668
5823
  copyFileSync2(legacyConfig, configPath);
5669
5824
  const legacyVertex = join9(getLegacyAppHome(), "vertex-models.json");
5670
5825
  const vertexPath = join9(getAppHome(), "vertex-models.json");
@@ -5680,9 +5835,9 @@ function ensureConfigMigrated() {
5680
5835
  if (!existsSync9(legacyPath)) return;
5681
5836
  const legacy = readJsonFile(legacyPath);
5682
5837
  if (!legacy) return;
5683
- mkdirSync5(dirname4(configPath), { recursive: true });
5838
+ mkdirSync5(dirname4(configPath), { recursive: true, mode: 448 });
5684
5839
  writeFileSync4(configPath, `${JSON.stringify(legacy, null, 2)}
5685
- `, "utf8");
5840
+ `, { encoding: "utf8", mode: 384 });
5686
5841
  try {
5687
5842
  renameSync2(legacyPath, `${legacyPath}.migrated`);
5688
5843
  } catch {
@@ -5694,9 +5849,9 @@ function readConfig() {
5694
5849
  }
5695
5850
  function writeConfig(config) {
5696
5851
  const configPath = getConfigPath();
5697
- mkdirSync5(dirname4(configPath), { recursive: true });
5852
+ mkdirSync5(dirname4(configPath), { recursive: true, mode: 448 });
5698
5853
  writeFileSync4(configPath, `${JSON.stringify(config, null, 2)}
5699
- `, "utf8");
5854
+ `, { encoding: "utf8", mode: 384 });
5700
5855
  }
5701
5856
  function loadPreferences() {
5702
5857
  const config = readConfig();
@@ -5732,10 +5887,51 @@ function recordLaunchSelection(agent, providerId, modelId, prefs) {
5732
5887
  recentModelsByProvider: { ...prefs.recentModelsByProvider, [providerId]: updatedRecent }
5733
5888
  });
5734
5889
  }
5735
- function getSavedServerPassword() {
5736
- return readConfig().server?.savedPassword?.trim() || null;
5890
+ var SERVER_PASSWORD_SERVICE = "relay-ai-server-password";
5891
+ var SERVER_PASSWORD_ACCOUNT = "server-password";
5892
+ async function getServerPasswordKeyring() {
5893
+ try {
5894
+ const { Entry } = await import("@napi-rs/keyring");
5895
+ return new Entry(SERVER_PASSWORD_SERVICE, SERVER_PASSWORD_ACCOUNT);
5896
+ } catch {
5897
+ return null;
5898
+ }
5737
5899
  }
5738
- function setSavedServerPassword(password3) {
5900
+ async function getSavedServerPassword() {
5901
+ const config = readConfig();
5902
+ if (config.server?.savedPassword) {
5903
+ const pwd = config.server.savedPassword;
5904
+ const keyring2 = await getServerPasswordKeyring();
5905
+ if (keyring2) {
5906
+ try {
5907
+ await keyring2.setPassword(pwd);
5908
+ delete config.server.savedPassword;
5909
+ if (Object.keys(config.server).length === 0) delete config.server;
5910
+ writeConfig(config);
5911
+ } catch {
5912
+ }
5913
+ }
5914
+ return pwd;
5915
+ }
5916
+ const keyring = await getServerPasswordKeyring();
5917
+ if (keyring) {
5918
+ try {
5919
+ return await keyring.getPassword();
5920
+ } catch {
5921
+ return null;
5922
+ }
5923
+ }
5924
+ return null;
5925
+ }
5926
+ async function setSavedServerPassword(password3) {
5927
+ const keyring = await getServerPasswordKeyring();
5928
+ if (keyring) {
5929
+ try {
5930
+ await keyring.setPassword(password3);
5931
+ return;
5932
+ } catch {
5933
+ }
5934
+ }
5739
5935
  const config = readConfig();
5740
5936
  config.server = {
5741
5937
  ...config.server ?? {},
@@ -5911,11 +6107,19 @@ function zenGoAsLocalProvider(backendId, models) {
5911
6107
  }
5912
6108
  function providersForPicker(catalog) {
5913
6109
  const registryIds = new Set(catalog.localProviders.map((p19) => p19.id));
5914
- return [
6110
+ const providers = [
5915
6111
  ...catalog.zenModels.length > 0 && !registryIds.has("zen") ? [zenGoAsLocalProvider("zen", catalog.zenModels)] : [],
5916
6112
  ...catalog.goModels.length > 0 && !registryIds.has("go") ? [zenGoAsLocalProvider("go", catalog.goModels)] : [],
5917
6113
  ...catalog.localProviders
5918
6114
  ];
6115
+ for (const p19 of providers) {
6116
+ p19.models.sort((a, b) => {
6117
+ const nameA = a.name || a.id;
6118
+ const nameB = b.name || b.id;
6119
+ return nameA.localeCompare(nameB, void 0, { sensitivity: "base", numeric: true });
6120
+ });
6121
+ }
6122
+ return providers.sort((a, b) => a.name.localeCompare(b.name, void 0, { sensitivity: "base", numeric: true }));
5919
6123
  }
5920
6124
  async function resolveLocalProviderApiKey(provider) {
5921
6125
  const direct = provider.apiKey?.trim();
@@ -6131,6 +6335,147 @@ async function askSaveServerPassword() {
6131
6335
 
6132
6336
  // src/server/router.ts
6133
6337
  import { createServer as createServer2 } from "http";
6338
+
6339
+ // src/openai-adapter.ts
6340
+ import { tool as tool2, jsonSchema as jsonSchema2, streamText as streamText2, generateText as generateText2 } from "ai";
6341
+ function translateOpenAiRequest(body) {
6342
+ const toolNameById = /* @__PURE__ */ new Map();
6343
+ for (const msg of body.messages) {
6344
+ if (msg.role === "assistant" && msg.tool_calls) {
6345
+ for (const tc of msg.tool_calls) toolNameById.set(tc.id, tc.function.name);
6346
+ }
6347
+ }
6348
+ let system;
6349
+ const messages = [];
6350
+ for (const msg of body.messages) {
6351
+ switch (msg.role) {
6352
+ case "system":
6353
+ system = typeof msg.content === "string" ? msg.content : void 0;
6354
+ break;
6355
+ case "user":
6356
+ messages.push({ role: "user", content: msg.content });
6357
+ break;
6358
+ case "assistant": {
6359
+ const parts = [];
6360
+ if (typeof msg.content === "string" && msg.content) {
6361
+ parts.push({ type: "text", text: msg.content });
6362
+ }
6363
+ for (const tc of msg.tool_calls ?? []) {
6364
+ parts.push({
6365
+ type: "tool-call",
6366
+ toolCallId: tc.id,
6367
+ toolName: tc.function.name,
6368
+ input: parseToolArguments(tc.function.arguments)
6369
+ });
6370
+ }
6371
+ messages.push({ role: "assistant", content: parts.length > 0 ? parts : "" });
6372
+ break;
6373
+ }
6374
+ case "tool": {
6375
+ const resultPart = {
6376
+ type: "tool-result",
6377
+ toolCallId: msg.tool_call_id ?? "",
6378
+ toolName: toolNameById.get(msg.tool_call_id ?? "") ?? "unknown",
6379
+ output: {
6380
+ type: "text",
6381
+ value: typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content ?? "")
6382
+ }
6383
+ };
6384
+ const lastMsg = messages[messages.length - 1];
6385
+ if (lastMsg?.role === "tool" && Array.isArray(lastMsg.content)) {
6386
+ lastMsg.content.push(resultPart);
6387
+ } else {
6388
+ messages.push({ role: "tool", content: [resultPart] });
6389
+ }
6390
+ break;
6391
+ }
6392
+ }
6393
+ }
6394
+ let sdkToolChoice;
6395
+ if (body.tool_choice === "auto" || body.tool_choice === "required") {
6396
+ sdkToolChoice = body.tool_choice;
6397
+ } else if (typeof body.tool_choice === "object" && body.tool_choice?.type === "function") {
6398
+ sdkToolChoice = { type: "tool", toolName: body.tool_choice.function.name };
6399
+ }
6400
+ let tools;
6401
+ if (body.tools?.length) {
6402
+ tools = {};
6403
+ for (const t of body.tools) {
6404
+ if (t.type === "function" && t.function.name) {
6405
+ const schema = t.function.parameters ? jsonSchema2(t.function.parameters) : void 0;
6406
+ tools[t.function.name] = tool2({
6407
+ description: t.function.description ?? "",
6408
+ inputSchema: schema ?? jsonSchema2({ type: "object", properties: {} })
6409
+ });
6410
+ }
6411
+ }
6412
+ }
6413
+ return {
6414
+ system,
6415
+ messages,
6416
+ tools,
6417
+ toolChoice: sdkToolChoice,
6418
+ temperature: body.temperature,
6419
+ maxOutputTokens: body.max_completion_tokens ?? body.max_tokens
6420
+ };
6421
+ }
6422
+ async function generateOpenAiResponse(model, params, responseModelId) {
6423
+ const result = await generateText2({ model, ...params });
6424
+ const message = { role: "assistant", content: result.text || null };
6425
+ if (result.toolCalls?.length) {
6426
+ message.tool_calls = result.toolCalls.map((tc) => ({
6427
+ id: tc.toolCallId,
6428
+ type: "function",
6429
+ function: { name: tc.toolName, arguments: JSON.stringify(tc.args) }
6430
+ }));
6431
+ }
6432
+ return {
6433
+ id: `chatcmpl-${Date.now()}`,
6434
+ object: "chat.completion",
6435
+ created: Math.floor(Date.now() / 1e3),
6436
+ model: responseModelId,
6437
+ choices: [{ index: 0, message, finish_reason: result.finishReason || "stop" }],
6438
+ usage: {
6439
+ prompt_tokens: result.usage?.promptTokens ?? 0,
6440
+ completion_tokens: result.usage?.completionTokens ?? 0,
6441
+ total_tokens: result.usage?.totalTokens ?? 0
6442
+ }
6443
+ };
6444
+ }
6445
+ async function streamOpenAiResponse(model, params, responseModelId, onChunk) {
6446
+ const { fullStream } = streamText2({ model, ...params });
6447
+ const baseData = {
6448
+ id: `chatcmpl-${Date.now()}`,
6449
+ object: "chat.completion.chunk",
6450
+ created: Math.floor(Date.now() / 1e3),
6451
+ model: responseModelId
6452
+ };
6453
+ const send = (delta, finish_reason = null) => onChunk(`data: ${JSON.stringify({ ...baseData, choices: [{ index: 0, delta, finish_reason }] })}
6454
+
6455
+ `);
6456
+ for await (const part of fullStream) {
6457
+ const p19 = part;
6458
+ switch (p19.type) {
6459
+ case "text-delta":
6460
+ send({ role: "assistant", content: p19.textDelta ?? p19.text ?? "" });
6461
+ break;
6462
+ case "tool-input-start":
6463
+ case "tool-call-streaming-start":
6464
+ send({ role: "assistant", tool_calls: [{ index: 0, id: p19.id ?? p19.toolCallId, type: "function", function: { name: p19.toolName, arguments: "" } }] });
6465
+ break;
6466
+ case "tool-input-delta":
6467
+ case "tool-call-delta":
6468
+ send({ tool_calls: [{ index: 0, function: { arguments: p19.delta ?? p19.text ?? p19.argsTextDelta ?? "" } }] });
6469
+ break;
6470
+ case "finish":
6471
+ send({}, p19.finishReason || "stop");
6472
+ break;
6473
+ }
6474
+ }
6475
+ onChunk("data: [DONE]\n\n");
6476
+ }
6477
+
6478
+ // src/server/router.ts
6134
6479
  function makeServerLog(debugLogPath) {
6135
6480
  if (!debugLogPath) return () => {
6136
6481
  };
@@ -6194,7 +6539,7 @@ async function routeRequest(req, res, options, modelCache, plog) {
6194
6539
  return;
6195
6540
  }
6196
6541
  if (req.method === "POST" && pathname === "/openai/v1/chat/completions") {
6197
- await handleOpenAIChatCompletions(req, res, options);
6542
+ await handleOpenAIChatCompletions(req, res, options, modelCache, plog);
6198
6543
  return;
6199
6544
  }
6200
6545
  sendJson(res, 404, { error: { message: "Not found" } });
@@ -6233,21 +6578,7 @@ async function handleAnthropicMessages(req, res, options, modelCache, plog) {
6233
6578
  return;
6234
6579
  }
6235
6580
  const apiKey = model.apiKey ?? options.apiKey;
6236
- const cacheKey = sdkModelCacheKey(model);
6237
- let languageModel = modelCache.get(cacheKey);
6238
- if (!languageModel) {
6239
- languageModel = await createLanguageModel({
6240
- npm: model.npm,
6241
- modelId: upstreamModelId(model),
6242
- apiKey,
6243
- baseURL: model.apiBaseUrl,
6244
- providerId: model.providerId ?? model.sourceBackend,
6245
- authType: model.authType,
6246
- oauthAccountId: model.oauthAccountId,
6247
- vertex: options.vertex
6248
- });
6249
- modelCache.set(cacheKey, languageModel);
6250
- }
6581
+ const languageModel = await getOrInitLanguageModel(modelCache, model, model.npm, model.apiBaseUrl, apiKey, options.vertex);
6251
6582
  const params = translateRequest(body, model.npm, {
6252
6583
  defaultEffort: anthropicEffortFromRequest(body) ? void 0 : model.defaultEffort,
6253
6584
  openAiOAuth: model.npm === "@ai-sdk/openai" && model.authType === "oauth",
@@ -6260,7 +6591,7 @@ async function handleAnthropicMessages(req, res, options, modelCache, plog) {
6260
6591
  }
6261
6592
  });
6262
6593
  const clientWantsStream = Boolean(body.stream);
6263
- const responseModelId = options.gateway?.maskGatewayIds ? gatewayDisplayName(model, options.gateway) : typeof body.model === "string" ? body.model : model.id;
6594
+ const responseModelId = getResponseModelId(body.model, model, options);
6264
6595
  plog(() => `sdk npm=${model.npm} upstream=${upstreamModelId(model)} responseModel=${responseModelId} stream=${clientWantsStream}`);
6265
6596
  try {
6266
6597
  if (clientWantsStream) {
@@ -6284,7 +6615,7 @@ async function handleAnthropicMessages(req, res, options, modelCache, plog) {
6284
6615
  }
6285
6616
  sendJson(res, 400, { error: { message: `Unsupported model format: ${model.modelFormat}` } });
6286
6617
  }
6287
- async function handleOpenAIChatCompletions(req, res, options) {
6618
+ async function handleOpenAIChatCompletions(req, res, options, modelCache, plog) {
6288
6619
  const body = await readJson(req);
6289
6620
  if (!body) {
6290
6621
  sendJson(res, 400, { error: { message: "Invalid JSON body" } });
@@ -6292,29 +6623,46 @@ async function handleOpenAIChatCompletions(req, res, options) {
6292
6623
  }
6293
6624
  const model = lookupModel(res, options.catalog, body.model);
6294
6625
  if (!model) return;
6295
- if (model.modelFormat === "openai") {
6296
- if (!isOpenAIChatCompletionsModel(model)) {
6297
- sendJson(res, 400, {
6298
- error: {
6299
- message: `OpenAI chat completions are not available for model: ${model.id}. Use /anthropic/v1/messages.`
6300
- }
6301
- });
6302
- return;
6303
- }
6626
+ if (supportsDirectOpenAIChatCompletions(model)) {
6304
6627
  if (model.completionsUrl && !/^https?:\/\//i.test(model.completionsUrl)) {
6305
6628
  sendJson(res, 400, { error: { message: `Invalid provider completionsUrl: must be http:// or https://` } });
6306
6629
  return;
6307
6630
  }
6308
6631
  const completionsUrl = model.completionsUrl ? model.completionsUrl : `${backendFor(options, model).baseUrl}/v1/chat/completions`;
6309
- const apiKey = model.apiKey ?? options.apiKey;
6310
- await forwardJson(res, completionsUrl, body, apiKey);
6632
+ const apiKey2 = model.apiKey ?? options.apiKey;
6633
+ await relayAnthropicMessages(res, completionsUrl, body, apiKey2, Boolean(body.stream));
6311
6634
  return;
6312
6635
  }
6313
- if (model.modelFormat === "anthropic") {
6314
- sendJson(res, 400, { error: { message: "OpenAI to Anthropic reverse translation is not supported yet" } });
6636
+ const npm = model.npm || (model.modelFormat === "anthropic" ? "@ai-sdk/anthropic" : void 0);
6637
+ if (!npm) {
6638
+ sendJson(res, 400, { error: { message: `No SDK provider for model: ${model.id}` } });
6315
6639
  return;
6316
6640
  }
6317
- sendJson(res, 400, { error: { message: `Unsupported model format: ${model.modelFormat}` } });
6641
+ const apiKey = model.apiKey ?? options.apiKey;
6642
+ const baseURL = model.modelFormat === "anthropic" ? model.baseUrl : model.apiBaseUrl;
6643
+ const languageModel = await getOrInitLanguageModel(modelCache, model, npm, baseURL, apiKey, options.vertex);
6644
+ const params = translateOpenAiRequest(body);
6645
+ const clientWantsStream = Boolean(body.stream);
6646
+ const responseModelId = getResponseModelId(body.model, model, options);
6647
+ plog(() => `sdk-openai npm=${npm} upstream=${upstreamModelId(model)} responseModel=${responseModelId} stream=${clientWantsStream}`);
6648
+ try {
6649
+ if (clientWantsStream) {
6650
+ res.writeHead(200, {
6651
+ "Content-Type": "text/event-stream",
6652
+ "Cache-Control": "no-cache",
6653
+ "Connection": "keep-alive"
6654
+ });
6655
+ await streamOpenAiResponse(languageModel, params, responseModelId, (chunk) => res.write(chunk));
6656
+ res.end();
6657
+ } else {
6658
+ const response = await generateOpenAiResponse(languageModel, params, responseModelId);
6659
+ sendJson(res, 200, response);
6660
+ }
6661
+ } catch (err) {
6662
+ const message = err instanceof Error ? err.message : String(err);
6663
+ if (!res.headersSent) sendJson(res, 502, { error: { message } });
6664
+ else res.end();
6665
+ }
6318
6666
  }
6319
6667
  function lookupModel(res, catalog, modelId) {
6320
6668
  if (typeof modelId !== "string") {
@@ -6336,14 +6684,32 @@ function backendFor(options, model) {
6336
6684
  if (model.sourceBackend === "go") return options.backends.go;
6337
6685
  throw new Error(`Provider ${model.sourceBackend} is not a cloud backend \u2014 model must set baseUrl/completionsUrl`);
6338
6686
  }
6339
- function sdkModelCacheKey(model) {
6340
- return [
6687
+ async function getOrInitLanguageModel(modelCache, model, npm, baseURL, apiKey, vertex) {
6688
+ const cacheKey = [
6341
6689
  model.providerId ?? model.sourceBackend,
6342
6690
  model.id,
6343
6691
  upstreamModelId(model),
6344
- model.npm ?? "",
6345
- model.apiBaseUrl ?? ""
6692
+ npm,
6693
+ baseURL ?? ""
6346
6694
  ].join("");
6695
+ let languageModel = modelCache.get(cacheKey);
6696
+ if (!languageModel) {
6697
+ languageModel = await createLanguageModel({
6698
+ npm,
6699
+ modelId: upstreamModelId(model),
6700
+ apiKey,
6701
+ baseURL,
6702
+ providerId: model.providerId ?? model.sourceBackend,
6703
+ authType: model.authType,
6704
+ oauthAccountId: model.oauthAccountId,
6705
+ vertex
6706
+ });
6707
+ modelCache.set(cacheKey, languageModel);
6708
+ }
6709
+ return languageModel;
6710
+ }
6711
+ function getResponseModelId(bodyModel, model, options) {
6712
+ return options.gateway?.maskGatewayIds ? gatewayDisplayName(model, options.gateway) : typeof bodyModel === "string" ? bodyModel : model.id;
6347
6713
  }
6348
6714
  async function forwardJson(res, url, body, apiKey, inboundBeta) {
6349
6715
  const upstream = await postJsonUpstream(url, body, apiKey, inboundBeta);
@@ -6607,16 +6973,44 @@ function createVertexModelCatalog(models) {
6607
6973
  }
6608
6974
 
6609
6975
  // src/server/index.ts
6610
- function getLocalIp() {
6976
+ function getLocalIps() {
6611
6977
  const ifaces = networkInterfaces();
6612
- for (const iface of Object.values(ifaces)) {
6978
+ const result = [];
6979
+ for (const [name, iface] of Object.entries(ifaces)) {
6613
6980
  for (const addr of iface ?? []) {
6614
6981
  if (addr.family === "IPv4" && !addr.internal) {
6615
- return addr.address;
6982
+ result.push({ name, address: addr.address });
6616
6983
  }
6617
6984
  }
6618
6985
  }
6619
- return "<this-computer-ip>";
6986
+ return result;
6987
+ }
6988
+ function printModelCatalog(models, gateway) {
6989
+ if (models.length === 0) return;
6990
+ const groups = /* @__PURE__ */ new Map();
6991
+ for (const model of models) {
6992
+ const label = gatewayProviderLabel(model);
6993
+ let list = groups.get(label);
6994
+ if (!list) {
6995
+ list = [];
6996
+ groups.set(label, list);
6997
+ }
6998
+ list.push(model);
6999
+ }
7000
+ const sortedGroups = [...groups.entries()].sort((a, b) => a[0].localeCompare(b[0]));
7001
+ console.log(pc6.bold("Model catalog:"));
7002
+ console.log("");
7003
+ for (const [label, groupModels] of sortedGroups) {
7004
+ console.log(` ${pc6.bold(label)}`);
7005
+ const sorted = [...groupModels].sort((a, b) => a.name.localeCompare(b.name));
7006
+ for (const model of sorted) {
7007
+ const anthropicId = exposedGatewayAliasId(model, gateway);
7008
+ console.log(` ${model.name}`);
7009
+ console.log(` ${pc6.dim("anthropic:")} ${pc6.cyan(anthropicId)}`);
7010
+ console.log(` ${pc6.dim("openai: ")} ${pc6.cyan(model.id)}`);
7011
+ }
7012
+ console.log("");
7013
+ }
6620
7014
  }
6621
7015
  function filterZenModelsForServer(models) {
6622
7016
  const zenProvider = loadRegistry().providers.find((entry) => entry.id === "zen" && entry.enabled);
@@ -6698,13 +7092,19 @@ function waitForShutdown() {
6698
7092
  });
6699
7093
  }
6700
7094
  async function getServerPasswordForMode(mode) {
6701
- if (mode === "local") return null;
6702
- const savedPassword = getSavedServerPassword();
7095
+ if (mode === "local") return { password: null, wasSaved: false };
7096
+ const savedPassword = await getSavedServerPassword();
6703
7097
  let serverPassword = null;
7098
+ let wasSaved = false;
6704
7099
  if (savedPassword) {
6705
7100
  const savedChoice = await askUseSavedServerPassword();
6706
7101
  if (!savedChoice) return void 0;
6707
- serverPassword = savedChoice === "use-saved" ? savedPassword : await askServerPassword();
7102
+ if (savedChoice === "use-saved") {
7103
+ serverPassword = savedPassword;
7104
+ wasSaved = true;
7105
+ } else {
7106
+ serverPassword = await askServerPassword();
7107
+ }
6708
7108
  } else {
6709
7109
  serverPassword = await askServerPassword();
6710
7110
  }
@@ -6712,9 +7112,12 @@ async function getServerPasswordForMode(mode) {
6712
7112
  if (serverPassword !== savedPassword) {
6713
7113
  const savePassword = await askSaveServerPassword();
6714
7114
  if (savePassword === null) return void 0;
6715
- if (savePassword) setSavedServerPassword(serverPassword);
7115
+ if (savePassword) {
7116
+ await setSavedServerPassword(serverPassword);
7117
+ wasSaved = true;
7118
+ }
6716
7119
  }
6717
- return serverPassword;
7120
+ return { password: serverPassword, wasSaved };
6718
7121
  }
6719
7122
  async function configureExposedProviders() {
6720
7123
  p6.log.info("Add providers to expose. Listed providers are removed when selected \u2014 like favorites.");
@@ -6770,8 +7173,9 @@ async function runVertexServerCommand() {
6770
7173
  }
6771
7174
  const mode = await askListenMode();
6772
7175
  if (!mode) return 0;
6773
- const serverPassword = await getServerPasswordForMode(mode);
6774
- if (serverPassword === void 0) return 0;
7176
+ const pwResult = await getServerPasswordForMode(mode);
7177
+ if (pwResult === void 0) return 0;
7178
+ const { password: serverPassword, wasSaved: passwordWasSaved } = pwResult;
6775
7179
  const host = mode === "network" ? "0.0.0.0" : "127.0.0.1";
6776
7180
  const models = vertexModelsToServerModels(vertexConfig);
6777
7181
  const server = await startServer({
@@ -6791,13 +7195,20 @@ async function runVertexServerCommand() {
6791
7195
  console.log(` Anthropic: http://127.0.0.1:${server.port}/anthropic`);
6792
7196
  console.log(` Models: ${models.map((model) => model.id).join(", ")}`);
6793
7197
  if (mode === "network") {
6794
- console.log(` Network: http://${getLocalIp()}:${server.port}`);
6795
- console.log(` API key: ${serverPassword}`);
7198
+ for (const { name, address } of getLocalIps()) {
7199
+ console.log(` Network (${name}): http://${address}:${server.port}/anthropic`);
7200
+ }
7201
+ if (passwordWasSaved) {
7202
+ console.log(" API key: saved, rotate with `relay-ai server --setup`");
7203
+ } else {
7204
+ console.log(` API key: ${serverPassword}`);
7205
+ }
6796
7206
  } else {
6797
7207
  console.log(" API key: any non-empty value");
6798
7208
  }
6799
7209
  console.log(pc6.dim(" Auth: gcloud Application Default Credentials"));
6800
7210
  console.log("");
7211
+ printModelCatalog(models);
6801
7212
  console.log(pc6.dim("Press Ctrl+C to stop."));
6802
7213
  await waitForShutdown();
6803
7214
  await server.close();
@@ -6835,8 +7246,9 @@ async function runServerCommand(options = {}) {
6835
7246
  if (!runConfig) return 0;
6836
7247
  const mode = await askListenMode();
6837
7248
  if (!mode) return 0;
6838
- const serverPassword = await getServerPasswordForMode(mode);
6839
- if (serverPassword === void 0) return 0;
7249
+ const pwResult = await getServerPasswordForMode(mode);
7250
+ if (pwResult === void 0) return 0;
7251
+ const { password: serverPassword, wasSaved: passwordWasSaved } = pwResult;
6840
7252
  const host = mode === "network" ? "0.0.0.0" : "127.0.0.1";
6841
7253
  const spinner9 = p6.spinner();
6842
7254
  spinner9.start("Fetching available models...");
@@ -6896,10 +7308,18 @@ async function runServerCommand(options = {}) {
6896
7308
  console.log("");
6897
7309
  console.log(pc6.bold(pc6.green("Relay AI server running")));
6898
7310
  console.log(` Anthropic: http://127.0.0.1:${server.port}/anthropic`);
6899
- console.log(` OpenAI: http://127.0.0.1:${server.port}/openai`);
7311
+ console.log(` OpenAI: http://127.0.0.1:${server.port}/openai/v1`);
6900
7312
  if (mode === "network") {
6901
- console.log(` Network: http://${getLocalIp()}:${server.port}`);
6902
- console.log(` API key: ${serverPassword}`);
7313
+ for (const { name, address } of getLocalIps()) {
7314
+ console.log(` Network (${name}):`);
7315
+ console.log(` Anthropic: http://${address}:${server.port}/anthropic`);
7316
+ console.log(` OpenAI: http://${address}:${server.port}/openai/v1`);
7317
+ }
7318
+ if (passwordWasSaved) {
7319
+ console.log(" API key: saved, rotate with `relay-ai server --setup`");
7320
+ } else {
7321
+ console.log(` API key: ${serverPassword}`);
7322
+ }
6903
7323
  } else {
6904
7324
  console.log(" API key: any non-empty value");
6905
7325
  }
@@ -6913,6 +7333,7 @@ async function runServerCommand(options = {}) {
6913
7333
  console.log(pc6.dim(" Discovery: gateway ids masked for Claude Desktop / Cowork"));
6914
7334
  }
6915
7335
  console.log("");
7336
+ printModelCatalog(models, gateway);
6916
7337
  console.log(pc6.dim("Press Ctrl+C to stop."));
6917
7338
  await waitForShutdown();
6918
7339
  await server.close();
@@ -6934,16 +7355,24 @@ var MODE_SEARCH = "search";
6934
7355
  var MODE_BROWSE = "browse";
6935
7356
  function sortModelsByBrand(models) {
6936
7357
  return [...models].sort((a, b) => {
6937
- const brandCmp = a.brand.localeCompare(b.brand);
6938
- return brandCmp !== 0 ? brandCmp : a.id.localeCompare(b.id);
7358
+ const brandCmp = a.brand.localeCompare(b.brand, void 0, { sensitivity: "base" });
7359
+ if (brandCmp !== 0) return brandCmp;
7360
+ const nameA = a.name || a.id;
7361
+ const nameB = b.name || b.id;
7362
+ return nameA.localeCompare(nameB, void 0, { sensitivity: "base", numeric: true });
6939
7363
  });
6940
7364
  }
7365
+ function normalizeForSearch(s) {
7366
+ return s.toLowerCase().replace(/[\s\-._/]+/g, " ").trim();
7367
+ }
6941
7368
  function filterModelsBySearch(models, query) {
6942
- const q = query.trim().toLowerCase();
7369
+ const q = query.trim();
6943
7370
  if (!q) return [];
6944
- return models.filter(
6945
- (m) => m.id.toLowerCase().includes(q) || m.name.toLowerCase().includes(q) || m.brand.toLowerCase().includes(q)
6946
- );
7371
+ const tokens = normalizeForSearch(q).split(" ").filter(Boolean);
7372
+ return models.filter((m) => {
7373
+ const fields = [normalizeForSearch(m.id), normalizeForSearch(m.name), normalizeForSearch(m.brand)];
7374
+ return tokens.every((token) => fields.some((f) => f.includes(token)));
7375
+ });
6947
7376
  }
6948
7377
  function sliceModelPage(items, page, pageSize = MODEL_PAGE_SIZE) {
6949
7378
  const totalPages = Math.max(1, Math.ceil(items.length / pageSize));
@@ -7018,12 +7447,12 @@ async function selectLargeCatalog(models, browseList, toOption, message, initial
7018
7447
  value: MODE_BROWSE,
7019
7448
  label: pc7.cyan("Browse all models"),
7020
7449
  hint: `${MODEL_PAGE_SIZE} per page \xB7 ${Math.ceil(browseList.length / MODEL_PAGE_SIZE)} pages`
7021
- }
7450
+ },
7451
+ navOption("__back__", "\u2190 Go back", "Select a different provider")
7022
7452
  ]
7023
7453
  });
7024
- if (p7.isCancel(method)) {
7025
- p7.cancel("Cancelled.");
7026
- return null;
7454
+ if (p7.isCancel(method) || String(method) === "__back__") {
7455
+ return "back";
7027
7456
  }
7028
7457
  mode = method === MODE_BROWSE ? "browse" : "search";
7029
7458
  continue;
@@ -7083,16 +7512,18 @@ async function selectModelWithSearch(models, toOption, message, initialModelId,
7083
7512
  if (models.length === 0) return null;
7084
7513
  const orderedBrowse = browseList ?? sortModelsByBrand(models);
7085
7514
  if (models.length <= MODEL_SEARCH_THRESHOLD) {
7086
- const options = models.map(toOption);
7515
+ const options = [
7516
+ ...models.map(toOption),
7517
+ navOption("__back__", "\u2190 Go back", "")
7518
+ ];
7087
7519
  const initialValue = initialModelId && options.some((o) => o.value === initialModelId) ? initialModelId : options[0]?.value;
7088
7520
  const picked = await p7.select({
7089
7521
  message,
7090
7522
  options,
7091
7523
  initialValue
7092
7524
  });
7093
- if (p7.isCancel(picked)) {
7094
- p7.cancel("Cancelled.");
7095
- return null;
7525
+ if (p7.isCancel(picked) || String(picked) === "__back__") {
7526
+ return "back";
7096
7527
  }
7097
7528
  const selected = models.find((m) => m.id === String(picked));
7098
7529
  if (!selected) return null;
@@ -7117,32 +7548,43 @@ async function browseAllModels(provider, prefs) {
7117
7548
  async function pickLocalModel(provider, conflicts, prefs) {
7118
7549
  const recentIds = (prefs.recentModelsByProvider?.[provider.id] ?? []).slice(0, MAX_RECENT);
7119
7550
  const recentModels = recentIds.map((id) => provider.models.find((m) => m.id === id)).filter((m) => m !== void 0);
7120
- let selectedModel;
7121
- if (recentModels.length > 0) {
7122
- const options = [
7123
- ...recentModels.map((m) => modelToOption(m, "recent")),
7124
- navOption(BROWSE_ALL, "Browse all models \u2192", `${provider.models.length} available`)
7125
- ];
7126
- const picked = await p7.select({
7127
- message: "Which model?",
7128
- options,
7129
- initialValue: recentModels[0].id
7130
- });
7131
- if (p7.isCancel(picked)) {
7132
- p7.cancel("Cancelled.");
7133
- return null;
7134
- }
7135
- if (String(picked) === BROWSE_ALL) {
7551
+ let selectedModel = null;
7552
+ while (true) {
7553
+ if (recentModels.length > 0) {
7554
+ const options = [
7555
+ ...recentModels.map((m) => modelToOption(m, "recent")),
7556
+ navOption(BROWSE_ALL, "Browse all models \u2192", `${provider.models.length} available`),
7557
+ navOption("__back__", "\u2190 Go back", "Select a different provider")
7558
+ ];
7559
+ const picked = await p7.select({
7560
+ message: "Which model?",
7561
+ options,
7562
+ initialValue: recentModels[0].id
7563
+ });
7564
+ if (p7.isCancel(picked) || String(picked) === "__back__") {
7565
+ return "back";
7566
+ }
7567
+ if (String(picked) === BROWSE_ALL) {
7568
+ const browsed = await browseAllModels(provider, prefs);
7569
+ if (browsed === "back") {
7570
+ continue;
7571
+ }
7572
+ if (!browsed) return null;
7573
+ selectedModel = browsed;
7574
+ break;
7575
+ } else {
7576
+ selectedModel = recentModels.find((m) => m.id === String(picked));
7577
+ break;
7578
+ }
7579
+ } else {
7136
7580
  const browsed = await browseAllModels(provider, prefs);
7581
+ if (browsed === "back") {
7582
+ return "back";
7583
+ }
7137
7584
  if (!browsed) return null;
7138
7585
  selectedModel = browsed;
7139
- } else {
7140
- selectedModel = recentModels.find((m) => m.id === String(picked));
7586
+ break;
7141
7587
  }
7142
- } else {
7143
- const browsed = await browseAllModels(provider, prefs);
7144
- if (!browsed) return null;
7145
- selectedModel = browsed;
7146
7588
  }
7147
7589
  noteEnvConflicts(conflicts);
7148
7590
  const modelLabel = formatCodexModelLabel(selectedModel);
@@ -7473,6 +7915,71 @@ function toggleProviderEnabled(id) {
7473
7915
  return { toggled: true, enabled: provider.enabled };
7474
7916
  }
7475
7917
 
7918
+ // src/data/openai-oauth-models.ts
7919
+ var CHATGPT_CODEX_UNSUPPORTED_MODELS = /* @__PURE__ */ new Set([
7920
+ "gpt-5.5-fast"
7921
+ // confirmed: rejected by chatgpt.com/backend-api/codex
7922
+ ]);
7923
+ var OPENAI_OAUTH_MODEL_SEEDS = [
7924
+ // GPT-5.5 family (Pro)
7925
+ { id: "gpt-5.5", name: "GPT-5.5", reasoning: true },
7926
+ // GPT-5.4 family
7927
+ { id: "gpt-5.4", name: "GPT-5.4" },
7928
+ { id: "gpt-5.4-mini", name: "GPT-5.4 Mini" },
7929
+ // GPT-5 base (Pro / Plus)
7930
+ { id: "gpt-5", name: "GPT-5", reasoning: true },
7931
+ // o-series reasoning (Plus+)
7932
+ { id: "o4-mini", name: "o4 Mini", reasoning: true },
7933
+ { id: "o3", name: "o3", reasoning: true },
7934
+ { id: "o3-mini", name: "o3 Mini", reasoning: true },
7935
+ { id: "o1", name: "o1", reasoning: true },
7936
+ { id: "o1-mini", name: "o1 Mini", reasoning: true }
7937
+ ];
7938
+ function buildOpenAiOAuthModels() {
7939
+ return OPENAI_OAUTH_MODEL_SEEDS.map((seed) => {
7940
+ const prefix = seed.id.split("-")[0] ?? seed.id;
7941
+ return {
7942
+ id: seed.id,
7943
+ name: seed.name,
7944
+ upstreamModelId: seed.id,
7945
+ family: prefix,
7946
+ brand: deriveBrand(prefix),
7947
+ contextWindow: resolveContextWindow(seed.id),
7948
+ modelFormat: "openai",
7949
+ npm: "@ai-sdk/openai",
7950
+ reasoning: seed.reasoning
7951
+ };
7952
+ });
7953
+ }
7954
+
7955
+ // src/data/xai-oauth-models.ts
7956
+ var XAI_OAUTH_MODEL_SEEDS = [
7957
+ // Grok 4 family
7958
+ { id: "grok-4", name: "Grok 4", reasoning: true },
7959
+ { id: "grok-4-fast", name: "Grok 4 Fast", reasoning: true },
7960
+ // Grok 3 family
7961
+ { id: "grok-3", name: "Grok 3", reasoning: true },
7962
+ { id: "grok-3-fast", name: "Grok 3 Fast" },
7963
+ { id: "grok-3-mini", name: "Grok 3 Mini", reasoning: true },
7964
+ { id: "grok-3-mini-fast", name: "Grok 3 Mini Fast", reasoning: true }
7965
+ ];
7966
+ function buildXaiOAuthModels() {
7967
+ return XAI_OAUTH_MODEL_SEEDS.map((seed) => {
7968
+ const prefix = seed.id.split("-")[0] ?? seed.id;
7969
+ return {
7970
+ id: seed.id,
7971
+ name: seed.name,
7972
+ upstreamModelId: seed.id,
7973
+ family: prefix,
7974
+ brand: deriveBrand(prefix),
7975
+ contextWindow: resolveContextWindow(seed.id),
7976
+ modelFormat: "openai",
7977
+ npm: "@ai-sdk/xai",
7978
+ reasoning: seed.reasoning
7979
+ };
7980
+ });
7981
+ }
7982
+
7476
7983
  // src/registry/refresh-models.ts
7477
7984
  function modelInfoToCached(m, npm, apiUrl) {
7478
7985
  return {
@@ -7499,6 +8006,99 @@ async function refreshZenGoProvider(provider) {
7499
8006
  return modelInfoToCached(m, npm, apiUrl);
7500
8007
  });
7501
8008
  }
8009
+ async function refreshOAuthProvider(provider, accessToken) {
8010
+ const tpl = provider.templateId ?? provider.id;
8011
+ if (tpl === "openai") return refreshOpenAiOAuthModels(accessToken);
8012
+ if (tpl === "xai") return refreshXaiOAuthModels(accessToken);
8013
+ throw new Error(`refreshOAuthProvider: unsupported template "${tpl}"`);
8014
+ }
8015
+ function parseOpenAiModelEntries(body) {
8016
+ if (!body || typeof body !== "object") return [];
8017
+ const b = body;
8018
+ if (Array.isArray(b.models)) {
8019
+ return b.models.map((m) => ({ id: m.slug ?? "", name: m.title ?? m.name ?? m.slug ?? "", context_window: m.context_window })).filter((m) => m.id.length > 0);
8020
+ }
8021
+ if (Array.isArray(b.data)) {
8022
+ return b.data.map((m) => ({ id: m.id ?? "", name: m.name ?? m.id ?? "", context_window: m.context_window })).filter((m) => m.id.length > 0);
8023
+ }
8024
+ return [];
8025
+ }
8026
+ function buildDynamicOAuthModel(id, name, contextWindow, seedById) {
8027
+ if (seedById.has(id)) return seedById.get(id);
8028
+ const prefix = id.split("-")[0] ?? id;
8029
+ return {
8030
+ id,
8031
+ name,
8032
+ upstreamModelId: id,
8033
+ family: prefix,
8034
+ brand: deriveBrand(prefix),
8035
+ contextWindow: contextWindow ?? resolveContextWindow(id),
8036
+ modelFormat: "openai",
8037
+ npm: "@ai-sdk/openai",
8038
+ reasoning: modelPrefersResponsesApi(id)
8039
+ };
8040
+ }
8041
+ async function fetchJsonWithAuth(url, accessToken, timeoutMs) {
8042
+ try {
8043
+ const controller = new AbortController();
8044
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
8045
+ const response = await fetch(url, {
8046
+ headers: {
8047
+ Accept: "application/json",
8048
+ Authorization: `Bearer ${accessToken}`,
8049
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
8050
+ },
8051
+ signal: controller.signal
8052
+ }).finally(() => clearTimeout(timer));
8053
+ if (!response.ok) return null;
8054
+ return await response.json();
8055
+ } catch {
8056
+ return null;
8057
+ }
8058
+ }
8059
+ async function refreshOpenAiOAuthModels(accessToken) {
8060
+ const TIMEOUT_MS = 1e4;
8061
+ const seedById = new Map(buildOpenAiOAuthModels().map((m) => [m.id, m]));
8062
+ const toModels = (entries) => entries.map(({ id, name, context_window }) => buildDynamicOAuthModel(id, name, context_window, seedById));
8063
+ const claudeVersion = getInstalledClaudeVersion();
8064
+ const codexBody = await fetchJsonWithAuth(
8065
+ `https://chatgpt.com/backend-api/codex/models?client_version=${claudeVersion}`,
8066
+ accessToken,
8067
+ TIMEOUT_MS
8068
+ );
8069
+ const codexEntries = parseOpenAiModelEntries(codexBody);
8070
+ if (codexEntries.length > 0) {
8071
+ return { models: toModels(codexEntries), source: "live" };
8072
+ }
8073
+ const chatGptBody = await fetchJsonWithAuth(
8074
+ "https://chatgpt.com/backend-api/models",
8075
+ accessToken,
8076
+ TIMEOUT_MS
8077
+ );
8078
+ const chatGptEntries = parseOpenAiModelEntries(chatGptBody).filter(({ id }) => !CHATGPT_CODEX_UNSUPPORTED_MODELS.has(id));
8079
+ if (chatGptEntries.length > 0) {
8080
+ return { models: toModels(chatGptEntries), source: "live" };
8081
+ }
8082
+ return { models: [...seedById.values()], source: "seed" };
8083
+ }
8084
+ async function refreshXaiOAuthModels(accessToken) {
8085
+ const seed = buildXaiOAuthModels();
8086
+ const seedById = new Map(seed.map((m) => [m.id, m]));
8087
+ const body = await fetchJsonWithAuth("https://api.x.ai/v1/models", accessToken, 8e3);
8088
+ if (body) {
8089
+ const ids = (body.data ?? []).map((m) => m.id).filter((id) => !!id);
8090
+ if (ids.length > 0) {
8091
+ const live = ids.map((id) => {
8092
+ const cached = seedById.get(id);
8093
+ if (cached) return cached;
8094
+ const prefix = id.split("-")[0] ?? id;
8095
+ return { id, name: id, upstreamModelId: id, family: prefix, brand: deriveBrand(prefix), contextWindow: resolveContextWindow(id), modelFormat: "openai", npm: "@ai-sdk/xai", reasoning: modelPrefersResponsesApi(id) };
8096
+ });
8097
+ return { models: live, source: "live" };
8098
+ }
8099
+ }
8100
+ return { models: seed, source: "seed" };
8101
+ }
7502
8102
  async function refreshApiListProvider(provider, apiKey) {
7503
8103
  const npm = provider.api.npm ?? "@ai-sdk/openai-compatible";
7504
8104
  const catalogTemplate = resolveProviderTemplate(provider);
@@ -7578,6 +8178,25 @@ async function refreshProviderModels(providerId, apiKey, registry = loadRegistry
7578
8178
  let baseUrl;
7579
8179
  if (source === "zen-go-api") {
7580
8180
  models = await refreshZenGoProvider(provider);
8181
+ } else if (provider.authType === "oauth" && (["openai", "xai", "xai-oauth"].includes(provider.templateId ?? provider.id) || provider.id === "openai-oauth" || provider.id === "xai-oauth")) {
8182
+ if (!apiKey) {
8183
+ return {
8184
+ id: provider.id,
8185
+ name: provider.name,
8186
+ ok: false,
8187
+ reason: "OAuth token not available \u2014 try signing in again with relay-ai providers auth."
8188
+ };
8189
+ }
8190
+ const oauthResult = await refreshOAuthProvider(provider, apiKey);
8191
+ models = oauthResult.models;
8192
+ if (models.length === 0) {
8193
+ return {
8194
+ id: provider.id,
8195
+ name: provider.name,
8196
+ ok: false,
8197
+ reason: "No models available for this OAuth provider \u2014 try signing in again."
8198
+ };
8199
+ }
7581
8200
  } else {
7582
8201
  const template = resolveProviderTemplate(provider);
7583
8202
  const keyOptional = template?.apiKeyOptional === true;
@@ -7688,6 +8307,7 @@ async function refreshAllProviderModels(resolveKey) {
7688
8307
  // src/registry/provider-auth.ts
7689
8308
  import pc9 from "picocolors";
7690
8309
  import * as p9 from "@clack/prompts";
8310
+ import open from "open";
7691
8311
 
7692
8312
  // src/registry/auth-broker.ts
7693
8313
  import { spawn as spawn3 } from "child_process";
@@ -7715,22 +8335,30 @@ async function runOpencodeAuthBroker(providerId, options = {}) {
7715
8335
  }
7716
8336
 
7717
8337
  // src/registry/provider-auth.ts
8338
+ var OPENAI_DISPLAY = "OpenAI ChatGPT Plus/Pro";
7718
8339
  var PROVIDER_DISPLAY = {
7719
8340
  xai: "xAI Grok (SuperGrok)",
7720
- openai: "OpenAI ChatGPT Plus/Pro",
8341
+ "xai-oauth": "xAI Grok (SuperGrok)",
8342
+ openai: OPENAI_DISPLAY,
8343
+ "openai-oauth": OPENAI_DISPLAY,
7721
8344
  "github-copilot": "GitHub Copilot (Individual / Business)"
7722
8345
  };
8346
+ function openBrowser(url) {
8347
+ open(url).catch(() => {
8348
+ });
8349
+ }
7723
8350
  async function runNativeDeviceCode(providerId) {
7724
8351
  const label = PROVIDER_DISPLAY[providerId];
7725
8352
  printOAuthStepsPanel(`${label} \u2014 Sign in`, label);
7726
8353
  const spinner9 = p9.spinner();
7727
8354
  spinner9.start("Waiting for authorization...");
7728
8355
  try {
7729
- if (providerId === "xai") {
8356
+ if (providerId === "xai" || providerId === "xai-oauth") {
7730
8357
  const tokens2 = await runXaiDeviceCodeFlow(({ url, userCode }) => {
7731
8358
  spinner9.stop("");
7732
8359
  p9.log.info(`Visit: ${pc9.cyan(url)}`);
7733
8360
  p9.log.info(`Enter code: ${pc9.bold(userCode)}`);
8361
+ openBrowser(url);
7734
8362
  spinner9.start("Waiting for authorization...");
7735
8363
  });
7736
8364
  spinner9.stop(pc9.green("Signed in to xAI"));
@@ -7741,6 +8369,7 @@ async function runNativeDeviceCode(providerId) {
7741
8369
  spinner9.stop("");
7742
8370
  p9.log.info(`Visit: ${pc9.cyan(url)}`);
7743
8371
  p9.log.info(`Enter code: ${pc9.bold(userCode)}`);
8372
+ openBrowser(url);
7744
8373
  spinner9.start("Waiting for authorization...");
7745
8374
  });
7746
8375
  spinner9.stop(pc9.green("Signed in to GitHub Copilot"));
@@ -7750,6 +8379,7 @@ async function runNativeDeviceCode(providerId) {
7750
8379
  spinner9.stop("");
7751
8380
  p9.log.info(`Visit: ${pc9.cyan(url)}`);
7752
8381
  p9.log.info(`Enter code: ${pc9.bold(userCode)}`);
8382
+ openBrowser(url);
7753
8383
  spinner9.start("Waiting for authorization...");
7754
8384
  });
7755
8385
  spinner9.stop(pc9.green("Signed in to OpenAI ChatGPT"));
@@ -7760,28 +8390,32 @@ async function runNativeDeviceCode(providerId) {
7760
8390
  }
7761
8391
  }
7762
8392
  async function upsertOAuthProvider(providerId, cred) {
8393
+ const registryId = toOAuthRegistryId(providerId);
8394
+ const templateId = providerId.replace(/-oauth$/, "") || providerId;
7763
8395
  const registry = loadRegistry();
7764
- const authRef = oauthAuthRef(providerId);
7765
- let entry = registry.providers.find((pr) => pr.id === providerId);
8396
+ const authRef = oauthAuthRef(registryId);
8397
+ let entry = registry.providers.find((pr) => pr.id === registryId);
7766
8398
  if (!entry) {
7767
8399
  const raw = await fetchRawOpencodeProviders();
7768
8400
  if (raw) {
7769
8401
  const { providers } = buildImportProviderList(raw, { [providerId]: cred });
7770
- const lp = providers.find((pr) => pr.id === providerId);
8402
+ const lp = providers.find((pr) => pr.id === registryId || pr.id === providerId);
7771
8403
  if (lp) {
7772
- entry = localProviderToRegistry(lp, { authType: "oauth", authRef }) ?? void 0;
8404
+ const converted = localProviderToRegistry(lp, { authType: "oauth", authRef });
8405
+ if (converted) entry = { ...converted, id: registryId, templateId };
7773
8406
  }
7774
8407
  }
7775
8408
  }
7776
8409
  if (!entry) {
7777
- const template = getTemplateById(providerId);
8410
+ const template = getTemplateById(templateId);
7778
8411
  if (!template) {
7779
8412
  throw new Error(`Provider "${providerId}" is not in your registry and has no template`);
7780
8413
  }
8414
+ const displayName = registryId === "openai-oauth" ? "OpenAI (ChatGPT)" : registryId === "xai-oauth" ? "xAI (SuperGrok)" : template.name;
7781
8415
  entry = {
7782
- id: providerId,
7783
- templateId: template.id,
7784
- name: template.name,
8416
+ id: registryId,
8417
+ templateId,
8418
+ name: displayName,
7785
8419
  enabled: true,
7786
8420
  authRef,
7787
8421
  authType: "oauth",
@@ -7789,24 +8423,25 @@ async function upsertOAuthProvider(providerId, cred) {
7789
8423
  addedAt: (/* @__PURE__ */ new Date()).toISOString()
7790
8424
  };
7791
8425
  } else {
7792
- entry = { ...entry, authType: "oauth", authRef };
8426
+ entry = { ...entry, authType: "oauth", authRef, templateId };
7793
8427
  }
7794
- const idx = registry.providers.findIndex((pr) => pr.id === providerId);
8428
+ const idx = registry.providers.findIndex((pr) => pr.id === registryId);
7795
8429
  if (idx >= 0) registry.providers[idx] = entry;
7796
8430
  else registry.providers.push(entry);
7797
8431
  saveRegistry(registry);
7798
8432
  return entry;
7799
8433
  }
7800
8434
  async function authenticateProvider(providerId, options = {}) {
8435
+ const registryId = toOAuthRegistryId(providerId);
7801
8436
  if (!supportsNativeOAuth(providerId)) {
7802
8437
  if (findOpencodeBinary()) {
7803
8438
  const cred2 = await runOpencodeAuthBroker(providerId, { method: options.brokerMethod });
7804
- const saved2 = await saveProviderCredential(oauthAuthRef(providerId), oauthCredentialToKeychainJson(cred2));
8439
+ const saved2 = await saveProviderCredential(oauthAuthRef(registryId), oauthCredentialToKeychainJson(cred2));
7805
8440
  if (!saved2) {
7806
8441
  p9.log.warn("Could not save OAuth tokens to Keychain \u2014 session may not persist.");
7807
8442
  }
7808
8443
  const registryProvider2 = await upsertOAuthProvider(providerId, cred2);
7809
- return { providerId, credential: cred2, registryProvider: registryProvider2 };
8444
+ return { providerId: registryId, credential: cred2, registryProvider: registryProvider2 };
7810
8445
  }
7811
8446
  throw new Error(
7812
8447
  `Native OAuth is only built in for xai and openai. Install OpenCode for other OAuth providers.`
@@ -7830,7 +8465,7 @@ async function authenticateProvider(providerId, options = {}) {
7830
8465
  }
7831
8466
  }
7832
8467
  const cred = method === "broker" ? await runOpencodeAuthBroker(providerId, { method: options.brokerMethod }) : await runNativeDeviceCode(providerId);
7833
- const saved = await saveProviderCredential(oauthAuthRef(providerId), oauthCredentialToKeychainJson(cred));
8468
+ const saved = await saveProviderCredential(oauthAuthRef(registryId), oauthCredentialToKeychainJson(cred));
7834
8469
  if (!saved) {
7835
8470
  p9.log.warn("Could not save OAuth tokens to Keychain \u2014 session may not persist.");
7836
8471
  }
@@ -7838,12 +8473,12 @@ async function authenticateProvider(providerId, options = {}) {
7838
8473
  const refreshSpinner = p9.spinner();
7839
8474
  refreshSpinner.start("Refreshing model list...");
7840
8475
  try {
7841
- await refreshProviderModels(providerId, cred.access);
8476
+ await refreshProviderModels(registryId, cred.access);
7842
8477
  refreshSpinner.stop("Models refreshed");
7843
8478
  } catch {
7844
8479
  refreshSpinner.stop("Could not refresh models \u2014 run relay-ai providers refresh-models later");
7845
8480
  }
7846
- return { providerId, credential: cred, registryProvider };
8481
+ return { providerId: registryId, credential: cred, registryProvider };
7847
8482
  }
7848
8483
  function providerAuthHelpText() {
7849
8484
  return `${pc9.bold("relay-ai providers auth")} \u2014 sign in with OAuth
@@ -8536,7 +9171,7 @@ import * as p13 from "@clack/prompts";
8536
9171
  import { createServer as createServer3 } from "http";
8537
9172
 
8538
9173
  // src/codex-responses-adapter.ts
8539
- import { streamText as streamText2, generateText as generateText2, tool as tool2, jsonSchema as jsonSchema2 } from "ai";
9174
+ import { streamText as streamText3, generateText as generateText3, tool as tool3, jsonSchema as jsonSchema3 } from "ai";
8540
9175
  function messageText(content) {
8541
9176
  if (typeof content === "string") return content;
8542
9177
  return (content ?? []).map((p19) => p19.type === "output_text" || p19.type === "input_text" || p19.type === "text" ? p19.text ?? "" : "").join("");
@@ -8660,9 +9295,9 @@ function translateResponsesTools(tools) {
8660
9295
  const out = {};
8661
9296
  for (const t of tools) {
8662
9297
  if (t.type !== "function" || !t.name) continue;
8663
- out[t.name] = tool2({
9298
+ out[t.name] = tool3({
8664
9299
  description: t.description ?? "",
8665
- inputSchema: jsonSchema2(t.parameters ?? { type: "object", properties: {} })
9300
+ inputSchema: jsonSchema3(t.parameters ?? { type: "object", properties: {} })
8666
9301
  });
8667
9302
  }
8668
9303
  return Object.keys(out).length ? out : void 0;
@@ -8905,24 +9540,24 @@ async function writeResponsesStream(fullStream, modelId, write) {
8905
9540
  });
8906
9541
  outputItems.unshift(reasoningItem);
8907
9542
  }
8908
- for (const tool3 of toolStates) {
9543
+ for (const tool4 of toolStates) {
8909
9544
  emit("response.function_call_arguments.done", {
8910
9545
  type: "response.function_call_arguments.done",
8911
- item_id: tool3.itemId,
8912
- output_index: tool3.outputIndex,
8913
- arguments: tool3.args
9546
+ item_id: tool4.itemId,
9547
+ output_index: tool4.outputIndex,
9548
+ arguments: tool4.args
8914
9549
  });
8915
9550
  const fcItem = {
8916
9551
  type: "function_call",
8917
- id: tool3.itemId,
8918
- call_id: tool3.callId,
8919
- name: tool3.name,
8920
- arguments: tool3.args,
9552
+ id: tool4.itemId,
9553
+ call_id: tool4.callId,
9554
+ name: tool4.name,
9555
+ arguments: tool4.args,
8921
9556
  status: "completed"
8922
9557
  };
8923
9558
  emit("response.output_item.done", {
8924
9559
  type: "response.output_item.done",
8925
- output_index: tool3.outputIndex,
9560
+ output_index: tool4.outputIndex,
8926
9561
  item: fcItem
8927
9562
  });
8928
9563
  outputItems.push(fcItem);
@@ -8941,7 +9576,7 @@ async function writeResponsesStream(fullStream, modelId, write) {
8941
9576
  });
8942
9577
  }
8943
9578
  async function streamResponsesResponse(model, params, modelId, write) {
8944
- const result = streamText2({ model, ...params });
9579
+ const result = streamText3({ model, ...params });
8945
9580
  Promise.resolve(result.text).catch(() => {
8946
9581
  });
8947
9582
  Promise.resolve(result.toolCalls).catch(() => {
@@ -8955,7 +9590,7 @@ async function streamResponsesResponse(model, params, modelId, write) {
8955
9590
  await writeResponsesStream(result.fullStream, modelId, write);
8956
9591
  }
8957
9592
  async function generateResponsesResponse(model, params, modelId) {
8958
- const r = await generateText2({ model, ...params });
9593
+ const r = await generateText3({ model, ...params });
8959
9594
  const createdAt = Math.floor(Date.now() / 1e3);
8960
9595
  const responseId = newResponseId();
8961
9596
  const output = [];
@@ -9143,6 +9778,24 @@ async function startCodexProxy(routes, options = {}) {
9143
9778
  if (debug) {
9144
9779
  log17(`-> ${req.method} ${url} content-type=${req.headers["content-type"] ?? "(none)"} content-encoding=${req.headers["content-encoding"] ?? "(none)"} content-length=${req.headers["content-length"] ?? "(none)"}`);
9145
9780
  }
9781
+ if (!requireAuth && req.method === "POST") {
9782
+ const origin = req.headers.origin;
9783
+ const referer = req.headers.referer;
9784
+ const isValidLoopback = (uStr) => {
9785
+ if (!uStr) return true;
9786
+ try {
9787
+ const parsed = new URL(Array.isArray(uStr) ? uStr[0] : uStr);
9788
+ const h = parsed.hostname;
9789
+ return h === "127.0.0.1" || h === "localhost" || h === "::1";
9790
+ } catch {
9791
+ return false;
9792
+ }
9793
+ };
9794
+ if (!isValidLoopback(origin) || !isValidLoopback(referer)) {
9795
+ sendJson(res, 403, { error: { message: "Forbidden origin", type: "invalid_request_error" } });
9796
+ return;
9797
+ }
9798
+ }
9146
9799
  if (req.method === "GET" && url === "/health") {
9147
9800
  sendJson(res, 200, { ok: true });
9148
9801
  return;
@@ -9220,6 +9873,9 @@ async function startCodexProxy(routes, options = {}) {
9220
9873
  const modelId = String(body.model ?? "");
9221
9874
  const resolved = resolveModel(routes, models, modelId);
9222
9875
  if (!resolved) {
9876
+ if (debug) {
9877
+ log17(`resolveModel failed: requested="${modelId}" known=[${routes.map((r) => r.modelId).join(", ")}]`);
9878
+ }
9223
9879
  sendJson(res, 404, { error: { message: `Unknown model: ${modelId}`, type: "invalid_request_error" } });
9224
9880
  return;
9225
9881
  }
@@ -9337,7 +9993,7 @@ function resolveCodexRoute(provider, model, apiKey) {
9337
9993
  reasoning: model.reasoning,
9338
9994
  interleavedReasoningField: model.interleavedReasoningField
9339
9995
  };
9340
- if (provider.id === "openai" && provider.authType !== "oauth" && model.modelFormat === "openai") {
9996
+ if (model.npm === "@ai-sdk/openai" && provider.authType !== "oauth" && model.modelFormat === "openai") {
9341
9997
  return { tier: "direct", ...base };
9342
9998
  }
9343
9999
  return { tier: "proxy", ...base };
@@ -9372,6 +10028,7 @@ function codexProviderEnvKey(providerId) {
9372
10028
  const known = {
9373
10029
  openai: "OPENAI_API_KEY",
9374
10030
  xai: "XAI_API_KEY",
10031
+ "xai-oauth": "XAI_API_KEY",
9375
10032
  anthropic: "ANTHROPIC_API_KEY",
9376
10033
  google: "GEMINI_API_KEY"
9377
10034
  };
@@ -9672,7 +10329,7 @@ function launchCodex(modelId, env, extraArgs) {
9672
10329
  // src/codex/prompts.ts
9673
10330
  import pc11 from "picocolors";
9674
10331
  import * as p11 from "@clack/prompts";
9675
- async function pickCodexProvider(providers, prefs, hasFavorites = false) {
10332
+ async function pickCodexProvider(providers, prefs, hasFavorites = false, initialProviderId) {
9676
10333
  if (providers.length === 0 && !hasFavorites) return null;
9677
10334
  const options = providers.map((lp) => providerSelectOption(lp));
9678
10335
  if (hasFavorites) {
@@ -9682,7 +10339,7 @@ async function pickCodexProvider(providers, prefs, hasFavorites = false) {
9682
10339
  hint: `${prefs.favoriteModels?.length ?? 0} saved favorites`
9683
10340
  });
9684
10341
  }
9685
- const initial = prefs.lastCodexProvider && options.some((o) => o.value === prefs.lastCodexProvider) ? prefs.lastCodexProvider : options[0].value;
10342
+ const initial = initialProviderId && options.some((o) => o.value === initialProviderId) ? initialProviderId : prefs.lastCodexProvider && options.some((o) => o.value === prefs.lastCodexProvider) ? prefs.lastCodexProvider : options[0].value;
9686
10343
  const chosen = await p11.select({
9687
10344
  message: "Which provider for Codex?",
9688
10345
  options,
@@ -9698,32 +10355,43 @@ async function pickCodexProvider(providers, prefs, hasFavorites = false) {
9698
10355
  async function pickCodexModel(provider, prefs) {
9699
10356
  const recentIds = (prefs.recentModelsByProvider?.[provider.id] ?? []).slice(0, 3);
9700
10357
  const recentModels = recentIds.map((id) => provider.models.find((m) => m.id === id)).filter((m) => m !== void 0);
9701
- let selectedModel;
9702
- if (recentModels.length > 0) {
9703
- const options = [
9704
- ...recentModels.map((m) => modelSelectOption(m, "recent")),
9705
- navOption("__browse_all__", "Browse all models \u2192", `${provider.models.length} available`)
9706
- ];
9707
- const picked = await p11.select({
9708
- message: `Model for ${provider.name}?`,
9709
- options,
9710
- initialValue: recentModels[0].id
9711
- });
9712
- if (p11.isCancel(picked)) {
9713
- p11.cancel("Cancelled.");
9714
- return null;
9715
- }
9716
- if (String(picked) === "__browse_all__") {
10358
+ let selectedModel = null;
10359
+ while (true) {
10360
+ if (recentModels.length > 0) {
10361
+ const options = [
10362
+ ...recentModels.map((m) => modelSelectOption(m, "recent")),
10363
+ navOption("__browse_all__", "Browse all models \u2192", `${provider.models.length} available`),
10364
+ navOption("__back__", "\u2190 Go back", "Select a different provider")
10365
+ ];
10366
+ const picked = await p11.select({
10367
+ message: `Model for ${provider.name}?`,
10368
+ options,
10369
+ initialValue: recentModels[0].id
10370
+ });
10371
+ if (p11.isCancel(picked) || String(picked) === "__back__") {
10372
+ return "back";
10373
+ }
10374
+ if (String(picked) === "__browse_all__") {
10375
+ const browsed = await browseAllModels(provider, prefs);
10376
+ if (browsed === "back") {
10377
+ continue;
10378
+ }
10379
+ if (!browsed) return null;
10380
+ selectedModel = browsed;
10381
+ break;
10382
+ } else {
10383
+ selectedModel = recentModels.find((m) => m.id === String(picked));
10384
+ break;
10385
+ }
10386
+ } else {
9717
10387
  const browsed = await browseAllModels(provider, prefs);
10388
+ if (browsed === "back") {
10389
+ return "back";
10390
+ }
9718
10391
  if (!browsed) return null;
9719
10392
  selectedModel = browsed;
9720
- } else {
9721
- selectedModel = recentModels.find((m) => m.id === String(picked));
10393
+ break;
9722
10394
  }
9723
- } else {
9724
- const browsed = await browseAllModels(provider, prefs);
9725
- if (!browsed) return null;
9726
- selectedModel = browsed;
9727
10395
  }
9728
10396
  return selectedModel;
9729
10397
  }
@@ -9846,13 +10514,18 @@ function buildFavoritesAppCatalog(resolved) {
9846
10514
  // src/codex/favorites-launch.ts
9847
10515
  import * as p12 from "@clack/prompts";
9848
10516
  function buildCodexProxyRoutesFromResolved(resolved, providersById) {
9849
- return resolved.map((r) => {
10517
+ const skippedOAuth = [];
10518
+ const routes = resolved.map((r) => {
9850
10519
  const provider = providersById.get(r.providerId);
9851
10520
  if (!provider) return void 0;
9852
10521
  const model = r.model;
10522
+ if (!r.apiKey && provider.authType === "oauth") {
10523
+ skippedOAuth.push(`${r.providerId}/${model.id}`);
10524
+ return void 0;
10525
+ }
9853
10526
  const route = resolveCodexRoute(provider, model, r.apiKey);
9854
10527
  return {
9855
- modelId: route.modelId,
10528
+ modelId: codexCliFavoritesSlug(r.providerId, model.id),
9856
10529
  npm: route.npm,
9857
10530
  apiKey: route.apiKey,
9858
10531
  baseURL: route.baseURL,
@@ -9862,6 +10535,12 @@ function buildCodexProxyRoutesFromResolved(resolved, providersById) {
9862
10535
  oauthAccountId: route.oauthAccountId
9863
10536
  };
9864
10537
  }).filter((r) => r !== void 0);
10538
+ if (skippedOAuth.length > 0) {
10539
+ p12.log.warn(
10540
+ `Skipped ${skippedOAuth.length} OAuth favorite(s) (OAuth auth not supported in favorites catalog): ${skippedOAuth.join(", ")}`
10541
+ );
10542
+ }
10543
+ return routes;
9865
10544
  }
9866
10545
  function resolveCodexFavorites(activeProvider, selectedModel, compatible, favorites, agent, zenGoApiKey) {
9867
10546
  const ctx = {
@@ -10381,26 +11060,35 @@ Error: ${launchPlan.error}
10381
11060
  p13.log.step(`Using ${selectedModel.name || selectedModel.id} (${activeProvider.name})`);
10382
11061
  }
10383
11062
  } else if (!configOnly) {
10384
- const pickedProvider = await pickCodexProvider(compatible, prefs, favoritesActive);
10385
- if (!pickedProvider) return 0;
10386
- if (pickedProvider === "__favorites__") {
10387
- const favoriteProviders = compatible.map((provider) => ({
10388
- ...provider,
10389
- models: routableModelsForProvider(provider, "codex")
10390
- }));
10391
- const favoriteStart = resolveFirstAvailableFavorite(favorites, favoriteProviders);
10392
- if (!favoriteStart) {
10393
- p13.log.warn("No saved Codex favorites are currently available.");
10394
- return 0;
11063
+ let currentInitialProvider = prefs.lastCodexProvider && compatible.some((o) => o.id === prefs.lastCodexProvider) ? prefs.lastCodexProvider : compatible[0].id;
11064
+ while (true) {
11065
+ const pickedProvider = await pickCodexProvider(compatible, prefs, favoritesActive, currentInitialProvider);
11066
+ if (!pickedProvider) return 0;
11067
+ if (pickedProvider === "__favorites__") {
11068
+ const favoriteProviders = compatible.map((provider) => ({
11069
+ ...provider,
11070
+ models: routableModelsForProvider(provider, "codex")
11071
+ }));
11072
+ const favoriteStart = resolveFirstAvailableFavorite(favorites, favoriteProviders);
11073
+ if (!favoriteStart) {
11074
+ p13.log.warn("No saved Codex favorites are currently available.");
11075
+ return 0;
11076
+ }
11077
+ activeProvider = favoriteStart.provider;
11078
+ selectedModel = favoriteStart.model;
11079
+ p13.log.step(`Loaded Favorites Catalog. Starting model: ${selectedModel.name || selectedModel.id} (${activeProvider.name})`);
11080
+ break;
11081
+ } else {
11082
+ activeProvider = pickedProvider;
11083
+ const pickedModelResult = await pickCodexModel(activeProvider, prefs);
11084
+ if (pickedModelResult === "back") {
11085
+ currentInitialProvider = activeProvider.id;
11086
+ continue;
11087
+ }
11088
+ if (!pickedModelResult) return 0;
11089
+ selectedModel = pickedModelResult;
11090
+ break;
10395
11091
  }
10396
- activeProvider = favoriteStart.provider;
10397
- selectedModel = favoriteStart.model;
10398
- p13.log.step(`Loaded Favorites Catalog. Starting model: ${selectedModel.name || selectedModel.id} (${activeProvider.name})`);
10399
- } else {
10400
- activeProvider = pickedProvider;
10401
- const pickedModel = await pickCodexModel(activeProvider, prefs);
10402
- if (!pickedModel) return 0;
10403
- selectedModel = pickedModel;
10404
11092
  }
10405
11093
  }
10406
11094
  let resolvedFavorites = [];
@@ -11045,9 +11733,9 @@ function openCodexAppAt(path) {
11045
11733
  }
11046
11734
  if (process.platform === "win32") {
11047
11735
  if (path.startsWith("shell:AppsFolder\\")) {
11048
- runPowerShell(`Start-Process ${JSON.stringify(path)}`);
11736
+ runPowerShell(`Start-Process '${path.replace(/'/g, "''")}'`);
11049
11737
  } else {
11050
- runPowerShell(`Start-Process -FilePath ${JSON.stringify(path)}`);
11738
+ runPowerShell(`Start-Process -FilePath '${path.replace(/'/g, "''")}'`);
11051
11739
  }
11052
11740
  }
11053
11741
  }
@@ -11380,23 +12068,32 @@ async function runCodexAppCommand(args, opts = {}) {
11380
12068
  );
11381
12069
  let selectedModel = activeProvider.models.find((m) => m.id === prefs.lastCodexModel) ?? activeProvider.models[0];
11382
12070
  if (!configOnly) {
11383
- const pickedProvider = await pickCodexProvider(compatible, prefs, favoritesActive);
11384
- if (!pickedProvider) return 0;
11385
- if (pickedProvider === "__favorites__") {
11386
- const favoriteProviders = compatible.map(providerForCodexPicker);
11387
- const favoriteStart = resolveFirstAvailableFavorite(favorites, favoriteProviders);
11388
- if (!favoriteStart) {
11389
- p15.log.warn("No saved Codex App favorites are currently available.");
11390
- return 0;
12071
+ let currentInitialProvider = prefs.lastCodexProvider && compatible.some((o) => o.id === prefs.lastCodexProvider) ? prefs.lastCodexProvider : compatible[0].id;
12072
+ while (true) {
12073
+ const pickedProvider = await pickCodexProvider(compatible, prefs, favoritesActive, currentInitialProvider);
12074
+ if (!pickedProvider) return 0;
12075
+ if (pickedProvider === "__favorites__") {
12076
+ const favoriteProviders = compatible.map(providerForCodexPicker);
12077
+ const favoriteStart = resolveFirstAvailableFavorite(favorites, favoriteProviders);
12078
+ if (!favoriteStart) {
12079
+ p15.log.warn("No saved Codex App favorites are currently available.");
12080
+ return 0;
12081
+ }
12082
+ activeProvider = favoriteStart.provider;
12083
+ selectedModel = favoriteStart.model;
12084
+ p15.log.step(`Loaded Favorites Catalog. Starting model: ${selectedModel.name || selectedModel.id} (${activeProvider.name})`);
12085
+ break;
12086
+ } else {
12087
+ activeProvider = providerForCodexPicker(pickedProvider);
12088
+ const pickedModelResult = await pickCodexModel(activeProvider, prefs);
12089
+ if (pickedModelResult === "back") {
12090
+ currentInitialProvider = activeProvider.id;
12091
+ continue;
12092
+ }
12093
+ if (!pickedModelResult) return 0;
12094
+ selectedModel = pickedModelResult;
12095
+ break;
11391
12096
  }
11392
- activeProvider = favoriteStart.provider;
11393
- selectedModel = favoriteStart.model;
11394
- p15.log.step(`Loaded Favorites Catalog. Starting model: ${selectedModel.name || selectedModel.id} (${activeProvider.name})`);
11395
- } else {
11396
- activeProvider = providerForCodexPicker(pickedProvider);
11397
- const pickedModel = await pickCodexModel(activeProvider, prefs);
11398
- if (!pickedModel) return 0;
11399
- selectedModel = pickedModel;
11400
12097
  }
11401
12098
  }
11402
12099
  const regEntry = loadRegistry().providers.find((pr) => pr.id === activeProvider.id);
@@ -11575,7 +12272,7 @@ import * as p17 from "@clack/prompts";
11575
12272
  import { existsSync as existsSync16, readFileSync as readFileSync13, writeFileSync as writeFileSync7, mkdirSync as mkdirSync9 } from "fs";
11576
12273
  import { homedir as homedir11 } from "os";
11577
12274
  import { join as join17, dirname as dirname7 } from "path";
11578
- import { randomUUID } from "crypto";
12275
+ import { randomUUID as randomUUID2 } from "crypto";
11579
12276
  function getClaudeDesktopHome() {
11580
12277
  if (process.platform === "win32") {
11581
12278
  return join17(process.env.APPDATA || join17(homedir11(), "AppData", "Roaming"), "Claude-3p");
@@ -11613,7 +12310,7 @@ function buildRelayAiConfig(proxyPort) {
11613
12310
  };
11614
12311
  }
11615
12312
  function writeRelayAiConfig(proxyPort) {
11616
- const uuid = randomUUID();
12313
+ const uuid = randomUUID2();
11617
12314
  const configPath = join17(getConfigLibraryPath(), `${uuid}.json`);
11618
12315
  const config = buildRelayAiConfig(proxyPort);
11619
12316
  mkdirSync9(dirname7(configPath), { recursive: true });
@@ -11878,9 +12575,9 @@ function openClaudeAppAt(path) {
11878
12575
  }
11879
12576
  if (process.platform === "win32") {
11880
12577
  if (path.startsWith("shell:AppsFolder\\")) {
11881
- runPowerShell2(`Start-Process ${JSON.stringify(path)}`);
12578
+ runPowerShell2(`Start-Process '${path.replace(/'/g, "''")}'`);
11882
12579
  } else {
11883
- runPowerShell2(`Start-Process -FilePath ${JSON.stringify(path)}`);
12580
+ runPowerShell2(`Start-Process -FilePath '${path.replace(/'/g, "''")}'`);
11884
12581
  }
11885
12582
  }
11886
12583
  }
@@ -13002,7 +13699,7 @@ async function launchClaudeViaCatalog(catalogRoutes, startingRoute, contextWindo
13002
13699
  const childEnv = buildChildEnv(
13003
13700
  `http://127.0.0.1:${proxyHandle.port}`,
13004
13701
  startingRoute.aliasId,
13005
- "catalog-proxy",
13702
+ proxyHandle.token,
13006
13703
  proxyHandle.port,
13007
13704
  contextWindow,
13008
13705
  true
@@ -13086,39 +13783,86 @@ async function runModelsCommand() {
13086
13783
  });
13087
13784
  if (p18.isCancel(addPath)) continue;
13088
13785
  let provider;
13089
- let browsed;
13786
+ let browsedMultiple = [];
13090
13787
  if (addPath === "global") {
13091
13788
  const globalPick = await pickGlobalFavoriteModel(allProviders, favorites);
13092
13789
  if (globalPick === null) continue;
13093
13790
  if (globalPick !== ADD_BY_PROVIDER) {
13094
13791
  provider = allProviders.find((ap) => ap.id === globalPick.providerId);
13095
- browsed = globalPick.model;
13792
+ browsedMultiple = [globalPick.model];
13096
13793
  }
13097
13794
  }
13098
- if (!browsed) {
13099
- const providerOptions = allProviders.map((ap) => providerSelectOption(ap));
13100
- const pickedProviderId = await p18.select({
13101
- message: "Which provider?",
13102
- options: providerOptions
13103
- });
13104
- if (p18.isCancel(pickedProviderId)) continue;
13105
- provider = allProviders.find((ap) => ap.id === pickedProviderId);
13106
- browsed = await browseAllModels(provider, prefs) ?? void 0;
13107
- if (!browsed) continue;
13795
+ if (browsedMultiple.length === 0) {
13796
+ let currentInitialProvider = void 0;
13797
+ while (true) {
13798
+ const providerOptions = allProviders.map((ap) => providerSelectOption(ap));
13799
+ const pickedProviderId = await p18.select({
13800
+ message: "Which provider?",
13801
+ options: providerOptions,
13802
+ initialValue: currentInitialProvider
13803
+ });
13804
+ if (p18.isCancel(pickedProviderId)) break;
13805
+ provider = allProviders.find((ap) => ap.id === pickedProviderId);
13806
+ const options2 = provider.models.map((m) => {
13807
+ const favorited = isFavorite(favorites, { providerId: provider.id, modelId: m.id });
13808
+ const label = formatCodexModelLabel(m);
13809
+ return {
13810
+ value: m.id,
13811
+ label: fmtModel(label, m.id),
13812
+ hint: favorited ? pc16.yellow("\u2605 already favorite") : ""
13813
+ };
13814
+ });
13815
+ const pickedModelIds = await p18.multiselect({
13816
+ message: `Select models to add from ${provider.name} ${pc16.dim("(Space to select, Enter to confirm)")}`,
13817
+ options: options2,
13818
+ required: false
13819
+ });
13820
+ if (p18.isCancel(pickedModelIds)) {
13821
+ currentInitialProvider = provider.id;
13822
+ continue;
13823
+ }
13824
+ if (pickedModelIds.length === 0) {
13825
+ currentInitialProvider = provider.id;
13826
+ continue;
13827
+ }
13828
+ browsedMultiple = provider.models.filter((m) => pickedModelIds.includes(m.id));
13829
+ break;
13830
+ }
13831
+ if (browsedMultiple.length === 0) continue;
13108
13832
  }
13109
- const fav = { providerId: provider.id, modelId: browsed.id };
13110
- const result = addFavorite(favorites, fav);
13111
- if (!result.ok) {
13112
- if (result.reason === "duplicate") {
13113
- p18.log.warn(`${browsed.name || browsed.id} is already in your favorites.`);
13833
+ const addedModels = [];
13834
+ let duplicateCount = 0;
13835
+ let limitReached = false;
13836
+ for (const model of browsedMultiple) {
13837
+ const fav = { providerId: provider.id, modelId: model.id };
13838
+ const result = addFavorite(favorites, fav);
13839
+ if (!result.ok) {
13840
+ if (result.reason === "duplicate") {
13841
+ duplicateCount++;
13842
+ } else {
13843
+ limitReached = true;
13844
+ break;
13845
+ }
13114
13846
  } else {
13115
- p18.log.warn(`Limit of ${MAX_MODEL_CATALOG} favorites reached \u2014 remove one first.`);
13847
+ favorites = result.list;
13848
+ favoritesDirty = true;
13849
+ addedModels.push(model);
13116
13850
  }
13117
- continue;
13118
13851
  }
13119
- favorites = result.list;
13120
- favoritesDirty = true;
13121
- p18.log.success(`Added ${browsed.name || browsed.id} (${provider.name}) to favorites.`);
13852
+ if (addedModels.length > 0) {
13853
+ if (addedModels.length === 1) {
13854
+ const modelName = addedModels[0].name || addedModels[0].id;
13855
+ p18.log.success(`Added ${modelName} (${provider.name}) to favorites.`);
13856
+ } else {
13857
+ p18.log.success(`Added ${addedModels.length} models from ${provider.name} to favorites.`);
13858
+ }
13859
+ }
13860
+ if (duplicateCount > 0) {
13861
+ p18.log.warn(`${duplicateCount} selected model(s) were already in your favorites.`);
13862
+ }
13863
+ if (limitReached) {
13864
+ p18.log.warn(`Limit of ${MAX_MODEL_CATALOG} favorites reached \u2014 some selected models could not be added.`);
13865
+ }
13122
13866
  } else if (choice.startsWith("fav-")) {
13123
13867
  const idx = parseInt(choice.slice(4), 10);
13124
13868
  const fav = favorites[idx];
@@ -13226,31 +13970,40 @@ Error: ${launchPlan.error}
13226
13970
  }
13227
13971
  if (!dryRun) recordLaunchSelection("claude", activeProvider.id, selectedModel.id, prefs);
13228
13972
  } else {
13229
- const chosen = await p18.select({
13230
- message: "Which provider?",
13231
- options: providerOptions,
13232
- initialValue: initialProvider
13233
- });
13234
- if (p18.isCancel(chosen)) {
13235
- p18.cancel("Cancelled.");
13236
- return 0;
13237
- }
13238
- const providerChoice = chosen;
13239
- if (providerChoice === "__favorites__") {
13240
- const favoriteStart = resolveFirstAvailableFavorite(favorites, allProviders);
13241
- if (!favoriteStart) {
13242
- p18.log.warn("No saved favorites are currently available.");
13973
+ let currentInitialProvider = initialProvider;
13974
+ while (true) {
13975
+ const chosen = await p18.select({
13976
+ message: "Which provider?",
13977
+ options: providerOptions,
13978
+ initialValue: currentInitialProvider
13979
+ });
13980
+ if (p18.isCancel(chosen)) {
13981
+ p18.cancel("Cancelled.");
13243
13982
  return 0;
13244
13983
  }
13245
- activeProvider = favoriteStart.provider;
13246
- selectedModel = favoriteStart.model;
13247
- p18.log.step(`Loaded Favorites Catalog. Starting model: ${selectedModel.name || selectedModel.id} (${activeProvider.name})`);
13248
- } else {
13249
- activeProvider = allProviders.find((lp) => lp.id === providerChoice);
13250
- const pickedModel = await pickLocalModel(activeProvider, conflicts, prefs);
13251
- if (!pickedModel) return 0;
13252
- selectedModel = pickedModel;
13253
- if (!dryRun) recordLaunchSelection("claude", activeProvider.id, selectedModel.id, prefs);
13984
+ const providerChoice = chosen;
13985
+ if (providerChoice === "__favorites__") {
13986
+ const favoriteStart = resolveFirstAvailableFavorite(favorites, allProviders);
13987
+ if (!favoriteStart) {
13988
+ p18.log.warn("No saved favorites are currently available.");
13989
+ return 0;
13990
+ }
13991
+ activeProvider = favoriteStart.provider;
13992
+ selectedModel = favoriteStart.model;
13993
+ p18.log.step(`Loaded Favorites Catalog. Starting model: ${selectedModel.name || selectedModel.id} (${activeProvider.name})`);
13994
+ break;
13995
+ } else {
13996
+ activeProvider = allProviders.find((lp) => lp.id === providerChoice);
13997
+ const pickedModelResult = await pickLocalModel(activeProvider, conflicts, prefs);
13998
+ if (pickedModelResult === "back") {
13999
+ currentInitialProvider = activeProvider.id;
14000
+ continue;
14001
+ }
14002
+ if (!pickedModelResult) return 0;
14003
+ selectedModel = pickedModelResult;
14004
+ if (!dryRun) recordLaunchSelection("claude", activeProvider.id, selectedModel.id, prefs);
14005
+ break;
14006
+ }
13254
14007
  }
13255
14008
  }
13256
14009
  const localProviders = catalog.localProviders.length > 0 ? catalog.localProviders : null;
@@ -13346,7 +14099,8 @@ Error: ${launchPlan.error}
13346
14099
  supportedParameters: selectedModel.supportedParameters,
13347
14100
  reasoning: selectedModel.reasoning,
13348
14101
  interleavedReasoningField: selectedModel.interleavedReasoningField
13349
- }
14102
+ },
14103
+ launchApiKey
13350
14104
  );
13351
14105
  if (!isAgentStdoutMode()) {
13352
14106
  p18.log.info(
@@ -13360,7 +14114,7 @@ Error: ${launchPlan.error}
13360
14114
  childEnv = buildChildEnv(
13361
14115
  `http://127.0.0.1:${proxyHandle.port}`,
13362
14116
  selectedModel.id,
13363
- launchApiKey,
14117
+ proxyHandle.token,
13364
14118
  proxyHandle.port,
13365
14119
  selectedModel.contextWindow
13366
14120
  );