@oh-my-pi/pi-ai 18.0.8 → 18.0.10

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
@@ -2,6 +2,14 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.0.9] - 2026-08-28
6
+
7
+ ### Fixed
8
+
9
+ - Improved OAuth sign-in flows, including a fallback message when the browser cannot automatically close the OAuth success tab.
10
+ - Fixed Cloudflare AI Gateway onboarding and routing so gateway account and endpoint configuration is preserved correctly while gateway credentials are not sent as upstream OpenAI authorization headers.
11
+ - Fixed Codex OAuth quota handling so chat and Spark usage remain independent, legacy shared quota limits continue to work, and incomplete usage reports are not incorrectly treated as unlimited.
12
+
5
13
  ## [18.0.8] - 2026-08-27
6
14
 
7
15
  ### Added
package/README.md CHANGED
@@ -76,7 +76,7 @@ Unified LLM API with automatic model discovery, provider configuration, token an
76
76
  - **Qwen Portal** (supports `QWEN_OAUTH_TOKEN` or `QWEN_PORTAL_API_KEY`)
77
77
  - **QwenCloud Token Plan** (supports `/login alibaba-token-plan`, `ALIBABA_TOKEN_PLAN_API_KEY`, or `BAILIAN_TOKEN_PLAN_API_KEY`; interactive login first selects a region — International (Singapore, default), China (Beijing) for 百炼 Token Plan keys, or a custom base URL — since region keys are non-interchangeable, then optionally stores a `home.qwencloud.com` Cookie request header for best-effort 5-hour and 7-day quota reporting)
78
78
  To enable quota reporting, sign in to the Token Plan dashboard, copy the `Cookie` request-header value from a `home.qwencloud.com` request in browser developer tools, and paste it at the second login prompt. Press Enter to skip; the Cookie is sensitive and session-lived, so rerun login when it expires.
79
- - **Cloudflare AI Gateway** (requires `CLOUDFLARE_AI_GATEWAY_API_KEY` and provider-specific gateway base URL)
79
+ - **Cloudflare AI Gateway** (supports `/login cloudflare-ai-gateway`, or `CLOUDFLARE_AI_GATEWAY_API_KEY` with `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_GATEWAY_ID`)
80
80
  - **Ollama** (local OpenAI-compatible runtime; optional `OLLAMA_API_KEY`)
81
81
  - **Ollama Cloud** (hosted native Ollama API; requires `OLLAMA_CLOUD_API_KEY`)
82
82
  - **llama.cpp** (local OpenAI and Anthropic compatible inference server)
@@ -960,11 +960,10 @@ In Node.js environments, you can set environment variables to avoid passing API
960
960
  | Xiaomi MiMo | `XIAOMI_API_KEY` |
961
961
  | ZenMux | `ZENMUX_API_KEY` |
962
962
  | vLLM | `VLLM_API_KEY` |
963
- | Cloudflare AI Gateway | `CLOUDFLARE_AI_GATEWAY_API_KEY` |
963
+ | Cloudflare AI Gateway | `CLOUDFLARE_AI_GATEWAY_API_KEY` + `CLOUDFLARE_ACCOUNT_ID` + `CLOUDFLARE_GATEWAY_ID` |
964
964
  | GitHub Copilot | `COPILOT_GITHUB_TOKEN` or `GH_TOKEN` or `GITHUB_TOKEN` |
965
965
 
966
- For Cloudflare AI Gateway models, use provider base URL format
967
- `https://gateway.ai.cloudflare.com/v1/<account>/<gateway>/anthropic`.
966
+ `/login cloudflare-ai-gateway` collects and stores the gateway token, account ID, and gateway ID. For environment configuration, set all three Cloudflare values above. OMP derives provider endpoints from the account and gateway IDs.
968
967
 
969
968
  For Anthropic Foundry routing, set `CLAUDE_CODE_USE_FOUNDRY=true` plus:
970
969
  `FOUNDRY_BASE_URL`, `ANTHROPIC_FOUNDRY_API_KEY`, optional `ANTHROPIC_CUSTOM_HEADERS`,
@@ -996,7 +995,7 @@ Provider endpoint defaults for the current OpenAI-compatible integrations:
996
995
  - Ollama: local OpenAI-compatible runtime (`http://127.0.0.1:11434/v1`)
997
996
  - Ollama Cloud: native Ollama API host (`https://ollama.com/api`, configured here as base URL `https://ollama.com`)
998
997
  - LiteLLM: `http://localhost:4000/v1`
999
- - Cloudflare AI Gateway: `https://gateway.ai.cloudflare.com/v1/<account>/<gateway>/anthropic`
998
+ - Cloudflare AI Gateway: native Anthropic, OpenAI, and Workers AI routes under `https://gateway.ai.cloudflare.com/v1/<account>/<gateway>`
1000
999
  - Qwen Portal: `https://portal.qwen.ai/v1`
1001
1000
  When set, the library automatically uses these keys:
1002
1001
 
@@ -821,6 +821,18 @@ export declare class AuthStorage {
821
821
  * `XAI_API_KEY` does not auto-select SuperGrok (`xai-oauth`).
822
822
  */
823
823
  hasAuth(provider: string): boolean;
824
+ /**
825
+ * Like {@link hasAuth} but excludes providers whose only credential is the
826
+ * self-resolving {@link AUTHENTICATED_SENTINEL} — the marker AWS/Vertex
827
+ * transports return when a credential *source* merely exists (a stray
828
+ * `~/.aws` profile, an EC2 instance role, Application Default Credentials)
829
+ * without a usable key resolved yet. Default-model auto-selection uses this
830
+ * so an ambiently-available provider (e.g. `amazon-bedrock` via an unrelated
831
+ * AWS profile) does not win the startup default over a provider the user
832
+ * actually signed into and then 403 on the first turn. Explicit selection
833
+ * and picker visibility still go through {@link hasAuth}. See issue #9967.
834
+ */
835
+ hasConcreteAuth(provider: string): boolean;
824
836
  /**
825
837
  * Whether a request could resolve a key for this provider, including
826
838
  * cross-provider env aliases (`xai-oauth` borrowing `XAI_API_KEY`).
@@ -1,13 +1,13 @@
1
- import type { OAuthLoginCallbacks } from "./oauth/types.js";
2
- /**
3
- * Login to Cloudflare AI Gateway.
4
- *
5
- * Opens browser to Cloudflare AI Gateway authentication docs and prompts for a gateway token/API key.
6
- * Returns the API key directly (not OAuthCredentials - this isn't OAuth).
7
- */
8
- export declare const loginCloudflareAiGateway: (options: import("./oauth/index.js").OAuthController) => Promise<string>;
1
+ import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types.js";
2
+ /** Collect the gateway credential used by CLI, setup-wizard, and TUI login callers. */
3
+ export declare function loginCloudflareAiGateway(options: OAuthController): Promise<string>;
9
4
  export declare const cloudflareAiGatewayProvider: {
10
5
  readonly id: "cloudflare-ai-gateway";
11
6
  readonly name: "Cloudflare AI Gateway";
7
+ readonly prepareModel: (model: import("@oh-my-pi/pi-catalog").Model<import("@oh-my-pi/pi-catalog").Api>) => import("@oh-my-pi/pi-catalog").Model<import("@oh-my-pi/pi-catalog").Api>;
8
+ readonly prepareRequest: (model: import("@oh-my-pi/pi-catalog").Model<import("@oh-my-pi/pi-catalog").Api>, options: import("../index.js").StreamOptions) => {
9
+ model: import("@oh-my-pi/pi-catalog").Model<import("@oh-my-pi/pi-catalog").Api>;
10
+ options: import("../index.js").StreamOptions;
11
+ };
12
12
  readonly login: (cb: OAuthLoginCallbacks) => Promise<string>;
13
13
  };
@@ -72,6 +72,11 @@ declare const ALL: ({
72
72
  } | {
73
73
  readonly id: "cloudflare-ai-gateway";
74
74
  readonly name: "Cloudflare AI Gateway";
75
+ readonly prepareModel: (model: import("@oh-my-pi/pi-catalog").Model<import("@oh-my-pi/pi-catalog").Api>) => import("@oh-my-pi/pi-catalog").Model<import("@oh-my-pi/pi-catalog").Api>;
76
+ readonly prepareRequest: (model: import("@oh-my-pi/pi-catalog").Model<import("@oh-my-pi/pi-catalog").Api>, options: import("../index.js").StreamOptions) => {
77
+ model: import("@oh-my-pi/pi-catalog").Model<import("@oh-my-pi/pi-catalog").Api>;
78
+ options: import("../index.js").StreamOptions;
79
+ };
75
80
  readonly login: (cb: import("./oauth/index.js").OAuthLoginCallbacks) => Promise<string>;
76
81
  } | {
77
82
  readonly id: "coreweave";
@@ -24,6 +24,7 @@ export interface PreparedProviderRequest {
24
24
  readonly options: StreamOptions;
25
25
  }
26
26
  export type ProviderRequestPreparer = (model: Model<Api>, options: StreamOptions) => PreparedProviderRequest;
27
+ export type ProviderModelPreparer = (model: Model<Api>) => Model<Api>;
27
28
  export type ProviderSimpleOptionsMapper = (options: SimpleStreamOptions) => Readonly<Record<string, unknown>>;
28
29
  export interface ProviderModelDiscoveryConfig {
29
30
  readonly apiKey?: string;
@@ -57,6 +58,8 @@ export interface ProviderDefinition {
57
58
  readonly envKeys?: KeyResolver;
58
59
  /** Provider transport can authenticate without a resolved API-key string. */
59
60
  readonly allowsMissingApiKey?: boolean;
61
+ /** Provider-owned model normalization that must run before API-specific option mapping. */
62
+ readonly prepareModel?: ProviderModelPreparer;
60
63
  /** Provider-owned request shaping applied before generic API dispatch. */
61
64
  readonly prepareRequest?: ProviderRequestPreparer;
62
65
  /** Provider-owned projection from the generic simple-stream option bag. */
@@ -1,5 +1,16 @@
1
- import type { CredentialRankingStrategy, UsageProvider } from "../usage.js";
1
+ import type { CredentialRankingContext, CredentialRankingStrategy, UsageLimit, UsageProvider, UsageReport } from "../usage.js";
2
2
  export declare const antigravityUsageProvider: UsageProvider;
3
+ /** Map an Antigravity model id to its backend quota-counter key. */
4
+ export declare function getAntigravityCounterKeyForModel(modelId: string | undefined): string | undefined;
5
+ /**
6
+ * Scope an Antigravity report to the active model's backend counter, falling
7
+ * back to legacy default counters only when that backend has no limits.
8
+ *
9
+ * Exhaustion checks are only safe with a concrete backend counter. A no-model
10
+ * credential lookup (for example image-provider discovery) must not turn one
11
+ * exhausted family into a provider-wide block.
12
+ */
13
+ export declare function scopeAntigravityLimitsForModel(report: UsageReport, context: CredentialRankingContext | undefined): UsageLimit[];
3
14
  /**
4
15
  * Antigravity quotas are returned per backend counter (Anthropic / Google /
5
16
  * OpenAI) and can include both daily and weekly windows. `fetchAntigravityUsage`
@@ -528,6 +528,10 @@ export interface CredentialRankingStrategy {
528
528
  primaryMs: number;
529
529
  secondaryMs: number;
530
530
  };
531
- /** Optional: priority boost for specific credential states (e.g., fresh 5h ticker start). */
532
- hasPriorityBoost?(primary: UsageLimit | undefined): boolean;
531
+ /**
532
+ * Optional: priority boost for specific credential states (e.g., fresh 5h
533
+ * ticker start). `primaryUncapped` is true only when the fetched report has
534
+ * an applicable secondary window but no applicable primary window.
535
+ */
536
+ hasPriorityBoost?(primary: UsageLimit | undefined, primaryUncapped?: boolean, context?: CredentialRankingContext): boolean;
533
537
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "18.0.8",
4
+ "version": "18.0.10",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Stencil Labs, Inc.",
@@ -37,10 +37,10 @@
37
37
  "fmt": "biome format --write ."
38
38
  },
39
39
  "dependencies": {
40
- "@oh-my-pi/omptype": "18.0.8",
41
- "@oh-my-pi/pi-catalog": "18.0.8",
42
- "@oh-my-pi/pi-utils": "18.0.8",
43
- "@oh-my-pi/pi-wire": "18.0.8"
40
+ "@oh-my-pi/omptype": "18.0.10",
41
+ "@oh-my-pi/pi-catalog": "18.0.10",
42
+ "@oh-my-pi/pi-utils": "18.0.10",
43
+ "@oh-my-pi/pi-wire": "18.0.10"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@types/bun": "^1.3.14"
@@ -8,6 +8,7 @@ import { Database, type Statement } from "bun:sqlite";
8
8
  import * as fs from "node:fs/promises";
9
9
  import * as path from "node:path";
10
10
  import { parseAlibabaTokenPlanCredential } from "@oh-my-pi/pi-catalog/wire/alibaba-token-plan";
11
+ import { parseCloudflareAiGatewayCredential } from "@oh-my-pi/pi-catalog/wire/cloudflare-ai-gateway";
11
12
  import {
12
13
  getAgentDbPath,
13
14
  getDbBusyTimeoutMs,
@@ -215,10 +216,17 @@ function matchesReplacementCredential(
215
216
  if (incoming.type === "api_key") {
216
217
  if (existing.type !== "api_key") return false;
217
218
  if (existing.key === incoming.key) return true;
218
- if (provider !== "alibaba-token-plan") return false;
219
- const existingToken = parseAlibabaTokenPlanCredential(existing.key)?.token;
220
- const incomingToken = parseAlibabaTokenPlanCredential(incoming.key)?.token;
221
- return existingToken !== undefined && existingToken === incomingToken;
219
+ if (provider === "alibaba-token-plan") {
220
+ const existingToken = parseAlibabaTokenPlanCredential(existing.key)?.token;
221
+ const incomingToken = parseAlibabaTokenPlanCredential(incoming.key)?.token;
222
+ return existingToken !== undefined && existingToken === incomingToken;
223
+ }
224
+ if (provider === "cloudflare-ai-gateway") {
225
+ const existingToken = parseCloudflareAiGatewayCredential(existing.key)?.token;
226
+ const incomingToken = parseCloudflareAiGatewayCredential(incoming.key)?.token;
227
+ return existingToken !== undefined && existingToken === incomingToken;
228
+ }
229
+ return false;
222
230
  }
223
231
  const incomingIdentifiers = extractOAuthCredentialIdentifiers(incoming);
224
232
  const incomingIdentityKey = resolveProviderCredentialIdentityKey(provider, incomingIdentifiers);
@@ -28,6 +28,7 @@ import type {
28
28
  OAuthProvider,
29
29
  OAuthProviderId,
30
30
  } from "./registry/oauth/types";
31
+ import { AUTHENTICATED_SENTINEL } from "./registry/types";
31
32
  import { getEnvApiKey, getEnvApiKeyName } from "./stream";
32
33
  import type { Provider } from "./types";
33
34
  import type {
@@ -1272,6 +1273,7 @@ type UsageRankedCandidate<T extends AuthCredential> = UsageCandidate<T> & {
1272
1273
  blocked: boolean;
1273
1274
  blockedUntil?: number;
1274
1275
  hasPriorityBoost: boolean;
1276
+ usageMeasured: boolean;
1275
1277
  planPriority: number;
1276
1278
  secondaryUsed: number;
1277
1279
  secondaryRequiredDrain: number;
@@ -2168,13 +2170,16 @@ export class AuthStorage {
2168
2170
  const windows = usage ? strategy.findWindowLimits(usage, args.rankingContext) : undefined;
2169
2171
  const primary = windows?.primary;
2170
2172
  const secondary = windows?.secondary;
2173
+ const usageMeasured = primary !== undefined || secondary !== undefined;
2174
+ const primaryUncapped = primary === undefined && secondary !== undefined;
2171
2175
  ranked.push({
2172
2176
  selection,
2173
2177
  usage,
2174
2178
  usageChecked,
2175
2179
  blocked,
2176
2180
  blockedUntil,
2177
- hasPriorityBoost: strategy.hasPriorityBoost?.(primary) ?? false,
2181
+ usageMeasured,
2182
+ hasPriorityBoost: strategy.hasPriorityBoost?.(primary, primaryUncapped, args.rankingContext) ?? false,
2178
2183
  planPriority: 0,
2179
2184
  secondaryUsed: this.#normalizeUsageFraction(secondary),
2180
2185
  secondaryRequiredDrain: this.#computeWindowRequiredDrain(
@@ -2748,6 +2753,34 @@ export class AuthStorage {
2748
2753
  return false;
2749
2754
  }
2750
2755
 
2756
+ /**
2757
+ * Like {@link hasAuth} but excludes providers whose only credential is the
2758
+ * self-resolving {@link AUTHENTICATED_SENTINEL} — the marker AWS/Vertex
2759
+ * transports return when a credential *source* merely exists (a stray
2760
+ * `~/.aws` profile, an EC2 instance role, Application Default Credentials)
2761
+ * without a usable key resolved yet. Default-model auto-selection uses this
2762
+ * so an ambiently-available provider (e.g. `amazon-bedrock` via an unrelated
2763
+ * AWS profile) does not win the startup default over a provider the user
2764
+ * actually signed into and then 403 on the first turn. Explicit selection
2765
+ * and picker visibility still go through {@link hasAuth}. See issue #9967.
2766
+ */
2767
+ hasConcreteAuth(provider: string): boolean {
2768
+ if (this.#runtimeOverrides.has(provider)) return true;
2769
+ if (this.#configOverrides.has(provider)) return true;
2770
+ if (this.#getCredentialsForProvider(provider).length > 0) return true;
2771
+ if ((provider === "amazon-bedrock" || provider === "bedrock-mantle") && $env.AWS_BEARER_TOKEN_BEDROCK?.trim()) {
2772
+ return true;
2773
+ }
2774
+ if (provider === "xai-oauth") {
2775
+ if ($env.XAI_OAUTH_TOKEN?.trim()) return true;
2776
+ } else {
2777
+ const envApiKey = getEnvApiKey(provider);
2778
+ if (envApiKey !== undefined && envApiKey !== AUTHENTICATED_SENTINEL) return true;
2779
+ }
2780
+ const fallback = this.#fallbackResolver?.(provider);
2781
+ return fallback !== undefined && fallback !== AUTHENTICATED_SENTINEL;
2782
+ }
2783
+
2751
2784
  /**
2752
2785
  * Whether a request could resolve a key for this provider, including
2753
2786
  * cross-provider env aliases (`xai-oauth` borrowing `XAI_API_KEY`).
@@ -4633,8 +4666,8 @@ export class AuthStorage {
4633
4666
  // scores are only comparable between measured windows, and the
4634
4667
  // clockless headroom fallback (0..1) must not let an account whose
4635
4668
  // usage fetch failed shadow a measured sibling.
4636
- const leftMeasured = left.usage !== null;
4637
- const rightMeasured = right.usage !== null;
4669
+ const leftMeasured = left.usageMeasured;
4670
+ const rightMeasured = right.usageMeasured;
4638
4671
  if (leftMeasured !== rightMeasured) return leftMeasured ? -1 : 1;
4639
4672
  // Required drain, descending: the account whose remaining quota must
4640
4673
  // burn fastest to avoid expiring unused at its reset comes first, so
@@ -4769,13 +4802,16 @@ export class AuthStorage {
4769
4802
  const windows = usage ? strategy.findWindowLimits(usage, args.rankingContext) : undefined;
4770
4803
  const primary = windows?.primary;
4771
4804
  const secondary = windows?.secondary;
4805
+ const usageMeasured = primary !== undefined || secondary !== undefined;
4806
+ const primaryUncapped = primary === undefined && secondary !== undefined;
4772
4807
  ranked.push({
4773
4808
  selection,
4774
4809
  usage,
4775
4810
  usageChecked,
4776
4811
  blocked,
4777
4812
  blockedUntil,
4778
- hasPriorityBoost: strategy.hasPriorityBoost?.(primary) ?? false,
4813
+ usageMeasured,
4814
+ hasPriorityBoost: strategy.hasPriorityBoost?.(primary, primaryUncapped, args.rankingContext) ?? false,
4779
4815
  planPriority: getOpenAICodexPlanPriority(usage, args.planRequirement),
4780
4816
  secondaryUsed: this.#normalizeUsageFraction(secondary),
4781
4817
  secondaryRequiredDrain: this.#computeWindowRequiredDrain(
@@ -1,26 +1,130 @@
1
- import { createApiKeyLogin } from "./api-key-login";
2
- import type { OAuthLoginCallbacks } from "./oauth/types";
1
+ import { buildModel } from "@oh-my-pi/pi-catalog/build";
2
+ import {
3
+ CLOUDFLARE_AI_GATEWAY_ANTHROPIC_BASE_URL,
4
+ CLOUDFLARE_AI_GATEWAY_BASE_URL,
5
+ CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL,
6
+ CLOUDFLARE_AI_GATEWAY_OPENAI_BASE_URL,
7
+ parseCloudflareAiGatewayCredential,
8
+ serializeCloudflareAiGatewayCredential,
9
+ } from "@oh-my-pi/pi-catalog/wire/cloudflare-ai-gateway";
10
+ import { $env } from "@oh-my-pi/pi-utils";
11
+ import * as AIError from "../error";
12
+ import { NO_AUTH_SENTINEL } from "../providers/openai-shared";
13
+ import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types";
3
14
  import type { ProviderDefinition } from "./types";
4
15
 
5
16
  const AUTH_URL = "https://developers.cloudflare.com/ai-gateway/configuration/authentication/";
6
17
 
7
- /**
8
- * Login to Cloudflare AI Gateway.
9
- *
10
- * Opens browser to Cloudflare AI Gateway authentication docs and prompts for a gateway token/API key.
11
- * Returns the API key directly (not OAuthCredentials - this isn't OAuth).
12
- */
13
- export const loginCloudflareAiGateway = createApiKeyLogin({
14
- providerLabel: "Cloudflare AI Gateway",
15
- authUrl: AUTH_URL,
16
- instructions: "Copy your Cloudflare AI Gateway token/API key. Configure account/gateway base URL in models config.",
17
- promptMessage: "Paste your Cloudflare AI Gateway token/API key",
18
- placeholder: "cf-aig-...",
19
- validation: null,
20
- });
18
+ /** Collect the gateway credential used by CLI, setup-wizard, and TUI login callers. */
19
+ export async function loginCloudflareAiGateway(options: OAuthController): Promise<string> {
20
+ if (!options.onPrompt) {
21
+ throw new AIError.OnPromptRequiredError("Cloudflare AI Gateway");
22
+ }
23
+ options.onAuth?.({
24
+ url: AUTH_URL,
25
+ instructions: "Create an AI Gateway token with Run permission, then copy it here.",
26
+ });
27
+
28
+ const apiKey = await options.onPrompt({
29
+ message: "Paste your Cloudflare AI Gateway token/API key",
30
+ placeholder: "cfut_...",
31
+ });
32
+ if (options.signal?.aborted) throw new AIError.LoginCancelledError();
33
+ if (!apiKey.trim()) throw new AIError.ApiKeyRequiredError();
34
+
35
+ const accountId = await options.onPrompt({
36
+ message: "Enter your Cloudflare account ID",
37
+ placeholder: "32-character account ID",
38
+ });
39
+ if (options.signal?.aborted) throw new AIError.LoginCancelledError();
40
+ if (!accountId.trim()) throw new AIError.ConfigurationError("Cloudflare account ID is required");
41
+
42
+ const gatewayId = await options.onPrompt({
43
+ message: "Enter your Cloudflare AI Gateway ID",
44
+ placeholder: "default",
45
+ });
46
+ if (options.signal?.aborted) throw new AIError.LoginCancelledError();
47
+ if (!gatewayId.trim()) throw new AIError.ConfigurationError("Cloudflare AI Gateway ID is required");
48
+
49
+ return serializeCloudflareAiGatewayCredential(apiKey, accountId, gatewayId);
50
+ }
21
51
 
22
52
  export const cloudflareAiGatewayProvider = {
23
53
  id: "cloudflare-ai-gateway",
24
54
  name: "Cloudflare AI Gateway",
55
+ prepareModel: model => {
56
+ const hasGatewayPlaceholders = model.baseUrl.includes("<account>") || model.baseUrl.includes("<gateway>");
57
+ if (model.id.startsWith("anthropic/")) {
58
+ const requestModelId = model.id.slice("anthropic/".length).replaceAll(".", "-");
59
+ const baseUrl = hasGatewayPlaceholders ? CLOUDFLARE_AI_GATEWAY_ANTHROPIC_BASE_URL : model.baseUrl;
60
+ if (
61
+ model.api === "anthropic-messages" &&
62
+ model.baseUrl === baseUrl &&
63
+ model.requestModelId === requestModelId
64
+ ) {
65
+ return model;
66
+ }
67
+ return {
68
+ ...model,
69
+ api: "anthropic-messages",
70
+ baseUrl,
71
+ requestModelId,
72
+ };
73
+ }
74
+ if (model.id.startsWith("openai/")) {
75
+ const requestModelId = model.id.slice("openai/".length);
76
+ const baseUrl = hasGatewayPlaceholders ? CLOUDFLARE_AI_GATEWAY_OPENAI_BASE_URL : model.baseUrl;
77
+ if (model.api === "openai-responses" && model.baseUrl === baseUrl && model.requestModelId === requestModelId) {
78
+ return model;
79
+ }
80
+ return buildModel({
81
+ ...model,
82
+ api: "openai-responses",
83
+ baseUrl,
84
+ compat: model.compatConfig,
85
+ requestModelId,
86
+ });
87
+ }
88
+ if (model.id.startsWith("workers-ai/")) {
89
+ const baseUrl = hasGatewayPlaceholders ? CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL : model.baseUrl;
90
+ if (model.api === "openai-completions" && model.baseUrl === baseUrl) {
91
+ return model;
92
+ }
93
+ return buildModel({
94
+ ...model,
95
+ api: "openai-completions",
96
+ baseUrl,
97
+ compat: model.compatConfig,
98
+ });
99
+ }
100
+ return model;
101
+ },
102
+ prepareRequest: (model, options) => {
103
+ const credential = parseCloudflareAiGatewayCredential(options.apiKey ?? $env.CLOUDFLARE_AI_GATEWAY_API_KEY ?? "");
104
+ if (!credential) return { model, options };
105
+ const accountId = credential.accountId ?? $env.CLOUDFLARE_ACCOUNT_ID;
106
+ const gatewayId = credential.gatewayId ?? $env.CLOUDFLARE_GATEWAY_ID;
107
+ let baseUrl = model.baseUrl;
108
+ if (baseUrl.startsWith(CLOUDFLARE_AI_GATEWAY_BASE_URL)) {
109
+ if (!accountId) throw new AIError.ConfigurationError("Cloudflare account ID is required");
110
+ if (!gatewayId) throw new AIError.ConfigurationError("Cloudflare AI Gateway ID is required");
111
+ baseUrl = baseUrl.replace("<account>", accountId).replace("<gateway>", gatewayId);
112
+ }
113
+
114
+ const isAnthropic = model.api === "anthropic-messages";
115
+ let headers = model.headers;
116
+ if (!isAnthropic) {
117
+ headers = { ...headers };
118
+ for (const name in headers) {
119
+ const normalized = name.toLowerCase();
120
+ if (normalized === "authorization" || normalized === "x-api-key") delete headers[name];
121
+ }
122
+ headers["cf-aig-authorization"] = `Bearer ${credential.token}`;
123
+ }
124
+ return {
125
+ model: { ...model, baseUrl, headers },
126
+ options: { ...options, apiKey: isAnthropic ? credential.token : NO_AUTH_SENTINEL },
127
+ };
128
+ },
25
129
  login: (cb: OAuthLoginCallbacks) => loginCloudflareAiGateway(cb),
26
130
  } as const satisfies ProviderDefinition;
@@ -303,10 +303,16 @@
303
303
  const message = document.getElementById("message");
304
304
 
305
305
  if (serverState.ok) {
306
+ const closeButton = document.querySelector(".btn");
306
307
  app.classList.add("success", "countdown");
307
308
  title.textContent = "Authentication Successful";
308
- message.innerHTML = "You have successfully logged in.<br>You can now close this tab.";
309
- setTimeout(() => window.close(), 3000);
309
+ message.textContent = "You have successfully logged in.";
310
+ window.close();
311
+ setTimeout(() => {
312
+ app.classList.remove("countdown");
313
+ closeButton.remove();
314
+ message.innerHTML = "You have successfully logged in.<br>Please close this tab manually.";
315
+ }, 300);
310
316
  } else {
311
317
  app.classList.add("error");
312
318
  title.textContent = "Authentication Failed";
@@ -29,6 +29,7 @@ export interface PreparedProviderRequest {
29
29
  }
30
30
 
31
31
  export type ProviderRequestPreparer = (model: Model<Api>, options: StreamOptions) => PreparedProviderRequest;
32
+ export type ProviderModelPreparer = (model: Model<Api>) => Model<Api>;
32
33
  export type ProviderSimpleOptionsMapper = (options: SimpleStreamOptions) => Readonly<Record<string, unknown>>;
33
34
 
34
35
  export interface ProviderModelDiscoveryConfig {
@@ -66,6 +67,8 @@ export interface ProviderDefinition {
66
67
  readonly envKeys?: KeyResolver;
67
68
  /** Provider transport can authenticate without a resolved API-key string. */
68
69
  readonly allowsMissingApiKey?: boolean;
70
+ /** Provider-owned model normalization that must run before API-specific option mapping. */
71
+ readonly prepareModel?: ProviderModelPreparer;
69
72
  /** Provider-owned request shaping applied before generic API dispatch. */
70
73
  readonly prepareRequest?: ProviderRequestPreparer;
71
74
  /** Provider-owned projection from the generic simple-stream option bag. */
package/src/stream.ts CHANGED
@@ -939,9 +939,10 @@ function streamDispatch<TApi extends Api>(
939
939
  return streamBedrock(model as Model<"bedrock-converse-stream">, context, requestOptions as BedrockOptions);
940
940
  }
941
941
 
942
- const prepareRequest = getProviderDefinition(model.provider)?.prepareRequest;
943
- const prepared = prepareRequest?.(model as Model<Api>, requestOptions as StreamOptions);
944
- const providerModel = prepared?.model ?? (model as Model<Api>);
942
+ const providerDefinition = getProviderDefinition(model.provider);
943
+ const requestModel = providerDefinition?.prepareModel?.(model) ?? model;
944
+ const prepared = providerDefinition?.prepareRequest?.(requestModel, requestOptions as StreamOptions);
945
+ const providerModel = prepared?.model ?? requestModel;
945
946
  const preparedOptions = prepared?.options ?? (requestOptions as StreamOptions);
946
947
  const apiKey = preparedOptions.apiKey || getEnvApiKey(providerModel.provider);
947
948
  if (!apiKey) {
@@ -1691,8 +1692,9 @@ function streamSimpleRequest<TApi extends Api>(
1691
1692
  ),
1692
1693
  );
1693
1694
  }
1694
- const providerOptions = mapOptionsForApi(model, requestOptions, apiKey);
1695
- return stream(model, context, providerOptions);
1695
+ const providerModel = getProviderDefinition(model.provider)?.prepareModel?.(model) ?? model;
1696
+ const providerOptions = mapOptionsForApi(providerModel, requestOptions, apiKey);
1697
+ return stream(providerModel, context, providerOptions);
1696
1698
  }
1697
1699
 
1698
1700
  export async function completeSimple<TApi extends Api>(
@@ -426,12 +426,19 @@ export const antigravityUsageProvider: UsageProvider = {
426
426
  supports: params => params.provider === "google-antigravity",
427
427
  };
428
428
 
429
- function getAntigravityCounterKeyForModel(context: CredentialRankingContext | undefined): string | undefined {
430
- const modelId = context?.modelId?.toLowerCase();
431
- if (!modelId) return undefined;
432
- if (modelId.startsWith("claude-")) return "anthropic";
433
- if (modelId.startsWith("gemini-") || modelId.startsWith("gemma-")) return "google";
434
- if (modelId.startsWith("gpt-") || modelId.startsWith("openai/")) return "openai";
429
+ /** Map an Antigravity model id to its backend quota-counter key. */
430
+ export function getAntigravityCounterKeyForModel(modelId: string | undefined): string | undefined {
431
+ const normalizedModelId = modelId?.toLowerCase();
432
+ if (!normalizedModelId) return undefined;
433
+ if (normalizedModelId.startsWith("claude-")) return "anthropic";
434
+ if (
435
+ normalizedModelId.startsWith("gemini-") ||
436
+ normalizedModelId.startsWith("gemma-") ||
437
+ normalizedModelId.startsWith("tab_")
438
+ ) {
439
+ return "google";
440
+ }
441
+ if (normalizedModelId.startsWith("gpt-") || normalizedModelId.startsWith("openai/")) return "openai";
435
442
  return undefined;
436
443
  }
437
444
 
@@ -440,14 +447,19 @@ function getAntigravityCounterLimits(report: UsageReport, counterKey: string): U
440
447
  return report.limits.filter(limit => limit.id.toLowerCase().startsWith(prefix));
441
448
  }
442
449
 
443
- // Exhaustion checks are only safe with a concrete backend counter. A no-model
444
- // Antigravity credential lookup (for example image-provider discovery) must
445
- // not turn one exhausted family into a provider-wide block.
446
- function scopeAntigravityLimitsForModel(
450
+ /**
451
+ * Scope an Antigravity report to the active model's backend counter, falling
452
+ * back to legacy default counters only when that backend has no limits.
453
+ *
454
+ * Exhaustion checks are only safe with a concrete backend counter. A no-model
455
+ * credential lookup (for example image-provider discovery) must not turn one
456
+ * exhausted family into a provider-wide block.
457
+ */
458
+ export function scopeAntigravityLimitsForModel(
447
459
  report: UsageReport,
448
460
  context: CredentialRankingContext | undefined,
449
461
  ): UsageLimit[] {
450
- const counterKey = getAntigravityCounterKeyForModel(context);
462
+ const counterKey = getAntigravityCounterKeyForModel(context?.modelId);
451
463
  if (!counterKey) return [];
452
464
  const backendLimits = getAntigravityCounterLimits(report, counterKey);
453
465
  if (backendLimits.length > 0) return backendLimits;
@@ -455,7 +467,7 @@ function scopeAntigravityLimitsForModel(
455
467
  }
456
468
 
457
469
  function rankAntigravityLimits(report: UsageReport, context: CredentialRankingContext | undefined): UsageLimit[] {
458
- const counterKey = getAntigravityCounterKeyForModel(context);
470
+ const counterKey = getAntigravityCounterKeyForModel(context?.modelId);
459
471
  if (!counterKey) return report.limits;
460
472
  return scopeAntigravityLimitsForModel(report, context);
461
473
  }
@@ -480,7 +492,7 @@ export const antigravityRankingStrategy: CredentialRankingStrategy = {
480
492
  // Always return a scope for Antigravity so missing/unknown model context
481
493
  // cannot fall through to AuthStorage's provider-wide block bucket.
482
494
  blockScope(context) {
483
- const counterKey = getAntigravityCounterKeyForModel(context);
495
+ const counterKey = getAntigravityCounterKeyForModel(context?.modelId);
484
496
  return `counter:${counterKey ?? "unknown"}`;
485
497
  },
486
498
  // Antigravity windows carry `durationMs` when the response identifies them
@@ -611,8 +611,11 @@ export const codexRankingStrategy: CredentialRankingStrategy = {
611
611
  return { primary: findLimit("primary"), secondary: findLimit("secondary") };
612
612
  },
613
613
  windowDefaults: { primaryMs: 60 * 60 * 1000, secondaryMs: 7 * 24 * 60 * 60 * 1000 },
614
- hasPriorityBoost(primary) {
615
- if (!primary) return false;
614
+ hasPriorityBoost(primary, primaryUncapped = false, context) {
615
+ // Chat plans can omit an uncapped primary window while retaining their
616
+ // weekly window. Spark always has a capped primary meter, so a missing
617
+ // Spark primary is incomplete rather than uncapped.
618
+ if (!primary) return primaryUncapped && !isCodexSparkRequest(context);
616
619
  const windowId = primary.scope.windowId?.toLowerCase();
617
620
  const durationMs = primary.window?.durationMs;
618
621
  const isFiveHourWindow =
package/src/usage.ts CHANGED
@@ -414,6 +414,14 @@ export interface CredentialRankingStrategy {
414
414
  primaryMs: number;
415
415
  secondaryMs: number;
416
416
  };
417
- /** Optional: priority boost for specific credential states (e.g., fresh 5h ticker start). */
418
- hasPriorityBoost?(primary: UsageLimit | undefined): boolean;
417
+ /**
418
+ * Optional: priority boost for specific credential states (e.g., fresh 5h
419
+ * ticker start). `primaryUncapped` is true only when the fetched report has
420
+ * an applicable secondary window but no applicable primary window.
421
+ */
422
+ hasPriorityBoost?(
423
+ primary: UsageLimit | undefined,
424
+ primaryUncapped?: boolean,
425
+ context?: CredentialRankingContext,
426
+ ): boolean;
419
427
  }