@khanglvm/llm-router 1.2.0 → 1.3.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.3.0] - 2026-03-05
9
+
10
+ ### Added
11
+ - Added Claude Code OAuth subscription provider support end-to-end:
12
+ - new subscription type: `claude-code`
13
+ - Claude OAuth constants and runtime request config (`anthropic-beta`, OAuth token endpoint, Claude messages endpoint)
14
+ - default Claude subscription model seed list for new subscription providers
15
+ - Added CLI support for Claude subscription auth operations:
16
+ - `llm-router subscription login --subscription-type=claude-code`
17
+ - `llm-router subscription logout --subscription-type=claude-code`
18
+ - `llm-router subscription status --subscription-type=claude-code`
19
+ - Added runtime and CLI test coverage for Claude subscription request translation/headers and setup flows.
20
+
21
+ ### Changed
22
+ - Updated subscription probe and provider upsert flow to build type-specific probe payloads:
23
+ - ChatGPT Codex keeps Responses/Codex probe shape
24
+ - Claude Code uses Claude messages probe shape
25
+ - Updated subscription config normalization/workflows so default format and model seed list are selected by `subscriptionType`.
26
+ - Updated README and CLI help text/examples to document both supported OAuth subscription types (`chatgpt-codex`, `claude-code`).
27
+
28
+ ### Fixed
29
+ - Fixed new Claude subscription provider creation default model seeding to correctly use Claude defaults instead of ChatGPT defaults.
30
+
8
31
  ## [1.2.0] - 2026-03-04
9
32
 
10
33
  ### Added
package/README.md CHANGED
@@ -81,27 +81,38 @@ Flow:
81
81
  - `API Key` -> endpoint + API key + model list
82
82
  - `OAuth` -> browser OAuth + editable model list
83
83
  4. For `OAuth`:
84
- - Choose subscription provider (`ChatGPT` for now)
84
+ - Choose subscription provider (`ChatGPT` or `Claude Code`)
85
85
  - Enter Friendly Name and Provider ID
86
86
  - Complete browser OAuth login inside this same flow
87
87
  - Edit model list (pre-filled defaults; you can add/remove)
88
88
  - llm-router live-tests every selected model before save
89
89
  5. Save
90
90
 
91
- ### 1b) Add Subscription Provider (ChatGPT Codex)
92
- Commandline example:
91
+ ### 1b) Add Subscription Provider (OAuth)
92
+ Commandline examples:
93
93
 
94
94
  ```bash
95
+ # ChatGPT Codex subscription
95
96
  llm-router config \
96
97
  --operation=upsert-provider \
97
98
  --provider-id=chatgpt \
98
99
  --name="GPT Sub" \
99
100
  --type=subscription
101
+
102
+ # Claude Code subscription
103
+ llm-router config \
104
+ --operation=upsert-provider \
105
+ --provider-id=claude-sub \
106
+ --name="Claude Sub" \
107
+ --type=subscription \
108
+ --subscription-type=claude-code
100
109
  ```
101
110
 
102
111
  Notes:
103
112
  - OAuth login is run during provider upsert (browser flow by default).
104
- - `chatgpt-codex` is the current subscription type and its default model list is prefilled, but editable.
113
+ - Supported `subscription-type`: `chatgpt-codex` and `claude-code` (defaults to `chatgpt-codex`).
114
+ - Default model lists are prefilled by subscription type, then editable.
115
+ - Device-code login is available for `chatgpt-codex` only.
105
116
  - No provider API key or endpoint probe input is required for subscription mode.
106
117
 
107
118
  ### 2) Configure Model Fallback (Optional)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@khanglvm/llm-router",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Single gateway endpoint for multi-provider LLMs with unified OpenAI+Anthropic format and seamless fallback",
5
5
  "keywords": [
6
6
  "llm-router",
@@ -37,7 +37,10 @@ import {
37
37
  sanitizeConfigForDisplay,
38
38
  validateRuntimeConfig
39
39
  } from "../runtime/config.js";
40
- import { CODEX_SUBSCRIPTION_MODELS } from "../runtime/subscription-constants.js";
40
+ import {
41
+ CODEX_SUBSCRIPTION_MODELS,
42
+ CLAUDE_CODE_SUBSCRIPTION_MODELS
43
+ } from "../runtime/subscription-constants.js";
41
44
  import { FORMATS } from "../translator/index.js";
42
45
  import {
43
46
  CLOUDFLARE_ACCOUNT_ID_ENV_NAME,
@@ -107,12 +110,21 @@ const DEFAULT_AI_HELP_GATEWAY_TEST_TIMEOUT_MS = 6000;
107
110
  const PROVIDER_TYPE_STANDARD = "standard";
108
111
  const PROVIDER_TYPE_SUBSCRIPTION = "subscription";
109
112
  const SUBSCRIPTION_TYPE_CHATGPT_CODEX = "chatgpt-codex";
113
+ const SUBSCRIPTION_TYPE_CLAUDE_CODE = "claude-code";
110
114
  const SUBSCRIPTION_PROVIDER_PRESETS = Object.freeze([
111
115
  Object.freeze({
112
116
  subscriptionType: SUBSCRIPTION_TYPE_CHATGPT_CODEX,
113
117
  label: "ChatGPT",
114
118
  defaultName: "GPT Sub",
115
- defaultModels: CODEX_SUBSCRIPTION_MODELS
119
+ defaultModels: CODEX_SUBSCRIPTION_MODELS,
120
+ targetFormat: FORMATS.OPENAI
121
+ }),
122
+ Object.freeze({
123
+ subscriptionType: SUBSCRIPTION_TYPE_CLAUDE_CODE,
124
+ label: "Claude Code",
125
+ defaultName: "Claude Sub",
126
+ defaultModels: CLAUDE_CODE_SUBSCRIPTION_MODELS,
127
+ targetFormat: FORMATS.CLAUDE
116
128
  })
117
129
  ]);
118
130
  const RATE_LIMIT_WINDOW_UNIT_ALIASES = new Map([
@@ -359,6 +371,7 @@ function normalizeSubscriptionTypeInput(value) {
359
371
  const normalized = String(value || "").trim().toLowerCase();
360
372
  if (!normalized) return "";
361
373
  if (normalized === SUBSCRIPTION_TYPE_CHATGPT_CODEX) return SUBSCRIPTION_TYPE_CHATGPT_CODEX;
374
+ if (normalized === SUBSCRIPTION_TYPE_CLAUDE_CODE) return SUBSCRIPTION_TYPE_CLAUDE_CODE;
362
375
  return "";
363
376
  }
364
377
 
@@ -366,11 +379,31 @@ function getSubscriptionProviderPreset(subscriptionType) {
366
379
  return SUBSCRIPTION_PROVIDER_PRESETS.find((preset) => preset.subscriptionType === subscriptionType) || null;
367
380
  }
368
381
 
369
- function getDefaultSubscriptionModelListInput(existingProvider) {
382
+ function getSupportedSubscriptionTypes() {
383
+ return SUBSCRIPTION_PROVIDER_PRESETS.map((preset) => preset.subscriptionType);
384
+ }
385
+
386
+ function formatSupportedSubscriptionTypes() {
387
+ return getSupportedSubscriptionTypes().join(", ");
388
+ }
389
+
390
+ function getSubscriptionTargetFormat(subscriptionType) {
391
+ const preset = getSubscriptionProviderPreset(subscriptionType);
392
+ return preset?.targetFormat || FORMATS.OPENAI;
393
+ }
394
+
395
+ function getDefaultSubscriptionModelListInput(
396
+ existingProvider,
397
+ fallbackSubscriptionType = SUBSCRIPTION_TYPE_CHATGPT_CODEX
398
+ ) {
399
+ const existingSubType = normalizeSubscriptionTypeInput(
400
+ existingProvider?.subscriptionType || existingProvider?.subscription_type || ""
401
+ ) || normalizeSubscriptionTypeInput(fallbackSubscriptionType) || SUBSCRIPTION_TYPE_CHATGPT_CODEX;
402
+ const preset = getSubscriptionProviderPreset(existingSubType);
370
403
  const existingModels = dedupeList((existingProvider?.models || []).map((model) => model?.id).filter(Boolean));
371
404
  const defaults = existingModels.length > 0
372
405
  ? existingModels
373
- : CODEX_SUBSCRIPTION_MODELS;
406
+ : (preset?.defaultModels || CODEX_SUBSCRIPTION_MODELS);
374
407
  return defaults.join(",");
375
408
  }
376
409
 
@@ -2695,7 +2728,9 @@ async function resolveUpsertInput(context, existingConfig) {
2695
2728
  message: "Provider Friendly Name (unique, shown in management screen)",
2696
2729
  required: true,
2697
2730
  initialValue: defaultName,
2698
- placeholder: providerType === PROVIDER_TYPE_SUBSCRIPTION ? "GPT Sub" : "OpenRouter Primary",
2731
+ placeholder: providerType === PROVIDER_TYPE_SUBSCRIPTION
2732
+ ? (subscriptionPreset?.defaultName || "Subscription Sub")
2733
+ : "OpenRouter Primary",
2699
2734
  validate: (value) => {
2700
2735
  const candidate = String(value || "").trim();
2701
2736
  if (!candidate) return "Provider Friendly Name is required.";
@@ -2717,7 +2752,9 @@ async function resolveUpsertInput(context, existingConfig) {
2717
2752
  message: "Provider ID (auto-slug from Friendly Name; editable)",
2718
2753
  required: true,
2719
2754
  initialValue: generatedProviderId,
2720
- placeholder: providerType === PROVIDER_TYPE_SUBSCRIPTION ? "gpt-sub" : "openrouter-primary",
2755
+ placeholder: providerType === PROVIDER_TYPE_SUBSCRIPTION
2756
+ ? slugifyId(subscriptionPreset?.defaultName || "subscription-sub")
2757
+ : "openrouter-primary",
2721
2758
  validate: (value) => {
2722
2759
  const candidate = String(value || "").trim();
2723
2760
  if (!candidate) return "Provider ID is required.";
@@ -2754,7 +2791,7 @@ async function resolveUpsertInput(context, existingConfig) {
2754
2791
  ? String(
2755
2792
  hasModelsArg
2756
2793
  ? readArg(args, ["models"], "")
2757
- : getDefaultSubscriptionModelListInput(selectedExisting)
2794
+ : getDefaultSubscriptionModelListInput(selectedExisting, subscriptionType)
2758
2795
  )
2759
2796
  : "";
2760
2797
  const baseIsSubscription = providerType === PROVIDER_TYPE_SUBSCRIPTION;
@@ -2779,7 +2816,7 @@ async function resolveUpsertInput(context, existingConfig) {
2779
2816
  models: baseIsSubscription
2780
2817
  ? parseProviderModelListInput(subscriptionModelsInput)
2781
2818
  : parseProviderModelListInput(baseModels),
2782
- format: baseIsSubscription ? "openai" : baseFormat,
2819
+ format: baseIsSubscription ? (subscriptionPreset?.targetFormat || FORMATS.OPENAI) : baseFormat,
2783
2820
  formats: baseFormats,
2784
2821
  headers: parsedHeaders,
2785
2822
  probeRequestsPerMinute: baseProbeRequestsPerMinute,
@@ -2797,11 +2834,13 @@ async function resolveUpsertInput(context, existingConfig) {
2797
2834
  let interactiveHeaders = parsedHeaders;
2798
2835
  let probe = false;
2799
2836
  let probeRequestsPerMinute = baseProbeRequestsPerMinute;
2800
- let manualFormat = providerType === PROVIDER_TYPE_SUBSCRIPTION ? "openai" : baseFormat;
2837
+ let manualFormat = providerType === PROVIDER_TYPE_SUBSCRIPTION
2838
+ ? (subscriptionPreset?.targetFormat || FORMATS.OPENAI)
2839
+ : baseFormat;
2801
2840
 
2802
2841
  if (providerType === PROVIDER_TYPE_SUBSCRIPTION) {
2803
2842
  const info = typeof context?.terminal?.info === "function" ? context.terminal.info.bind(context.terminal) : null;
2804
- info?.(`${subscriptionPreset?.label || "ChatGPT"} uses browser OAuth login. Model validation will run after authentication.`);
2843
+ info?.(`${subscriptionPreset?.label || "Subscription provider"} uses browser OAuth login. Model validation will run after authentication.`);
2805
2844
  } else {
2806
2845
  const askReplaceKey = selectedExisting?.apiKey ? await context.prompts.confirm({
2807
2846
  message: "Replace saved API key?",
@@ -2930,6 +2969,7 @@ async function resolveSubscriptionProviderFns(context) {
2930
2969
 
2931
2970
  async function ensureSubscriptionAuthenticated(context, {
2932
2971
  profile,
2972
+ subscriptionType = SUBSCRIPTION_TYPE_CHATGPT_CODEX,
2933
2973
  forceLogin = false,
2934
2974
  deviceCode = false
2935
2975
  }) {
@@ -2944,7 +2984,7 @@ async function ensureSubscriptionAuthenticated(context, {
2944
2984
  }
2945
2985
 
2946
2986
  if (!forceLogin) {
2947
- const status = await getAuthStatus(profile);
2987
+ const status = await getAuthStatus(profile, { subscriptionType });
2948
2988
  if (status?.authenticated) {
2949
2989
  line?.(`Subscription profile '${profile}' already authenticated.`);
2950
2990
  return { authenticated: true, loginAttempted: false };
@@ -2954,12 +2994,14 @@ async function ensureSubscriptionAuthenticated(context, {
2954
2994
  line?.(`Starting OAuth login for subscription profile '${profile}'...`);
2955
2995
  if (deviceCode) {
2956
2996
  await loginWithDeviceCode(profile, {
2997
+ subscriptionType,
2957
2998
  onCode: ({ userCode, verificationUri, expiresIn }) => {
2958
2999
  line?.(`Open ${verificationUri} and enter code ${userCode} (expires in ${Math.floor(Number(expiresIn || 0) / 60)} minutes).`);
2959
3000
  }
2960
3001
  });
2961
3002
  } else {
2962
3003
  await loginWithBrowser(profile, {
3004
+ subscriptionType,
2963
3005
  onUrl: (url, meta = {}) => {
2964
3006
  if (meta?.openedBrowser === true) {
2965
3007
  line?.("Opened browser for OAuth login. Complete authentication to continue.");
@@ -2970,26 +3012,27 @@ async function ensureSubscriptionAuthenticated(context, {
2970
3012
  });
2971
3013
  }
2972
3014
 
2973
- const refreshedStatus = await getAuthStatus(profile);
3015
+ const refreshedStatus = await getAuthStatus(profile, { subscriptionType });
2974
3016
  if (!refreshedStatus?.authenticated) {
2975
3017
  throw new Error(`OAuth login did not complete for subscription profile '${profile}'.`);
2976
3018
  }
2977
3019
  return { authenticated: true, loginAttempted: true };
2978
3020
  }
2979
3021
 
2980
- function buildSubscriptionProbeSeed(models) {
3022
+ function buildSubscriptionProbeSeed(models, targetFormat = FORMATS.OPENAI) {
3023
+ const format = targetFormat === FORMATS.CLAUDE ? FORMATS.CLAUDE : FORMATS.OPENAI;
2981
3024
  const modelSupport = {};
2982
3025
  const modelPreferredFormat = {};
2983
3026
  for (const model of (models || [])) {
2984
- modelSupport[model] = [FORMATS.OPENAI];
2985
- modelPreferredFormat[model] = FORMATS.OPENAI;
3027
+ modelSupport[model] = [format];
3028
+ modelPreferredFormat[model] = format;
2986
3029
  }
2987
3030
 
2988
3031
  return {
2989
3032
  ok: true,
2990
- preferredFormat: FORMATS.OPENAI,
2991
- formats: [FORMATS.OPENAI],
2992
- workingFormats: [FORMATS.OPENAI],
3033
+ preferredFormat: format,
3034
+ formats: [format],
3035
+ workingFormats: [format],
2993
3036
  models: [...(models || [])],
2994
3037
  modelSupport,
2995
3038
  modelPreferredFormat
@@ -3021,6 +3064,49 @@ async function closeSubscriptionProbeResponse(result) {
3021
3064
  }
3022
3065
  }
3023
3066
 
3067
+ function buildSubscriptionProbeBody(modelId, subscriptionType) {
3068
+ if (subscriptionType === SUBSCRIPTION_TYPE_CLAUDE_CODE) {
3069
+ return {
3070
+ model: modelId,
3071
+ max_tokens: 16,
3072
+ stream: true,
3073
+ messages: [
3074
+ {
3075
+ role: "user",
3076
+ content: [
3077
+ {
3078
+ type: "text",
3079
+ text: "Reply with exactly: pong"
3080
+ }
3081
+ ]
3082
+ }
3083
+ ]
3084
+ };
3085
+ }
3086
+
3087
+ return {
3088
+ model: modelId,
3089
+ stream: true,
3090
+ store: false,
3091
+ instructions: "You are a helpful assistant. Reply concisely.",
3092
+ input: [
3093
+ {
3094
+ type: "message",
3095
+ role: "user",
3096
+ content: [
3097
+ {
3098
+ type: "input_text",
3099
+ text: "Reply with exactly: pong"
3100
+ }
3101
+ ]
3102
+ }
3103
+ ],
3104
+ tools: [],
3105
+ tool_choice: "auto",
3106
+ parallel_tool_calls: false
3107
+ };
3108
+ }
3109
+
3024
3110
  async function probeSubscriptionModels(context, {
3025
3111
  providerId,
3026
3112
  providerName,
@@ -3048,31 +3134,12 @@ async function probeSubscriptionModels(context, {
3048
3134
  headers: headers || {},
3049
3135
  models: uniqueModels.map((id) => ({ id }))
3050
3136
  };
3137
+ const targetFormat = getSubscriptionTargetFormat(subscriptionType);
3051
3138
 
3052
3139
  const failures = [];
3053
3140
  for (const modelId of uniqueModels) {
3054
3141
  line?.(`[subscription probe] Testing model ${modelId}...`);
3055
- const probeBody = {
3056
- model: modelId,
3057
- stream: true,
3058
- store: false,
3059
- instructions: "You are a helpful assistant. Reply concisely.",
3060
- input: [
3061
- {
3062
- type: "message",
3063
- role: "user",
3064
- content: [
3065
- {
3066
- type: "input_text",
3067
- text: "Reply with exactly: pong"
3068
- }
3069
- ]
3070
- }
3071
- ],
3072
- tools: [],
3073
- tool_choice: "auto",
3074
- parallel_tool_calls: false
3075
- };
3142
+ const probeBody = buildSubscriptionProbeBody(modelId, subscriptionType);
3076
3143
  const result = await makeSubscriptionProviderCall({
3077
3144
  provider,
3078
3145
  body: probeBody,
@@ -3100,7 +3167,7 @@ async function probeSubscriptionModels(context, {
3100
3167
  return {
3101
3168
  ok: failures.length === 0,
3102
3169
  failures,
3103
- probe: buildSubscriptionProbeSeed(uniqueModels)
3170
+ probe: buildSubscriptionProbeSeed(uniqueModels, targetFormat)
3104
3171
  };
3105
3172
  }
3106
3173
 
@@ -3115,6 +3182,9 @@ async function doUpsertProvider(context) {
3115
3182
  const subscriptionType = isSubscriptionProvider
3116
3183
  ? (normalizedSubscriptionType || SUBSCRIPTION_TYPE_CHATGPT_CODEX)
3117
3184
  : "";
3185
+ const subscriptionPreset = isSubscriptionProvider
3186
+ ? getSubscriptionProviderPreset(subscriptionType || SUBSCRIPTION_TYPE_CHATGPT_CODEX)
3187
+ : null;
3118
3188
  const subscriptionProfile = String(input.subscriptionProfile || input.providerId || "default").trim() || input.providerId || "default";
3119
3189
 
3120
3190
  const endpointCandidates = parseEndpointListInput([
@@ -3138,7 +3208,7 @@ async function doUpsertProvider(context) {
3138
3208
  ok: false,
3139
3209
  mode: context.mode,
3140
3210
  exitCode: EXIT_VALIDATION,
3141
- errorMessage: `Unsupported subscription-type '${rawSubscriptionType}'. Supported: ${SUBSCRIPTION_TYPE_CHATGPT_CODEX}.`
3211
+ errorMessage: `Unsupported subscription-type '${rawSubscriptionType}'. Supported: ${formatSupportedSubscriptionTypes()}.`
3142
3212
  };
3143
3213
  }
3144
3214
  if (!isSubscriptionProvider && (!hasAnyEndpoint || !input.apiKey)) {
@@ -3199,6 +3269,7 @@ async function doUpsertProvider(context) {
3199
3269
  try {
3200
3270
  await ensureSubscriptionAuthenticated(context, {
3201
3271
  profile: subscriptionProfile,
3272
+ subscriptionType,
3202
3273
  forceLogin: Boolean(input.requireSubscriptionLogin),
3203
3274
  deviceCode: Boolean(input.subscriptionDeviceCode)
3204
3275
  });
@@ -3305,7 +3376,7 @@ async function doUpsertProvider(context) {
3305
3376
  }
3306
3377
  }
3307
3378
 
3308
- selectedFormat = FORMATS.OPENAI;
3379
+ selectedFormat = subscriptionPreset?.targetFormat || getSubscriptionTargetFormat(subscriptionType);
3309
3380
  effectiveBaseUrl = "";
3310
3381
  effectiveOpenAIBaseUrl = "";
3311
3382
  effectiveClaudeBaseUrl = "";
@@ -5454,7 +5525,7 @@ async function runAiHelpAction(context) {
5454
5525
  const suggestions = [];
5455
5526
  if (providerCount === 0) {
5456
5527
  suggestions.push("Add first provider with at least one model. Run: llm-router config --operation=upsert-provider --provider-id=<id> --name=\"<name>\" --base-url=<url> --api-key=<key> --models=<model1,model2>");
5457
- suggestions.push("Or add OAuth-backed subscription provider. Run: llm-router config --operation=upsert-provider --provider-id=chatgpt --name=\"GPT Sub\" --type=subscription --subscription-type=chatgpt-codex --subscription-profile=default");
5528
+ suggestions.push("Or add OAuth-backed subscription provider. Run: llm-router config --operation=upsert-provider --provider-id=chatgpt --name=\"GPT Sub\" --type=subscription --subscription-type=chatgpt-codex --subscription-profile=default (or use --subscription-type=claude-code).");
5458
5529
  } else {
5459
5530
  const providersWithoutModels = providers
5460
5531
  .filter((provider) => (provider.models || []).filter((model) => model && model.enabled !== false).length === 0)
@@ -5581,9 +5652,11 @@ async function runAiHelpAction(context) {
5581
5652
  "## LLM-ROUTER CONFIG WORKFLOWS (CLI)",
5582
5653
  "1. Upsert provider + models:",
5583
5654
  " llm-router config --operation=upsert-provider --provider-id=<id> --name=\"<name>\" --endpoints=<url1,url2> --api-key=<key> --models=<model1,model2>",
5584
- "1b. Upsert subscription provider (OAuth-backed ChatGPT Codex):",
5655
+ "1b. Upsert subscription provider (OAuth-backed ChatGPT Codex / Claude Code):",
5585
5656
  " llm-router config --operation=upsert-provider --provider-id=chatgpt --name=\"GPT Sub\" --type=subscription --subscription-type=chatgpt-codex --subscription-profile=default",
5586
- " llm-router subscription login --profile=default",
5657
+ " llm-router config --operation=upsert-provider --provider-id=claude-sub --name=\"Claude Sub\" --type=subscription --subscription-type=claude-code --subscription-profile=default",
5658
+ " llm-router subscription login --subscription-type=chatgpt-codex --profile=default",
5659
+ " llm-router subscription login --subscription-type=claude-code --profile=default",
5587
5660
  "2. Upsert model alias/group:",
5588
5661
  " llm-router config --operation=upsert-model-alias --alias-id=<alias> --strategy=auto --targets=<provider/model,...>",
5589
5662
  "3. Set provider rate limit bucket:",
@@ -6341,12 +6414,32 @@ async function runSubscriptionLoginAction(context) {
6341
6414
  const args = context.args || {};
6342
6415
  const profile = String(readArg(args, ["profile", "profileId"], "default") || "default").trim();
6343
6416
  const deviceCode = toBoolean(readArg(args, ["device-code", "deviceCode"], false), false);
6417
+ const rawSubscriptionType = String(readArg(args, ["subscription-type", "subscriptionType"], "") || "").trim();
6418
+ const normalizedSubscriptionType = normalizeSubscriptionTypeInput(rawSubscriptionType);
6419
+ if (rawSubscriptionType && !normalizedSubscriptionType) {
6420
+ return {
6421
+ mode: context.mode,
6422
+ exitCode: EXIT_VALIDATION,
6423
+ data: `Unsupported subscription-type '${rawSubscriptionType}'. Supported: ${formatSupportedSubscriptionTypes()}.`
6424
+ };
6425
+ }
6426
+ const subscriptionType = normalizedSubscriptionType || SUBSCRIPTION_TYPE_CHATGPT_CODEX;
6427
+ const subscriptionPreset = getSubscriptionProviderPreset(subscriptionType);
6428
+ const subscriptionLabel = subscriptionPreset?.label || subscriptionType;
6429
+ if (deviceCode && subscriptionType === SUBSCRIPTION_TYPE_CLAUDE_CODE) {
6430
+ return {
6431
+ mode: context.mode,
6432
+ exitCode: EXIT_VALIDATION,
6433
+ data: "Device code flow is not supported for subscription-type=claude-code. Use browser OAuth login."
6434
+ };
6435
+ }
6344
6436
 
6345
6437
  // Import subscription auth functions
6346
6438
  const { loginWithBrowser, loginWithDeviceCode } = await import("../runtime/subscription-auth.js");
6347
6439
 
6348
6440
  const lines = [];
6349
6441
  lines.push(`Logging into subscription profile: ${profile}`);
6442
+ lines.push(`Subscription provider: ${subscriptionLabel} (${subscriptionType})`);
6350
6443
  lines.push("");
6351
6444
 
6352
6445
  try {
@@ -6355,6 +6448,7 @@ async function runSubscriptionLoginAction(context) {
6355
6448
  lines.push("");
6356
6449
 
6357
6450
  const success = await loginWithDeviceCode(profile, {
6451
+ subscriptionType,
6358
6452
  onCode: ({ userCode, verificationUri, expiresIn }) => {
6359
6453
  lines.push(`1. Go to: ${verificationUri}`);
6360
6454
  lines.push(`2. Enter code: ${userCode}`);
@@ -6374,6 +6468,7 @@ async function runSubscriptionLoginAction(context) {
6374
6468
  lines.push("");
6375
6469
 
6376
6470
  const success = await loginWithBrowser(profile, {
6471
+ subscriptionType,
6377
6472
  onUrl: (url, meta = {}) => {
6378
6473
  if (meta?.openedBrowser === true) {
6379
6474
  lines.push("Opened browser for OAuth login.");
@@ -6414,15 +6509,28 @@ async function runSubscriptionLoginAction(context) {
6414
6509
  async function runSubscriptionLogoutAction(context) {
6415
6510
  const args = context.args || {};
6416
6511
  const profile = String(readArg(args, ["profile", "profileId"], "default") || "default").trim();
6512
+ const rawSubscriptionType = String(readArg(args, ["subscription-type", "subscriptionType"], "") || "").trim();
6513
+ const normalizedSubscriptionType = normalizeSubscriptionTypeInput(rawSubscriptionType);
6514
+ if (rawSubscriptionType && !normalizedSubscriptionType) {
6515
+ return {
6516
+ mode: context.mode,
6517
+ exitCode: EXIT_VALIDATION,
6518
+ data: `Unsupported subscription-type '${rawSubscriptionType}'. Supported: ${formatSupportedSubscriptionTypes()}.`
6519
+ };
6520
+ }
6521
+ const subscriptionType = normalizedSubscriptionType || SUBSCRIPTION_TYPE_CHATGPT_CODEX;
6522
+ const subscriptionPreset = getSubscriptionProviderPreset(subscriptionType);
6523
+ const subscriptionLabel = subscriptionPreset?.label || subscriptionType;
6417
6524
 
6418
6525
  // Import subscription auth functions
6419
6526
  const { logout } = await import("../runtime/subscription-auth.js");
6420
6527
 
6421
6528
  const lines = [];
6422
6529
  lines.push(`Logging out subscription profile: ${profile}`);
6530
+ lines.push(`Subscription provider: ${subscriptionLabel} (${subscriptionType})`);
6423
6531
 
6424
6532
  try {
6425
- await logout(profile);
6533
+ await logout(profile, { subscriptionType });
6426
6534
  lines.push("");
6427
6535
  lines.push(`✓ Successfully logged out profile '${profile}'.`);
6428
6536
 
@@ -6449,16 +6557,30 @@ async function runSubscriptionLogoutAction(context) {
6449
6557
  async function runSubscriptionStatusAction(context) {
6450
6558
  const args = context.args || {};
6451
6559
  const profile = String(readArg(args, ["profile", "profileId"], "") || "").trim();
6560
+ const rawSubscriptionType = String(readArg(args, ["subscription-type", "subscriptionType"], "") || "").trim();
6561
+ const normalizedSubscriptionType = normalizeSubscriptionTypeInput(rawSubscriptionType);
6562
+ if (rawSubscriptionType && !normalizedSubscriptionType) {
6563
+ return {
6564
+ mode: context.mode,
6565
+ exitCode: EXIT_VALIDATION,
6566
+ data: `Unsupported subscription-type '${rawSubscriptionType}'. Supported: ${formatSupportedSubscriptionTypes()}.`
6567
+ };
6568
+ }
6569
+ const subscriptionType = normalizedSubscriptionType || SUBSCRIPTION_TYPE_CHATGPT_CODEX;
6570
+ const subscriptionPreset = getSubscriptionProviderPreset(subscriptionType);
6571
+ const subscriptionLabel = subscriptionPreset?.label || subscriptionType;
6452
6572
 
6453
6573
  // Import subscription auth functions
6454
6574
  const { getAuthStatus, listTokenProfiles } = await import("../runtime/subscription-auth.js");
6455
6575
 
6456
6576
  const lines = [];
6577
+ lines.push(`Subscription provider: ${subscriptionLabel} (${subscriptionType})`);
6578
+ lines.push("");
6457
6579
 
6458
6580
  try {
6459
6581
  if (profile) {
6460
6582
  // Show status for specific profile
6461
- const status = await getAuthStatus(profile);
6583
+ const status = await getAuthStatus(profile, { subscriptionType });
6462
6584
 
6463
6585
  lines.push(`Subscription Profile: ${profile}`);
6464
6586
  lines.push(`Status: ${status.authenticated ? "✓ Authenticated" : "✗ Not authenticated"}`);
@@ -6471,7 +6593,7 @@ async function runSubscriptionStatusAction(context) {
6471
6593
  }
6472
6594
  } else {
6473
6595
  // List all profiles
6474
- const profiles = await listTokenProfiles();
6596
+ const profiles = await listTokenProfiles({ subscriptionType });
6475
6597
 
6476
6598
  lines.push("Subscription Profiles:");
6477
6599
  lines.push("");
@@ -6479,10 +6601,10 @@ async function runSubscriptionStatusAction(context) {
6479
6601
  if (profiles.length === 0) {
6480
6602
  lines.push(" No authenticated profiles found.");
6481
6603
  lines.push("");
6482
- lines.push(" To login: llm-router subscription login --profile=<name>");
6604
+ lines.push(` To login: llm-router subscription login --subscription-type=${subscriptionType} --profile=<name>`);
6483
6605
  } else {
6484
6606
  for (const p of profiles) {
6485
- const status = await getAuthStatus(p);
6607
+ const status = await getAuthStatus(p, { subscriptionType });
6486
6608
  const statusIcon = status.authenticated ? "✓" : "✗";
6487
6609
  lines.push(` ${statusIcon} ${p}`);
6488
6610
  if (status.authenticated && status.expiresAtIso) {
@@ -6712,15 +6834,15 @@ const routerModule = {
6712
6834
  { name: "provider-id", required: false, description: "Provider id (lowercase letters/numbers/dashes).", example: "--provider-id=openrouter-primary" },
6713
6835
  { name: "name", required: false, description: "Provider Friendly Name (must be unique; shown in management screen).", example: "--name=OpenRouter Primary" },
6714
6836
  { name: "type", required: false, description: "Provider type: standard (API key) | subscription (OAuth).", example: "--type=subscription" },
6715
- { name: "subscription-type", required: false, description: "For --type=subscription. Defaults to chatgpt-codex.", example: "--subscription-type=chatgpt-codex" },
6837
+ { name: "subscription-type", required: false, description: "For --type=subscription. Supported: chatgpt-codex | claude-code. Defaults to chatgpt-codex.", example: "--subscription-type=claude-code" },
6716
6838
  { name: "subscription-profile", required: false, description: "OAuth token profile for subscription provider (defaults to provider-id).", example: "--subscription-profile=personal" },
6717
- { name: "device-code", required: false, description: "For subscription OAuth login during upsert: use device-code flow instead of browser.", example: "--device-code=true" },
6839
+ { name: "device-code", required: false, description: "For subscription OAuth login during upsert: use device-code flow instead of browser (chatgpt-codex only).", example: "--device-code=true" },
6718
6840
  { name: "endpoints", required: false, description: "For standard provider: endpoint candidates for auto-probe (comma-separated URLs).", example: "--endpoints=https://ramclouds.me,https://ramclouds.me/v1" },
6719
6841
  { name: "base-url", required: false, description: "For standard provider: provider base URL.", example: "--base-url=https://openrouter.ai/api/v1" },
6720
6842
  { name: "openai-base-url", required: false, description: "For standard provider: OpenAI endpoint base URL (format-specific override).", example: "--openai-base-url=https://ramclouds.me/v1" },
6721
6843
  { name: "claude-base-url", required: false, description: "For standard provider: Anthropic endpoint base URL (format-specific override).", example: "--claude-base-url=https://ramclouds.me" },
6722
6844
  { name: "api-key", required: false, description: "For standard provider: API key.", example: "--api-key=sk-or-v1-..." },
6723
- { name: "models", required: false, description: "Model list (comma-separated IDs; strips common log/error noise). For chatgpt-codex subscription type, defaults are prefilled and you can add/remove models; all selected models are live-validated before save.", example: "--models=gpt-5.3-codex,gpt-5-codex" },
6845
+ { name: "models", required: false, description: "Model list (comma-separated IDs; strips common log/error noise). Subscription defaults are prefilled by subscription-type and all selected models are live-validated before save.", example: "--models=claude-sonnet-4-6,claude-opus-4-6" },
6724
6846
  { name: "model", required: false, description: "Single model id (used by remove-model).", example: "--model=gpt-4o" },
6725
6847
  { name: "fallback-models", required: false, description: "Qualified fallback models for set-model-fallbacks (comma-separated).", example: "--fallback-models=openrouter/gpt-4o,anthropic/claude-3-7-sonnet" },
6726
6848
  { name: "clear-fallbacks", required: false, description: "Clear all fallback models for set-model-fallbacks.", example: "--clear-fallbacks=true" },
@@ -6759,7 +6881,9 @@ const routerModule = {
6759
6881
  "llm-router config",
6760
6882
  "llm-router config --operation=upsert-provider --provider-id=ramclouds --name=RamClouds --api-key=sk-... --endpoints=https://ramclouds.me,https://ramclouds.me/v1 --models=claude-opus-4-6-thinking,gpt-5.3-codex",
6761
6883
  "llm-router config --operation=upsert-provider --provider-id=chatgpt --name=\"GPT Sub\" --type=subscription --subscription-type=chatgpt-codex --subscription-profile=default",
6762
- "llm-router subscription login --profile=default",
6884
+ "llm-router config --operation=upsert-provider --provider-id=claude-sub --name=\"Claude Sub\" --type=subscription --subscription-type=claude-code --subscription-profile=default",
6885
+ "llm-router subscription login --subscription-type=chatgpt-codex --profile=default",
6886
+ "llm-router subscription login --subscription-type=claude-code --profile=default",
6763
6887
  "llm-router config --operation=upsert-model-alias --alias-id=chat.default --strategy=auto --targets=openrouter/gpt-4o-mini@3,anthropic/claude-3-5-haiku@2 --fallback-targets=openrouter/gpt-4o",
6764
6888
  "llm-router config --operation=set-provider-rate-limits --provider-id=openrouter --bucket-id=openrouter-all-month --bucket-models=all --bucket-requests=20000 --bucket-window=month:1",
6765
6889
  "llm-router config --operation=set-provider-rate-limits --provider-id=openrouter --bucket-name=\"6-hours cap\" --bucket-models=all --bucket-requests=600 --bucket-window=hour:6",
@@ -6907,32 +7031,34 @@ const routerModule = {
6907
7031
  tui: { steps: ["subscription-auth"] },
6908
7032
  commandline: {
6909
7033
  requiredArgs: [],
6910
- optionalArgs: ["profile", "device-code"]
7034
+ optionalArgs: ["profile", "device-code", "subscription-type"]
6911
7035
  },
6912
7036
  help: {
6913
- summary: "Manage OAuth authentication for subscription providers (ChatGPT Codex).",
7037
+ summary: "Manage OAuth authentication for subscription providers (ChatGPT Codex and Claude Code).",
6914
7038
  args: [
6915
7039
  { name: "profile", required: false, description: "Subscription profile ID (defaults to 'default').", example: "--profile=personal" },
6916
- { name: "device-code", required: false, description: "Use device code flow instead of browser (for headless environments).", example: "--device-code=true" }
7040
+ { name: "subscription-type", required: false, description: "Subscription provider type: chatgpt-codex | claude-code (defaults to chatgpt-codex).", example: "--subscription-type=claude-code" },
7041
+ { name: "device-code", required: false, description: "Use device code flow instead of browser (headless environments; chatgpt-codex only).", example: "--device-code=true" }
6917
7042
  ],
6918
7043
  examples: [
6919
7044
  "llm-router subscription login",
6920
- "llm-router subscription login --profile=personal",
6921
- "llm-router subscription login --device-code=true",
7045
+ "llm-router subscription login --subscription-type=chatgpt-codex --profile=personal",
7046
+ "llm-router subscription login --subscription-type=claude-code --profile=work",
7047
+ "llm-router subscription login --subscription-type=chatgpt-codex --device-code=true",
6922
7048
  "llm-router subscription logout --profile=personal",
6923
7049
  "llm-router subscription status",
6924
- "llm-router subscription status --profile=personal"
7050
+ "llm-router subscription status --subscription-type=claude-code --profile=personal"
6925
7051
  ],
6926
7052
  useCases: [
6927
7053
  {
6928
7054
  name: "browser login",
6929
- description: "Login to ChatGPT Codex subscription via browser OAuth.",
6930
- command: "llm-router subscription login --profile=personal"
7055
+ description: "Login to subscription provider via browser OAuth.",
7056
+ command: "llm-router subscription login --subscription-type=claude-code --profile=personal"
6931
7057
  },
6932
7058
  {
6933
7059
  name: "device code login",
6934
- description: "Login on headless server using device code flow.",
6935
- command: "llm-router subscription login --device-code=true --profile=server"
7060
+ description: "Login on headless server using device code flow (chatgpt-codex only).",
7061
+ command: "llm-router subscription login --subscription-type=chatgpt-codex --device-code=true --profile=server"
6936
7062
  },
6937
7063
  {
6938
7064
  name: "check status",
@@ -6,6 +6,7 @@ import { normalizeRuntimeConfig, validateRuntimeConfig } from "../runtime/config
6
6
 
7
7
  const PROVIDER_TYPE_SUBSCRIPTION = "subscription";
8
8
  const SUBSCRIPTION_TYPE_CHATGPT_CODEX = "chatgpt-codex";
9
+ const SUBSCRIPTION_TYPE_CLAUDE_CODE = "claude-code";
9
10
 
10
11
  function dedupe(values) {
11
12
  return [...new Set((values || []).filter(Boolean).map((value) => String(value).trim()).filter(Boolean))];
@@ -20,6 +21,7 @@ function normalizeSubscriptionType(value) {
20
21
  const normalized = String(value || "").trim().toLowerCase();
21
22
  if (!normalized) return undefined;
22
23
  if (normalized === SUBSCRIPTION_TYPE_CHATGPT_CODEX) return SUBSCRIPTION_TYPE_CHATGPT_CODEX;
24
+ if (normalized === SUBSCRIPTION_TYPE_CLAUDE_CODE) return SUBSCRIPTION_TYPE_CLAUDE_CODE;
23
25
  return normalized;
24
26
  }
25
27
 
@@ -121,7 +123,7 @@ export function buildProviderFromConfigInput(input) {
121
123
  const endpointFormats = baseUrlByFormat ? Object.keys(baseUrlByFormat) : [];
122
124
 
123
125
  const preferredFormat = isSubscriptionProvider
124
- ? "openai"
126
+ ? (subscriptionType === SUBSCRIPTION_TYPE_CLAUDE_CODE ? "claude" : "openai")
125
127
  : (input.probe?.preferredFormat || input.format);
126
128
  const supportedFormats = dedupe([
127
129
  ...(input.probe?.formats || []),