@gajae-code/ai 0.5.3 → 0.5.4

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.
@@ -2,7 +2,7 @@ import { $env, $inheritedEnv } from "@gajae-code/utils";
2
2
  import type { ModelManagerOptions } from "../model-manager";
3
3
  import { Effort } from "../model-thinking";
4
4
  import { getBundledModels } from "../models";
5
- import type { Api, Model, ThinkingConfig } from "../types";
5
+ import type { Api, FetchImpl, Model, Provider, ThinkingConfig } from "../types";
6
6
  import { isAnthropicOAuthToken, isRecord, toBoolean, toNumber, toPositiveNumber } from "../utils";
7
7
  import {
8
8
  fetchOpenAICompatibleModels,
@@ -1414,64 +1414,99 @@ export function cloudflareAiGatewayModelManagerOptions(
1414
1414
  // ---------------------------------------------------------------------------
1415
1415
  // 20. Xiaomi
1416
1416
  // ---------------------------------------------------------------------------
1417
+ /** Region codes for Xiaomi Token Plan clusters exposed as separate login providers. */
1418
+ export type XiaomiTokenPlanRegion = "sgp" | "ams" | "cn";
1417
1419
 
1420
+ /** Configures Xiaomi standard or regional Token Plan OpenAI-compatible model discovery. */
1418
1421
  export interface XiaomiModelManagerConfig {
1419
1422
  apiKey?: string;
1420
1423
  baseUrl?: string;
1424
+ fetch?: FetchImpl;
1425
+ providerId?: Provider;
1426
+ tokenPlanRegion?: XiaomiTokenPlanRegion;
1427
+ }
1428
+ const XIAOMI_TOKEN_PLAN_BASE_URLS: Record<XiaomiTokenPlanRegion, string> = {
1429
+ sgp: "https://token-plan-sgp.xiaomimimo.com/v1",
1430
+ ams: "https://token-plan-ams.xiaomimimo.com/v1",
1431
+ cn: "https://token-plan-cn.xiaomimimo.com/v1",
1432
+ };
1433
+
1434
+ const XIAOMI_TOKEN_PLAN_FALLBACK_BASE_URLS = [
1435
+ XIAOMI_TOKEN_PLAN_BASE_URLS.sgp,
1436
+ XIAOMI_TOKEN_PLAN_BASE_URLS.ams,
1437
+ XIAOMI_TOKEN_PLAN_BASE_URLS.cn,
1438
+ ];
1439
+
1440
+ function inferXiaomiTokenPlanRegion(providerId: Provider): XiaomiTokenPlanRegion | undefined {
1441
+ if (providerId === "xiaomi-token-plan-sgp") return "sgp";
1442
+ if (providerId === "xiaomi-token-plan-ams") return "ams";
1443
+ if (providerId === "xiaomi-token-plan-cn") return "cn";
1444
+ return undefined;
1421
1445
  }
1422
1446
 
1447
+ /** Builds a Xiaomi model manager, preserving Token Plan region provider ids during discovery. */
1423
1448
  export function xiaomiModelManagerOptions(
1424
1449
  config?: XiaomiModelManagerConfig,
1425
1450
  ): ModelManagerOptions<"openai-completions"> {
1426
1451
  const apiKey = config?.apiKey;
1427
- // Xiaomi splits API keys across two backends: standard `sk-` keys hit
1428
- // api.xiaomimimo.com; "token plan" `tp-` keys hit either the SG or EU
1429
- // token-plan host. Try SGP first; if discovery fails, retry AMS.
1430
- const TOKEN_PLAN_SGP_BASE_URL = "https://token-plan-sgp.xiaomimimo.com/v1";
1431
- const TOKEN_PLAN_AMS_BASE_URL = "https://token-plan-ams.xiaomimimo.com/v1";
1432
- const defaultBaseUrl = apiKey?.startsWith("tp-") ? TOKEN_PLAN_SGP_BASE_URL : "https://api.xiaomimimo.com/v1";
1433
- // Token-plan keys always use the TP baseUrl; config?.baseUrl (from catalog)
1452
+ const providerId = config?.providerId ?? "xiaomi";
1453
+ const tokenPlanRegion = config?.tokenPlanRegion ?? inferXiaomiTokenPlanRegion(providerId);
1454
+ const tokenPlanBaseUrls = tokenPlanRegion
1455
+ ? [XIAOMI_TOKEN_PLAN_BASE_URLS[tokenPlanRegion]]
1456
+ : XIAOMI_TOKEN_PLAN_FALLBACK_BASE_URLS;
1457
+ const XIAOMI_STANDARD_BASE_URL = "https://api.xiaomimimo.com/v1";
1458
+ const isTokenPlanProvider = tokenPlanRegion !== undefined || providerId.startsWith("xiaomi-token-plan-");
1459
+ const isTokenPlanKey = isTokenPlanProvider || apiKey?.startsWith("tp-");
1460
+ // Token-plan keys always use a TP cluster; config?.baseUrl (from catalog)
1434
1461
  // would incorrectly pin to the standard endpoint (api.xiaomimimo.com).
1435
- const baseUrl = apiKey?.startsWith("tp-") ? defaultBaseUrl : (config?.baseUrl ?? defaultBaseUrl);
1462
+ const baseUrl = isTokenPlanKey ? tokenPlanBaseUrls[0] : (config?.baseUrl ?? XIAOMI_STANDARD_BASE_URL);
1436
1463
  const references = createBundledReferenceMap<"openai-completions">("xiaomi");
1464
+ const throwOnAuthStatus = (response: Response): Error | undefined => {
1465
+ if (response.status === 401 || response.status === 403) {
1466
+ return new Error(`Authentication failed (${response.status}) for ${response.url}`);
1467
+ }
1468
+ return undefined;
1469
+ };
1470
+ const fetchModels = (url: string) =>
1471
+ fetchOpenAICompatibleModels({
1472
+ api: "openai-completions",
1473
+ provider: providerId,
1474
+ baseUrl: url,
1475
+ apiKey,
1476
+ filterModel: (_entry, model) => !model.id.includes("-tts"),
1477
+ mapModel: (entry, defaults) => {
1478
+ const reference = references.get(defaults.id);
1479
+ const model = mapWithBundledReference(entry, defaults, reference);
1480
+ return {
1481
+ ...model,
1482
+ api: "openai-completions",
1483
+ provider: providerId,
1484
+ baseUrl: defaults.baseUrl,
1485
+ name: toModelName(entry.display_name, model.name),
1486
+ };
1487
+ },
1488
+ fetch: config?.fetch,
1489
+ throwOnStatus: throwOnAuthStatus,
1490
+ });
1437
1491
  return {
1438
- providerId: "xiaomi",
1492
+ providerId,
1439
1493
  ...(apiKey && {
1440
1494
  fetchDynamicModels: async () => {
1441
- const sgpResult = await fetchOpenAICompatibleModels({
1442
- api: "openai-completions",
1443
- provider: "xiaomi",
1444
- baseUrl,
1445
- apiKey,
1446
- filterModel: (_entry, model) => !model.id.includes("-tts"),
1447
- mapModel: (entry, defaults) => {
1448
- const reference = references.get(defaults.id);
1449
- const model = mapWithBundledReference(entry, defaults, reference);
1450
- return {
1451
- ...model,
1452
- name: toModelName(entry.display_name, model.name),
1453
- };
1454
- },
1455
- });
1456
- if (sgpResult || !apiKey?.startsWith("tp-")) {
1457
- return sgpResult;
1495
+ if (!isTokenPlanKey) {
1496
+ return fetchModels(baseUrl);
1458
1497
  }
1459
- // Token-plan discovery failed with SGP; retry with AMS
1460
- return fetchOpenAICompatibleModels({
1461
- api: "openai-completions",
1462
- provider: "xiaomi",
1463
- baseUrl: TOKEN_PLAN_AMS_BASE_URL,
1464
- apiKey,
1465
- filterModel: (_entry, model) => !model.id.includes("-tts"),
1466
- mapModel: (entry, defaults) => {
1467
- const reference = references.get(defaults.id);
1468
- const model = mapWithBundledReference(entry, defaults, reference);
1469
- return {
1470
- ...model,
1471
- name: toModelName(entry.display_name, model.name),
1472
- };
1473
- },
1474
- });
1498
+ for (const url of tokenPlanBaseUrls) {
1499
+ try {
1500
+ const result = await fetchModels(url);
1501
+ if (result) return result;
1502
+ } catch (error) {
1503
+ // Auth errors (401/403) should fail fast, not retry other regions
1504
+ const message = error instanceof Error ? error.message : String(error);
1505
+ if (message.includes("Authentication failed")) throw error;
1506
+ // Network/timeout errors: try next URL
1507
+ }
1508
+ }
1509
+ return null;
1475
1510
  },
1476
1511
  }),
1477
1512
  };
@@ -2166,9 +2201,37 @@ const MODELS_DEV_PROVIDER_DESCRIPTORS_CODING_PLANS: readonly ModelsDevProviderDe
2166
2201
  // --- zAI ---
2167
2202
  anthropicMessagesDescriptor("zai-coding-plan", "zai", "https://api.z.ai/api/anthropic"),
2168
2203
  // --- Xiaomi ---
2169
- anthropicMessagesDescriptor("xiaomi", "xiaomi", "https://api.xiaomimimo.com/anthropic", {
2204
+ openAiCompletionsDescriptor("xiaomi", "xiaomi", "https://api.xiaomimimo.com/v1", {
2205
+ defaultContextWindow: 262144,
2206
+ defaultMaxTokens: 8192,
2207
+ compat: {
2208
+ supportsStore: false,
2209
+ thinkingFormat: "zai",
2210
+ },
2211
+ }),
2212
+ openAiCompletionsDescriptor("xiaomi", "xiaomi-token-plan-sgp", "https://token-plan-sgp.xiaomimimo.com/v1", {
2170
2213
  defaultContextWindow: 262144,
2171
2214
  defaultMaxTokens: 8192,
2215
+ compat: {
2216
+ supportsStore: false,
2217
+ thinkingFormat: "zai",
2218
+ },
2219
+ }),
2220
+ openAiCompletionsDescriptor("xiaomi", "xiaomi-token-plan-ams", "https://token-plan-ams.xiaomimimo.com/v1", {
2221
+ defaultContextWindow: 262144,
2222
+ defaultMaxTokens: 8192,
2223
+ compat: {
2224
+ supportsStore: false,
2225
+ thinkingFormat: "zai",
2226
+ },
2227
+ }),
2228
+ openAiCompletionsDescriptor("xiaomi", "xiaomi-token-plan-cn", "https://token-plan-cn.xiaomimimo.com/v1", {
2229
+ defaultContextWindow: 262144,
2230
+ defaultMaxTokens: 8192,
2231
+ compat: {
2232
+ supportsStore: false,
2233
+ thinkingFormat: "zai",
2234
+ },
2172
2235
  }),
2173
2236
  // --- MiniMax Coding Plan ---
2174
2237
  openAiCompletionsDescriptor("minimax-coding-plan", "minimax-code", "https://api.minimax.io/v1", {
package/src/stream.ts CHANGED
@@ -194,6 +194,60 @@ export function listProvidersWithEnvKey(): string[] {
194
194
  return Object.keys(serviceProviderMap);
195
195
  }
196
196
 
197
+ /**
198
+ * Subscription-style providers whose "subscription" is delivered as an API key
199
+ * (created at https://opencode.ai/auth), not a separate OAuth/session token.
200
+ * Used to give OpenCode users an accurate headless auth diagnostic (#755).
201
+ */
202
+ const OPENCODE_SUBSCRIPTION_PROVIDERS = new Set(["opencode-go", "opencode-zen"]);
203
+
204
+ /**
205
+ * Provider-specific credential guidance appended to "no credential" errors.
206
+ *
207
+ * Headless GJC has no interactive `/login` TUI, so a bare "No API key" /
208
+ * "No credentials" error left users — OpenCode Go subscribers especially
209
+ * (#755) — unsure what signal GJC actually reads. OpenCode subscriptions are
210
+ * themselves API keys, so this names the env var GJC reads for the provider,
211
+ * warns that a project `.env` is intentionally ignored for provider
212
+ * credentials, and points OpenCode users at one-time interactive CLI credential capture.
213
+ *
214
+ * Returns an empty string when the provider has no env-var key and no special
215
+ * handling, so callers can append it unconditionally.
216
+ */
217
+ export function formatProviderCredentialHint(provider: string): string {
218
+ const resolver = serviceProviderMap[provider];
219
+ const envVar = typeof resolver === "string" ? resolver : undefined;
220
+ const isOpenCodeSubscription = OPENCODE_SUBSCRIPTION_PROVIDERS.has(provider);
221
+ const parts: string[] = [];
222
+ if (isOpenCodeSubscription) {
223
+ parts.push(
224
+ "OpenCode subscriptions authenticate with an API key (created at https://opencode.ai/auth), not a separate session/OAuth token.",
225
+ );
226
+ }
227
+ if (envVar) {
228
+ parts.push(
229
+ `Headless GJC reads this provider's key from ${envVar} (exported in your shell or set in ~/.gjc/.env).`,
230
+ );
231
+ parts.push("A value set only in a project .env is intentionally ignored for provider credentials.");
232
+ }
233
+ if (isOpenCodeSubscription) {
234
+ parts.push(
235
+ `Or run \`gjc auth-broker login ${provider}\` once before headless/print mode to store the key interactively.`,
236
+ );
237
+ }
238
+ return parts.join(" ");
239
+ }
240
+
241
+ /**
242
+ * Build an actionable "missing API key" error for a provider, used by the
243
+ * low-level `stream`/`complete` entry points (#755).
244
+ */
245
+ export function formatMissingApiKeyError(provider: string): string {
246
+ const base = `No API key for provider: ${provider}.`;
247
+ const hint = formatProviderCredentialHint(provider);
248
+ return hint ? `${base} ${hint}` : base;
249
+ }
250
+
197
251
  export function stream<TApi extends Api>(
198
252
  model: Model<TApi>,
199
253
  context: Context,
@@ -208,7 +262,7 @@ export function stream<TApi extends Api>(
208
262
  if (isGitLabDuoModel(model)) {
209
263
  const apiKey = (options as StreamOptions | undefined)?.apiKey || getEnvApiKey(model.provider);
210
264
  if (!apiKey) {
211
- throw new Error(`No API key for provider: ${model.provider}`);
265
+ throw new Error(formatMissingApiKeyError(model.provider));
212
266
  }
213
267
  return streamGitLabDuo(model, context, {
214
268
  ...(options as SimpleStreamOptions | undefined),
@@ -226,7 +280,7 @@ export function stream<TApi extends Api>(
226
280
 
227
281
  const apiKey = options?.apiKey || getEnvApiKey(model.provider);
228
282
  if (!apiKey) {
229
- throw new Error(`No API key for provider: ${model.provider}`);
283
+ throw new Error(formatMissingApiKeyError(model.provider));
230
284
  }
231
285
  const providerOptions = { ...options, apiKey };
232
286
 
@@ -410,7 +464,7 @@ export function streamSimple<TApi extends Api>(
410
464
 
411
465
  const apiKey = options?.apiKey || getEnvApiKey(model.provider);
412
466
  if (!apiKey) {
413
- throw new Error(`No API key for provider: ${model.provider}`);
467
+ throw new Error(formatMissingApiKeyError(model.provider));
414
468
  }
415
469
 
416
470
  // GitLab Duo - wraps Anthropic/OpenAI behind GitLab AI Gateway direct access tokens
package/src/types.ts CHANGED
@@ -141,6 +141,9 @@ export type KnownProvider =
141
141
  | "venice"
142
142
  | "vllm"
143
143
  | "xiaomi"
144
+ | "xiaomi-token-plan-sgp"
145
+ | "xiaomi-token-plan-ams"
146
+ | "xiaomi-token-plan-cn"
144
147
  | "zenmux"
145
148
  | "lm-studio";
146
149
  export type Provider = KnownProvider | string;
@@ -1,6 +1,6 @@
1
1
  import { UNK_CONTEXT_WINDOW, UNK_MAX_TOKENS } from "@gajae-code/ai";
2
2
  import * as z from "zod/v4";
3
- import type { Api, Model, Provider } from "../../types";
3
+ import type { Api, FetchImpl, Model, Provider } from "../../types";
4
4
 
5
5
  const MODELS_PATH = "/models";
6
6
 
@@ -81,7 +81,9 @@ export interface FetchOpenAICompatibleModelsOptions<TApi extends Api> {
81
81
  /** Optional AbortSignal for request cancellation. */
82
82
  signal?: AbortSignal;
83
83
  /** Optional fetch implementation override for testing/custom runtimes. */
84
- fetch?: typeof globalThis.fetch;
84
+ fetch?: FetchImpl;
85
+ /** Optional HTTP status predicate for provider-specific hard failures. */
86
+ throwOnStatus?: (response: Response) => Error | undefined;
85
87
  /**
86
88
  * Optional post-normalization filter.
87
89
  * Return false to skip a model.
@@ -133,6 +135,10 @@ export async function fetchOpenAICompatibleModels<TApi extends Api>(
133
135
  }
134
136
 
135
137
  if (!response.ok) {
138
+ const hardFailure = options.throwOnStatus?.(response);
139
+ if (hardFailure) {
140
+ throw hardFailure;
141
+ }
136
142
  return null;
137
143
  }
138
144
 
@@ -140,6 +140,21 @@ const builtInOAuthProviders: OAuthProviderInfo[] = [
140
140
  name: "Xiaomi MiMo",
141
141
  available: true,
142
142
  },
143
+ {
144
+ id: "xiaomi-token-plan-sgp",
145
+ name: "Xiaomi Token Plan (Singapore)",
146
+ available: true,
147
+ },
148
+ {
149
+ id: "xiaomi-token-plan-ams",
150
+ name: "Xiaomi Token Plan (Europe)",
151
+ available: true,
152
+ },
153
+ {
154
+ id: "xiaomi-token-plan-cn",
155
+ name: "Xiaomi Token Plan (China)",
156
+ available: true,
157
+ },
143
158
  {
144
159
  id: "opencode-zen",
145
160
  name: "OpenCode Zen",
@@ -50,6 +50,9 @@ export type OAuthProvider =
50
50
  | "vllm"
51
51
  | "xai"
52
52
  | "xiaomi"
53
+ | "xiaomi-token-plan-sgp"
54
+ | "xiaomi-token-plan-ams"
55
+ | "xiaomi-token-plan-cn"
53
56
  | "zenmux"
54
57
  | "zai";
55
58
 
@@ -78,6 +81,7 @@ export interface OAuthController {
78
81
  onManualCodeInput?(): Promise<string>;
79
82
  onPrompt?(prompt: OAuthPrompt): Promise<string>;
80
83
  signal?: AbortSignal;
84
+ fetch?: typeof globalThis.fetch;
81
85
  }
82
86
 
83
87
  export interface OAuthLoginCallbacks extends OAuthController {
@@ -4,23 +4,44 @@
4
4
  * Xiaomi MiMo provides OpenAI-compatible models via
5
5
  * https://api.xiaomimimo.com/v1.
6
6
  *
7
- * This is not OAuth - it's a simple API key flow:
8
- * 1. Open browser to Xiaomi MiMo API key console
9
- * 2. User copies their API key
10
- * 3. User pastes the API key into the CLI
7
+ * Standard Xiaomi login opens the pay-as-you-go API key console. Token Plan
8
+ * login opens plan management so users copy the regional `tp-...` key.
11
9
  */
12
10
 
11
+ import type { FetchImpl } from "../../types";
13
12
  import type { OAuthController } from "./types";
14
13
 
15
14
  const PROVIDER_ID = "xiaomi";
16
15
  const PROVIDER_NAME = "Xiaomi MiMo";
17
16
  const STANDARD_AUTH_URL = "https://platform.xiaomimimo.com/#/console/api-keys";
17
+ const TOKEN_PLAN_AUTH_URL = "https://platform.xiaomimimo.com/console/plan-manage";
18
18
  const STANDARD_API_BASE_URL = "https://api.xiaomimimo.com/v1";
19
- const TOKEN_PLAN_SGP_API_BASE_URL = "https://token-plan-sgp.xiaomimimo.com/v1";
20
- const TOKEN_PLAN_AMS_API_BASE_URL = "https://token-plan-ams.xiaomimimo.com/v1";
21
19
  const TOKEN_PLAN_KEY_PREFIX = "tp-";
22
20
  const STANDARD_VALIDATION_MODEL = "mimo-v2-flash";
23
21
  const TOKEN_PLAN_VALIDATION_MODEL = "mimo-v2.5";
22
+ const TOKEN_PLAN_SGP_API_BASE_URL = "https://token-plan-sgp.xiaomimimo.com/v1";
23
+ const TOKEN_PLAN_AMS_API_BASE_URL = "https://token-plan-ams.xiaomimimo.com/v1";
24
+ const TOKEN_PLAN_CN_API_BASE_URL = "https://token-plan-cn.xiaomimimo.com/v1";
25
+
26
+ /** Region codes accepted by the Xiaomi Token Plan login flow. */
27
+ export type XiaomiTokenPlanRegion = "sgp" | "ams" | "cn";
28
+
29
+ type XiaomiValidationEndpoint = {
30
+ baseUrl: string;
31
+ model: string;
32
+ };
33
+
34
+ const TOKEN_PLAN_VALIDATION_ENDPOINTS: Record<XiaomiTokenPlanRegion, XiaomiValidationEndpoint> = {
35
+ sgp: { baseUrl: TOKEN_PLAN_SGP_API_BASE_URL, model: TOKEN_PLAN_VALIDATION_MODEL },
36
+ ams: { baseUrl: TOKEN_PLAN_AMS_API_BASE_URL, model: TOKEN_PLAN_VALIDATION_MODEL },
37
+ cn: { baseUrl: TOKEN_PLAN_CN_API_BASE_URL, model: TOKEN_PLAN_VALIDATION_MODEL },
38
+ };
39
+
40
+ const TOKEN_PLAN_REGION_NAMES: Record<XiaomiTokenPlanRegion, string> = {
41
+ sgp: "Singapore",
42
+ ams: "Europe",
43
+ cn: "China",
44
+ };
24
45
 
25
46
  function isTokenPlanKey(apiKey: string): boolean {
26
47
  return apiKey.startsWith(TOKEN_PLAN_KEY_PREFIX);
@@ -28,15 +49,24 @@ function isTokenPlanKey(apiKey: string): boolean {
28
49
 
29
50
  const VALIDATION_TIMEOUT_MS = 15_000;
30
51
 
31
- async function validateXiaomiApiKey(apiKey: string, signal?: AbortSignal): Promise<void> {
32
- // For token-plan keys try SGP first, then AMS as fallback.
33
- // Standard sk- keys only hit the one endpoint.
34
- const endpoints = isTokenPlanKey(apiKey)
35
- ? [
36
- { baseUrl: TOKEN_PLAN_SGP_API_BASE_URL, model: TOKEN_PLAN_VALIDATION_MODEL },
37
- { baseUrl: TOKEN_PLAN_AMS_API_BASE_URL, model: TOKEN_PLAN_VALIDATION_MODEL },
38
- ]
39
- : [{ baseUrl: STANDARD_API_BASE_URL, model: STANDARD_VALIDATION_MODEL }];
52
+ async function validateXiaomiApiKey(
53
+ apiKey: string,
54
+ tokenPlanRegion: XiaomiTokenPlanRegion | undefined,
55
+ signal?: AbortSignal,
56
+ fetchOverride?: FetchImpl,
57
+ ): Promise<void> {
58
+ const fetchImpl = fetchOverride ?? fetch;
59
+ // Region-specific Token Plan logins must validate against the selected
60
+ // cluster. Generic Xiaomi login keeps the historical SGP → AMS → CN fallback.
61
+ const endpoints = tokenPlanRegion
62
+ ? [TOKEN_PLAN_VALIDATION_ENDPOINTS[tokenPlanRegion]]
63
+ : isTokenPlanKey(apiKey)
64
+ ? [
65
+ TOKEN_PLAN_VALIDATION_ENDPOINTS.sgp,
66
+ TOKEN_PLAN_VALIDATION_ENDPOINTS.ams,
67
+ TOKEN_PLAN_VALIDATION_ENDPOINTS.cn,
68
+ ]
69
+ : [{ baseUrl: STANDARD_API_BASE_URL, model: STANDARD_VALIDATION_MODEL }];
40
70
 
41
71
  let lastError: Error | null = null;
42
72
 
@@ -47,11 +77,11 @@ async function validateXiaomiApiKey(apiKey: string, signal?: AbortSignal): Promi
47
77
  const timeoutSignal = AbortSignal.timeout(VALIDATION_TIMEOUT_MS);
48
78
  const requestSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
49
79
  try {
50
- const response = await fetch(`${ep.baseUrl}/chat/completions`, {
80
+ const response = await fetchImpl(`${ep.baseUrl}/chat/completions`, {
51
81
  method: "POST",
52
82
  headers: {
53
83
  "Content-Type": "application/json",
54
- "x-api-key": apiKey,
84
+ Authorization: `Bearer ${apiKey}`,
55
85
  },
56
86
  body: JSON.stringify({
57
87
  model: ep.model,
@@ -112,6 +142,7 @@ async function validateXiaomiApiKey(apiKey: string, signal?: AbortSignal): Promi
112
142
  * Returns the API key directly (not OAuthCredentials - this isn't OAuth).
113
143
  */
114
144
  export async function loginXiaomi(options: OAuthController): Promise<string> {
145
+ const fetchImpl = options.fetch ?? fetch;
115
146
  if (!options.onPrompt) {
116
147
  throw new Error(`${PROVIDER_NAME} login requires onPrompt callback`);
117
148
  }
@@ -132,6 +163,37 @@ export async function loginXiaomi(options: OAuthController): Promise<string> {
132
163
  }
133
164
 
134
165
  options.onProgress?.(`Validating ${PROVIDER_ID} API key...`);
135
- await validateXiaomiApiKey(trimmed, options.signal);
166
+ await validateXiaomiApiKey(trimmed, undefined, options.signal, fetchImpl);
167
+ return trimmed;
168
+ }
169
+
170
+ /**
171
+ * Login to a regional Xiaomi Token Plan endpoint.
172
+ *
173
+ * Prompts for a token-plan API key and validates it against the selected region.
174
+ */
175
+ export async function loginXiaomiTokenPlan(options: OAuthController, region: XiaomiTokenPlanRegion): Promise<string> {
176
+ const fetchImpl = options.fetch ?? fetch;
177
+ if (!options.onPrompt) {
178
+ throw new Error(`Xiaomi Token Plan (${TOKEN_PLAN_REGION_NAMES[region]}) login requires onPrompt callback`);
179
+ }
180
+ options.onAuth?.({
181
+ url: TOKEN_PLAN_AUTH_URL,
182
+ instructions: `Copy your token-plan API key for the ${TOKEN_PLAN_REGION_NAMES[region]} region`,
183
+ });
184
+ const apiKey = await options.onPrompt({
185
+ message: `Paste your Xiaomi Token Plan ${TOKEN_PLAN_REGION_NAMES[region]} API key (tp-...)`,
186
+ placeholder: "tp-...",
187
+ });
188
+ if (options.signal?.aborted) {
189
+ throw new Error("Login cancelled");
190
+ }
191
+ const trimmed = apiKey.trim();
192
+ if (!trimmed) {
193
+ throw new Error("API key is required");
194
+ }
195
+
196
+ options.onProgress?.(`Validating Xiaomi Token Plan (${TOKEN_PLAN_REGION_NAMES[region]}) API key...`);
197
+ await validateXiaomiApiKey(trimmed, region, options.signal, fetchImpl);
136
198
  return trimmed;
137
199
  }