typeclaw 0.32.1 → 0.33.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeclaw",
3
- "version": "0.32.1",
3
+ "version": "0.33.0",
4
4
  "homepage": "https://github.com/typeclaw/typeclaw#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/typeclaw/typeclaw/issues"
@@ -54,6 +54,7 @@ const PROCESS_ENV_TARGETS: ReadonlyArray<string> = [
54
54
  'FIREWORKS_API_KEY',
55
55
  'OPENAI_API_KEY',
56
56
  'ANTHROPIC_API_KEY',
57
+ 'MINIMAX_API_KEY',
57
58
  'GOOGLE_API_KEY',
58
59
  'GEMINI_API_KEY',
59
60
  'AWS_ACCESS_KEY_ID',
package/src/cli/init.ts CHANGED
@@ -4,11 +4,17 @@ import { cancel, confirm, intro, isCancel, log, note, password, select, spinner,
4
4
  import { defineCommand } from 'citty'
5
5
 
6
6
  import {
7
+ KNOWN_PROVIDER_VENDORS,
7
8
  KNOWN_PROVIDERS,
9
+ listKnownProviderVendorIds,
10
+ providerIdsForVendor,
8
11
  supportsApiKey as providerSupportsApiKey,
9
12
  supportsOAuth as providerSupportsOAuth,
13
+ variantHint,
14
+ variantLabel,
10
15
  type KnownModelRef,
11
16
  type KnownProviderId,
17
+ type KnownProviderVendorId,
12
18
  } from '@/config/providers'
13
19
  import { checkDockerAvailable, type DockerAvailability } from '@/container'
14
20
  import {
@@ -32,7 +38,12 @@ import {
32
38
  import { runKakaotalkBootstrap } from '@/init/kakaotalk-auth'
33
39
  import { fetchModelOptions, type ModelOption } from '@/init/models-dev'
34
40
  import { makeOAuthLoginRunner, type OAuthLoginResult } from '@/init/oauth-login'
35
- import { API_KEY_DASHBOARD_URL, validateApiKey, type KeyValidationResult } from '@/init/validate-api-key'
41
+ import {
42
+ API_KEY_DASHBOARD_URL,
43
+ MINIMAX_TOKEN_PLAN_DASHBOARD_URL,
44
+ validateApiKey,
45
+ type KeyValidationResult,
46
+ } from '@/init/validate-api-key'
36
47
 
37
48
  import { buildOAuthCallbacks } from './oauth-callbacks'
38
49
  import { CANCEL_SYMBOL, promptPrivateKeyPem } from './prompt-pem'
@@ -290,11 +301,13 @@ export const init = defineCommand({
290
301
 
291
302
  interface WizardState {
292
303
  catalog?: { options: ModelOption[]; source: 'models.dev' | 'curated'; warning?: string }
304
+ vendorId?: KnownProviderVendorId
293
305
  providerId?: KnownProviderId
294
306
  model?: ModelOption
295
307
  reuseExisting?: boolean
296
308
  authMethod?: 'api-key' | 'oauth'
297
309
  llmAuth?: LLMAuth
310
+ visionVendorId?: KnownProviderVendorId
298
311
  visionProviderId?: KnownProviderId
299
312
  visionModel?: ModelOption
300
313
  visionReuseExisting?: boolean
@@ -336,14 +349,16 @@ interface CollectedInputs {
336
349
  }
337
350
 
338
351
  type StepId =
339
- | 'pick-provider'
340
- | 'pick-model'
352
+ | 'pick-vendor'
353
+ | 'pick-provider-variant'
341
354
  | 'reuse-existing-key'
342
355
  | 'pick-auth-method'
356
+ | 'pick-model'
343
357
  | 'enter-api-key'
344
- | 'pick-vision-provider'
345
- | 'pick-vision-model'
358
+ | 'pick-vision-vendor'
359
+ | 'pick-vision-provider-variant'
346
360
  | 'pick-vision-auth-method'
361
+ | 'pick-vision-model'
347
362
  | 'enter-vision-api-key'
348
363
  | 'pick-channel'
349
364
  | 'reuse-existing-channel'
@@ -353,7 +368,15 @@ export interface WizardPrompts {
353
368
  loadCatalog: () => Promise<NonNullable<WizardState['catalog']>>
354
369
  readExistingApiKey: (cwd: string, providerId: KnownProviderId) => Promise<string | null>
355
370
  hasExistingOAuthCredentials: (cwd: string, providerId: KnownProviderId) => Promise<boolean>
356
- pickProvider: (options: ModelOption[], initial: KnownProviderId | undefined) => Promise<StepResult<KnownProviderId>>
371
+ pickVendor: (
372
+ options: ModelOption[],
373
+ initial: KnownProviderVendorId | undefined,
374
+ ) => Promise<StepResult<KnownProviderVendorId>>
375
+ pickProviderVariant: (
376
+ vendorId: KnownProviderVendorId,
377
+ options: ModelOption[],
378
+ initial: KnownProviderId | undefined,
379
+ ) => Promise<StepResult<KnownProviderId>>
357
380
  pickModel: (
358
381
  options: ModelOption[],
359
382
  providerId: KnownProviderId,
@@ -365,10 +388,15 @@ export interface WizardPrompts {
365
388
  ) => Promise<StepResult<'api-key' | 'oauth'>>
366
389
  askApiKey: (provider: (typeof KNOWN_PROVIDERS)[KnownProviderId]) => Promise<StepResult<string>>
367
390
  validateApiKey: (providerId: KnownProviderId, key: string) => Promise<KeyValidationResult>
368
- pickVisionProvider: (
391
+ pickVisionVendor: (
392
+ options: ModelOption[],
393
+ initial: KnownProviderVendorId | undefined,
394
+ ) => Promise<StepResult<KnownProviderVendorId | 'skip'>>
395
+ pickVisionProviderVariant: (
396
+ vendorId: KnownProviderVendorId,
369
397
  options: ModelOption[],
370
398
  initial: KnownProviderId | undefined,
371
- ) => Promise<StepResult<KnownProviderId | 'skip'>>
399
+ ) => Promise<StepResult<KnownProviderId>>
372
400
  pickVisionModel: (
373
401
  options: ModelOption[],
374
402
  providerId: KnownProviderId,
@@ -399,12 +427,14 @@ export const defaultWizardPrompts: WizardPrompts = {
399
427
  loadCatalog,
400
428
  readExistingApiKey: readExistingProviderApiKey,
401
429
  hasExistingOAuthCredentials,
402
- pickProvider,
430
+ pickVendor,
431
+ pickProviderVariant,
403
432
  pickModel: pickModelForProvider,
404
433
  pickAuthMethod,
405
434
  askApiKey,
406
435
  validateApiKey,
407
- pickVisionProvider,
436
+ pickVisionVendor,
437
+ pickVisionProviderVariant,
408
438
  pickVisionModel,
409
439
  pickChannel,
410
440
  hasExistingChannelSecrets,
@@ -428,7 +458,7 @@ export async function collectWizardInputs(
428
458
  const reset = options.reset === true
429
459
  const catalog = await prompts.loadCatalog()
430
460
  const state: WizardState = { catalog }
431
- let step: StepId = 'pick-provider'
461
+ let step: StepId = 'pick-vendor'
432
462
  let pendingBackOrigin: StepId | null = null
433
463
  let oauthCredentialsSaved = false
434
464
 
@@ -463,29 +493,39 @@ export async function collectWizardInputs(
463
493
 
464
494
  while (true) {
465
495
  switch (step) {
466
- case 'pick-provider': {
467
- const result = onResult(step, await prompts.pickProvider(catalog.options, state.providerId))
496
+ case 'pick-vendor': {
497
+ const result = onResult(step, await prompts.pickVendor(catalog.options, state.vendorId))
468
498
  if (result.kind === 'back') {
469
499
  break
470
500
  }
471
- if (state.providerId !== result.value) {
501
+ if (state.vendorId !== result.value) {
502
+ state.providerId = undefined
472
503
  state.model = undefined
473
504
  state.reuseExisting = undefined
474
505
  state.authMethod = undefined
475
506
  state.llmAuth = undefined
476
507
  }
477
- state.providerId = result.value
478
- step = 'pick-model'
508
+ state.vendorId = result.value
509
+ step = 'pick-provider-variant'
479
510
  break
480
511
  }
481
512
 
482
- case 'pick-model': {
483
- const result = onResult(step, await prompts.pickModel(catalog.options, state.providerId!, state.model?.ref))
513
+ case 'pick-provider-variant': {
514
+ const result = onResult(
515
+ step,
516
+ await prompts.pickProviderVariant(state.vendorId!, catalog.options, state.providerId),
517
+ )
484
518
  if (result.kind === 'back') {
485
- step = 'pick-provider'
519
+ step = 'pick-vendor'
486
520
  break
487
521
  }
488
- state.model = result.value
522
+ if (state.providerId !== result.value) {
523
+ state.model = undefined
524
+ state.reuseExisting = undefined
525
+ state.authMethod = undefined
526
+ state.llmAuth = undefined
527
+ }
528
+ state.providerId = result.value
489
529
  step = 'reuse-existing-key'
490
530
  break
491
531
  }
@@ -503,7 +543,7 @@ export async function collectWizardInputs(
503
543
  log.info(`Reusing existing ${provider.name} API key from secrets.json.`)
504
544
  state.llmAuth = { kind: 'api-key', apiKey: existingApiKey }
505
545
  state.reuseExisting = true
506
- step = stepAfterDefaultAuth(state)
546
+ step = 'pick-model'
507
547
  break
508
548
  }
509
549
  state.reuseExisting = false
@@ -517,12 +557,12 @@ export async function collectWizardInputs(
517
557
  const result = onResult(step, await prompts.pickAuthMethod(provider, state.authMethod))
518
558
  if (result.kind === 'back') {
519
559
  // Skip past `reuse-existing-key` — it is a silent auto-resume
520
- // step with no user prompt, so unwind directly to pick-model
521
- // (the prior user-visible step). This only fires when
522
- // pickAuthMethod was an interactive choice (dual-auth providers);
523
- // single-method providers return autoValue and never reach the
524
- // back branch.
525
- step = 'pick-model'
560
+ // step with no user prompt, so unwind directly to the prior
561
+ // user-visible step (the variant picker when it was interactive,
562
+ // else the vendor picker). This only fires when pickAuthMethod was
563
+ // an interactive choice (dual-auth providers); single-method
564
+ // providers return autoValue and never reach the back branch.
565
+ step = stepBeforeAuthMethod(state)
526
566
  break
527
567
  }
528
568
  state.authMethod = result.value
@@ -530,21 +570,24 @@ export async function collectWizardInputs(
530
570
  // Auto-resume: skip the browser flow when OAuth credentials are
531
571
  // already on disk from a prior partial run. The fresh-tokens path
532
572
  // and the resume path both leave `state.llmAuth = oauth-completed`,
533
- // so downstream steps (vision, channel, scaffold) can't tell the
534
- // difference. `--reset` short-circuits this by making
573
+ // so downstream steps (model, vision, channel, scaffold) can't tell
574
+ // the difference. `--reset` short-circuits this by making
535
575
  // `hasExistingOAuth` return false.
536
576
  if (await hasExistingOAuth(state.providerId!)) {
537
577
  log.info(`Reusing existing ${provider.name} OAuth credentials from secrets.json.`)
538
578
  state.llmAuth = { kind: 'oauth-completed' }
539
- step = stepAfterDefaultAuth(state)
579
+ step = 'pick-model'
540
580
  break
541
581
  }
542
582
  // Run the browser login eagerly so the user sees the OAuth URL the
543
583
  // moment they pick "OAuth (browser login)" — not at the end of the
544
- // wizard. On failure we ask the user how to recover (retry / fall
545
- // back to API key / abort) instead of dumping them back into the
546
- // auth method picker with no guidance.
547
- const login = await runOAuthLoginSafely(prompts, provider, cwd, state.model!.ref)
584
+ // wizard. The model isn't picked yet at this point, so we hand the
585
+ // login the provider's first model ref purely to resolve its
586
+ // `oauthProviderId` (login ignores the model otherwise). On failure
587
+ // we ask the user how to recover (retry / fall back to API key /
588
+ // abort) instead of dumping them back into the auth method picker
589
+ // with no guidance.
590
+ const login = await runOAuthLoginSafely(prompts, provider, cwd, oauthDiscoveryRef(state.providerId!))
548
591
  if (!login.ok) {
549
592
  const recovery = await prompts.askOAuthFailureRecovery(
550
593
  provider,
@@ -560,24 +603,38 @@ export async function collectWizardInputs(
560
603
  if (recovery === 'abort') abort()
561
604
  state.authMethod = recovery === 'api-key' ? 'api-key' : undefined
562
605
  state.llmAuth = undefined
563
- step = recovery === 'api-key' ? 'enter-api-key' : 'pick-auth-method'
606
+ step = recovery === 'api-key' ? 'pick-model' : 'pick-auth-method'
564
607
  break
565
608
  }
566
609
  oauthCredentialsSaved = true
567
610
  state.llmAuth = { kind: 'oauth-completed' }
568
- step = stepAfterDefaultAuth(state)
611
+ step = 'pick-model'
569
612
  } else {
570
- step = 'enter-api-key'
613
+ step = 'pick-model'
571
614
  }
572
615
  break
573
616
  }
574
617
 
618
+ case 'pick-model': {
619
+ const result = onResult(step, await prompts.pickModel(catalog.options, state.providerId!, state.model?.ref))
620
+ if (result.kind === 'back') {
621
+ step = stepBeforeModel(state)
622
+ break
623
+ }
624
+ state.model = result.value
625
+ // OAuth and reused api-key already minted `state.llmAuth`; only a
626
+ // freshly-chosen api-key still needs the key prompt.
627
+ step =
628
+ state.authMethod === 'api-key' && state.reuseExisting !== true ? 'enter-api-key' : stepAfterDefaultAuth(state)
629
+ break
630
+ }
631
+
575
632
  case 'enter-api-key': {
576
633
  const providerId = state.providerId!
577
634
  const provider = KNOWN_PROVIDERS[providerId]
578
635
  const result = onResult(step, await prompts.askApiKey(provider))
579
636
  if (result.kind === 'back') {
580
- step = 'pick-auth-method'
637
+ step = 'pick-model'
581
638
  break
582
639
  }
583
640
  const verdict = await runApiKeyValidation(prompts, providerId, result.value)
@@ -590,14 +647,15 @@ export async function collectWizardInputs(
590
647
  break
591
648
  }
592
649
 
593
- case 'pick-vision-provider': {
650
+ case 'pick-vision-vendor': {
594
651
  const visionOptions = catalog.options.filter((o) => o.supportsVision)
595
- const result = onResult(step, await prompts.pickVisionProvider(visionOptions, state.visionProviderId))
652
+ const result = onResult(step, await prompts.pickVisionVendor(visionOptions, state.visionVendorId))
596
653
  if (result.kind === 'back') {
597
654
  step = stepBeforeVision(state)
598
655
  break
599
656
  }
600
657
  if (result.value === 'skip') {
658
+ state.visionVendorId = undefined
601
659
  state.visionProviderId = undefined
602
660
  state.visionModel = undefined
603
661
  state.visionLlmAuth = undefined
@@ -606,6 +664,28 @@ export async function collectWizardInputs(
606
664
  step = 'pick-channel'
607
665
  break
608
666
  }
667
+ if (state.visionVendorId !== result.value) {
668
+ state.visionProviderId = undefined
669
+ state.visionModel = undefined
670
+ state.visionReuseExisting = undefined
671
+ state.visionAuthMethod = undefined
672
+ state.visionLlmAuth = undefined
673
+ }
674
+ state.visionVendorId = result.value
675
+ step = 'pick-vision-provider-variant'
676
+ break
677
+ }
678
+
679
+ case 'pick-vision-provider-variant': {
680
+ const visionOptions = catalog.options.filter((o) => o.supportsVision)
681
+ const result = onResult(
682
+ step,
683
+ await prompts.pickVisionProviderVariant(state.visionVendorId!, visionOptions, state.visionProviderId),
684
+ )
685
+ if (result.kind === 'back') {
686
+ step = 'pick-vision-vendor'
687
+ break
688
+ }
609
689
  if (state.visionProviderId !== result.value) {
610
690
  state.visionModel = undefined
611
691
  state.visionReuseExisting = undefined
@@ -624,7 +704,7 @@ export async function collectWizardInputs(
624
704
  await prompts.pickVisionModel(visionOptions, state.visionProviderId!, state.visionModel?.ref),
625
705
  )
626
706
  if (result.kind === 'back') {
627
- step = 'pick-vision-provider'
707
+ step = 'pick-vision-provider-variant'
628
708
  break
629
709
  }
630
710
  state.visionModel = result.value
@@ -806,14 +886,33 @@ function channelDisplayName(choice: Exclude<ChannelChoice, 'none'>): string {
806
886
  }
807
887
  }
808
888
 
889
+ // Model is the last default-track step before the vision/channel branch, so
890
+ // vendor/variant/auth all sit upstream of it. Reached after `pick-model`
891
+ // (api-key path also passes through `enter-api-key` first).
809
892
  function stepAfterDefaultAuth(state: WizardState): StepId {
810
- return state.model?.supportsVision === false ? 'pick-vision-provider' : 'pick-channel'
893
+ return state.model?.supportsVision === false ? 'pick-vision-vendor' : 'pick-channel'
894
+ }
895
+
896
+ // Back-target when leaving `pick-auth-method`: the variant picker when it was
897
+ // interactive (multi-provider vendor), else the vendor picker. The
898
+ // `reuse-existing-key` step in between is a silent auto-resume with no prompt.
899
+ function stepBeforeAuthMethod(state: WizardState): StepId {
900
+ return providerIdsForVendor(state.vendorId!).length > 1 ? 'pick-provider-variant' : 'pick-vendor'
811
901
  }
812
902
 
813
- function stepBeforeVision(state: WizardState): StepId {
814
- if (state.reuseExisting === true) return 'reuse-existing-key'
815
- if (state.authMethod === 'api-key') return 'enter-api-key'
816
- return 'pick-auth-method'
903
+ // Back-target when leaving `pick-model`: the auth picker when it was
904
+ // interactive (dual-auth provider), else fall back past the silent
905
+ // auto-resume/auth steps to the prior user-visible picker.
906
+ function stepBeforeModel(state: WizardState): StepId {
907
+ const provider = KNOWN_PROVIDERS[state.providerId!]
908
+ if (providerSupportsApiKey(provider) && providerSupportsOAuth(provider)) return 'pick-auth-method'
909
+ return stepBeforeAuthMethod(state)
910
+ }
911
+
912
+ // Back-target when leaving the vision track to the default track. With model
913
+ // now the final default-track step, that is always `pick-model`.
914
+ function stepBeforeVision(_state: WizardState): StepId {
915
+ return 'pick-model'
817
916
  }
818
917
 
819
918
  function stepBeforePickChannel(state: WizardState): StepId {
@@ -824,10 +923,18 @@ function stepBeforePickChannel(state: WizardState): StepId {
824
923
  if (state.visionAuthMethod === 'oauth') return 'pick-vision-auth-method'
825
924
  return 'pick-vision-model'
826
925
  }
827
- if (state.model?.supportsVision === false) return 'pick-vision-provider'
926
+ if (state.model?.supportsVision === false) return 'pick-vision-vendor'
828
927
  return stepBeforeVision(state)
829
928
  }
830
929
 
930
+ function oauthDiscoveryRef(providerId: KnownProviderId): KnownModelRef {
931
+ // OAuth login only reads the provider's `oauthProviderId` from the ref, so
932
+ // any registered model for the provider works as the discovery handle.
933
+ const modelId = Object.keys(KNOWN_PROVIDERS[providerId].models)[0]
934
+ if (modelId === undefined) throw new Error(`Provider ${providerId} has no registered models for OAuth discovery`)
935
+ return `${providerId}/${modelId}` as KnownModelRef
936
+ }
937
+
831
938
  async function loadCatalog(): Promise<NonNullable<WizardState['catalog']>> {
832
939
  const s = spinner()
833
940
  s.start('Loading model catalog from models.dev...')
@@ -840,15 +947,41 @@ async function loadCatalog(): Promise<NonNullable<WizardState['catalog']>> {
840
947
  return warning !== undefined ? { options, source, warning } : { options, source }
841
948
  }
842
949
 
843
- async function pickProvider(
950
+ async function pickVendor(
844
951
  options: ModelOption[],
845
- initial: KnownProviderId | undefined,
846
- ): Promise<StepResult<KnownProviderId>> {
847
- const providers = uniqueProviders(options)
952
+ initial: KnownProviderVendorId | undefined,
953
+ ): Promise<StepResult<KnownProviderVendorId>> {
954
+ const vendors = uniqueVendors(options)
848
955
  const choice = await select({
849
956
  message: 'Pick an LLM provider',
850
- options: providers.map((id) => ({ value: id, label: KNOWN_PROVIDERS[id].name, hint: providerAuthHint(id) })),
851
- initialValue: initial ?? providers[0],
957
+ options: vendors.map((id) => ({
958
+ value: id,
959
+ label: KNOWN_PROVIDER_VENDORS[id].name,
960
+ hint: vendorHint(id, options),
961
+ })),
962
+ initialValue: initial ?? vendors[0],
963
+ })
964
+ if (isCancel(choice)) return back()
965
+ return value(choice)
966
+ }
967
+
968
+ async function pickProviderVariant(
969
+ vendorId: KnownProviderVendorId,
970
+ options: ModelOption[],
971
+ initial: KnownProviderId | undefined,
972
+ ): Promise<StepResult<KnownProviderId>> {
973
+ const variants = providersForVendorInCatalog(vendorId, options)
974
+ if (variants.length === 0) throw new Error(`Internal error: vendor ${vendorId} has no providers in the catalog`)
975
+ if (variants.length === 1) return autoValue(variants[0]!)
976
+ const choice = await select<KnownProviderId>({
977
+ message: `Pick a ${KNOWN_PROVIDER_VENDORS[vendorId].name} option`,
978
+ options: variants.map((id) => {
979
+ const hint = variantHint(vendorId, id)
980
+ return hint !== undefined
981
+ ? { value: id, label: variantLabel(vendorId, id), hint }
982
+ : { value: id, label: variantLabel(vendorId, id) }
983
+ }),
984
+ initialValue: initial ?? variants[0],
852
985
  })
853
986
  if (isCancel(choice)) return back()
854
987
  return value(choice)
@@ -860,7 +993,11 @@ async function pickModelForProvider(
860
993
  initial: KnownModelRef | undefined,
861
994
  ): Promise<StepResult<ModelOption>> {
862
995
  const candidates = sortRecommendedFirst(options.filter((o) => o.providerId === providerId))
863
- const choice = await select<KnownModelRef>({
996
+ // select<string>, not select<KnownModelRef>: clack's Option<Value> is a
997
+ // distributive conditional type, so a large KnownModelRef union explodes into
998
+ // a per-literal option union that no longer accepts `value: ref`. The runtime
999
+ // value is the ref string and is re-narrowed via `candidates.find` below.
1000
+ const choice = await select<string>({
864
1001
  message: `Pick a ${KNOWN_PROVIDERS[providerId].name} model`,
865
1002
  options: candidates.map((o) => ({
866
1003
  value: o.ref,
@@ -896,38 +1033,47 @@ async function pickAuthMethod(
896
1033
  return autoValue(supportsOAuth ? 'oauth' : 'api-key')
897
1034
  }
898
1035
 
899
- async function pickVisionProvider(
1036
+ async function pickVisionVendor(
900
1037
  options: ModelOption[],
901
- initial: KnownProviderId | undefined,
902
- ): Promise<StepResult<KnownProviderId | 'skip'>> {
903
- const providers = uniqueProviders(options)
904
- if (providers.length === 0) {
1038
+ initial: KnownProviderVendorId | undefined,
1039
+ ): Promise<StepResult<KnownProviderVendorId | 'skip'>> {
1040
+ const vendors = uniqueVendors(options)
1041
+ if (vendors.length === 0) {
905
1042
  log.warn('No vision-capable models available; skipping vision profile.')
906
1043
  return autoValue('skip')
907
1044
  }
908
- const choice = await select<KnownProviderId | 'skip'>({
1045
+ const choice = await select<KnownProviderVendorId | 'skip'>({
909
1046
  message: 'Your model is text-only. Pick a provider for the `vision` profile (used for image input)',
910
1047
  options: [
911
- ...providers.map((id) => ({
912
- value: id as KnownProviderId | 'skip',
913
- label: KNOWN_PROVIDERS[id].name,
914
- hint: providerAuthHint(id),
1048
+ ...vendors.map((id) => ({
1049
+ value: id as KnownProviderVendorId | 'skip',
1050
+ label: KNOWN_PROVIDER_VENDORS[id].name,
1051
+ hint: vendorHint(id, options),
915
1052
  })),
916
1053
  { value: 'skip', label: 'Skip — no vision support', hint: 'add later with `typeclaw model set vision <ref>`' },
917
1054
  ],
918
- initialValue: initial ?? providers[0],
1055
+ initialValue: initial ?? vendors[0],
919
1056
  })
920
1057
  if (isCancel(choice)) return back()
921
1058
  return value(choice)
922
1059
  }
923
1060
 
1061
+ async function pickVisionProviderVariant(
1062
+ vendorId: KnownProviderVendorId,
1063
+ options: ModelOption[],
1064
+ initial: KnownProviderId | undefined,
1065
+ ): Promise<StepResult<KnownProviderId>> {
1066
+ return pickProviderVariant(vendorId, options, initial)
1067
+ }
1068
+
924
1069
  async function pickVisionModel(
925
1070
  options: ModelOption[],
926
1071
  providerId: KnownProviderId,
927
1072
  initial: KnownModelRef | undefined,
928
1073
  ): Promise<StepResult<ModelOption>> {
929
1074
  const candidates = sortRecommendedFirst(options.filter((o) => o.providerId === providerId))
930
- const choice = await select<KnownModelRef>({
1075
+ // select<string> for the same distributive-Option reason as pickModelForProvider.
1076
+ const choice = await select<string>({
931
1077
  message: `Pick a vision-capable ${KNOWN_PROVIDERS[providerId].name} model`,
932
1078
  options: candidates.map((o) => ({
933
1079
  value: o.ref,
@@ -989,21 +1135,52 @@ async function runApiKeyValidation(
989
1135
 
990
1136
  async function askApiKey(provider: (typeof KNOWN_PROVIDERS)[KnownProviderId]): Promise<StepResult<string>> {
991
1137
  const providerId = provider.id as KnownProviderId
992
- const dashboardUrl = API_KEY_DASHBOARD_URL[providerId]
1138
+ const keySurface = await pickMinimaxKeySurface(providerId)
1139
+ if (keySurface.kind === 'back') return back()
1140
+ const label = keySurface.kind === 'chosen' ? keySurface.label : `${provider.name} API`
1141
+ const dashboardUrl = keySurface.kind === 'chosen' ? keySurface.dashboardUrl : API_KEY_DASHBOARD_URL[providerId]
993
1142
  if (dashboardUrl) {
994
1143
  note(
995
1144
  [`Don't have a key yet?`, `Get one at ${dashboardUrl}`, `Then come back and paste it below.`].join('\n'),
996
- `Get a ${provider.name} API key`,
1145
+ `Get a ${label} key`,
997
1146
  )
998
1147
  }
999
1148
  const apiKey = await password({
1000
- message: `Put your ${provider.name} API key (will be saved to secrets.json)`,
1149
+ message: `Put your ${label} key (will be saved to secrets.json)`,
1001
1150
  validate: (v) => (v && v.length > 0 ? undefined : 'API key is required'),
1002
1151
  })
1003
1152
  if (isCancel(apiKey)) return back()
1004
1153
  return value(apiKey)
1005
1154
  }
1006
1155
 
1156
+ type MinimaxKeySurface =
1157
+ | { kind: 'back' }
1158
+ | { kind: 'default' }
1159
+ | { kind: 'chosen'; label: string; dashboardUrl: string }
1160
+
1161
+ // MiniMax issues keys on two billing surfaces (pay-as-you-go vs Token Plan
1162
+ // subscription) that store in the same provider slot but live on different
1163
+ // dashboard pages. For the `minimax` provider, let the user say which one they
1164
+ // have so the deep-link points at the right page; every other provider skips
1165
+ // straight to the paste prompt. The picked surface only changes the label and
1166
+ // dashboard URL — the pasted key is stored identically either way.
1167
+ async function pickMinimaxKeySurface(providerId: KnownProviderId): Promise<MinimaxKeySurface> {
1168
+ if (providerId !== 'minimax') return { kind: 'default' }
1169
+ const choice = await select<'paygo' | 'token-plan'>({
1170
+ message: 'Which MiniMax key do you have?',
1171
+ options: [
1172
+ { value: 'paygo', label: 'Pay-as-you-go API key', hint: 'billed per token' },
1173
+ { value: 'token-plan', label: 'Token Plan API key', hint: 'subscription / Credits (sk-cp-…)' },
1174
+ ],
1175
+ initialValue: 'paygo',
1176
+ })
1177
+ if (isCancel(choice)) return { kind: 'back' }
1178
+ if (choice === 'token-plan') {
1179
+ return { kind: 'chosen', label: 'MiniMax Token Plan', dashboardUrl: MINIMAX_TOKEN_PLAN_DASHBOARD_URL }
1180
+ }
1181
+ return { kind: 'chosen', label: 'MiniMax pay-as-you-go API', dashboardUrl: API_KEY_DASHBOARD_URL.minimax! }
1182
+ }
1183
+
1007
1184
  async function askOAuthFailureRecovery(
1008
1185
  provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
1009
1186
  reason: string,
@@ -1486,15 +1663,23 @@ function reportHatching(event: Extract<InitStepEvent, { step: 'hatching' }>): vo
1486
1663
  }
1487
1664
  }
1488
1665
 
1489
- function uniqueProviders(options: ModelOption[]): KnownProviderId[] {
1490
- const seen = new Set<KnownProviderId>()
1491
- const out: KnownProviderId[] = []
1492
- for (const o of options) {
1493
- if (seen.has(o.providerId)) continue
1494
- seen.add(o.providerId)
1495
- out.push(o.providerId)
1496
- }
1497
- return out
1666
+ function providersInCatalog(options: ModelOption[]): Set<KnownProviderId> {
1667
+ return new Set(options.map((o) => o.providerId))
1668
+ }
1669
+
1670
+ // Vendors with at least one provider present in the catalog, ordered by the
1671
+ // product priority encoded in `KNOWN_PROVIDER_VENDORS` declaration order (not
1672
+ // catalog iteration order).
1673
+ function uniqueVendors(options: ModelOption[]): KnownProviderVendorId[] {
1674
+ const present = providersInCatalog(options)
1675
+ return listKnownProviderVendorIds().filter((vendorId) =>
1676
+ providerIdsForVendor(vendorId).some((providerId) => present.has(providerId)),
1677
+ )
1678
+ }
1679
+
1680
+ function providersForVendorInCatalog(vendorId: KnownProviderVendorId, options: ModelOption[]): KnownProviderId[] {
1681
+ const present = providersInCatalog(options)
1682
+ return providerIdsForVendor(vendorId).filter((providerId) => present.has(providerId))
1498
1683
  }
1499
1684
 
1500
1685
  // Per-provider recommended model refs. Surfaces a "(Recommended)" suffix in
@@ -1528,10 +1713,10 @@ function formatModelHint(o: ModelOption): string {
1528
1713
  return parts.join(' · ')
1529
1714
  }
1530
1715
 
1531
- function providerAuthHint(id: KnownProviderId): string {
1532
- const provider = KNOWN_PROVIDERS[id]
1533
- const apiKey = providerSupportsApiKey(provider)
1534
- const oauth = providerSupportsOAuth(provider)
1716
+ function vendorHint(vendorId: KnownProviderVendorId, options: ModelOption[]): string {
1717
+ const providers = providersForVendorInCatalog(vendorId, options)
1718
+ const apiKey = providers.some((id) => providerSupportsApiKey(KNOWN_PROVIDERS[id]))
1719
+ const oauth = providers.some((id) => providerSupportsOAuth(KNOWN_PROVIDERS[id]))
1535
1720
  if (apiKey && oauth) return 'API key or OAuth'
1536
1721
  if (oauth) return 'OAuth login'
1537
1722
  return 'API key'
package/src/cli/model.ts CHANGED
@@ -230,7 +230,11 @@ async function pickModelRef(cwd: string): Promise<string> {
230
230
  }
231
231
  continue
232
232
  }
233
- const choice = await select<KnownModelRef | typeof ADD_PROVIDER_SENTINEL>({
233
+ // select<string>, not the KnownModelRef union: clack's Option<Value> is a
234
+ // distributive conditional type and a large ref union breaks `value: ref`
235
+ // assignability. Values are ref strings (+ the sentinel) and stay correct
236
+ // at runtime — the sentinel check and `return choice` below are unaffected.
237
+ const choice = await select<string>({
234
238
  message: 'Pick a model',
235
239
  options: [
236
240
  ...refs.map((ref) => ({