@oh-my-pi/pi-ai 18.0.8 → 18.0.9
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 +8 -0
- package/README.md +4 -5
- package/dist/types/auth-storage.d.ts +12 -0
- package/dist/types/registry/cloudflare-ai-gateway.d.ts +8 -8
- package/dist/types/registry/registry.d.ts +5 -0
- package/dist/types/registry/types.d.ts +3 -0
- package/dist/types/usage/google-antigravity.d.ts +12 -1
- package/dist/types/usage.d.ts +6 -2
- package/package.json +5 -5
- package/src/auth/sqlite-credential-store.ts +12 -4
- package/src/auth-storage.ts +40 -4
- package/src/registry/cloudflare-ai-gateway.ts +120 -16
- package/src/registry/oauth/oauth.html +8 -2
- package/src/registry/types.ts +3 -0
- package/src/stream.ts +7 -5
- package/src/usage/google-antigravity.ts +25 -13
- package/src/usage/openai-codex.ts +5 -2
- package/src/usage.ts +10 -2
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** (
|
|
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
|
|
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
|
|
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
|
-
|
|
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`
|
package/dist/types/usage.d.ts
CHANGED
|
@@ -528,6 +528,10 @@ export interface CredentialRankingStrategy {
|
|
|
528
528
|
primaryMs: number;
|
|
529
529
|
secondaryMs: number;
|
|
530
530
|
};
|
|
531
|
-
/**
|
|
532
|
-
|
|
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.
|
|
4
|
+
"version": "18.0.9",
|
|
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.
|
|
41
|
-
"@oh-my-pi/pi-catalog": "18.0.
|
|
42
|
-
"@oh-my-pi/pi-utils": "18.0.
|
|
43
|
-
"@oh-my-pi/pi-wire": "18.0.
|
|
40
|
+
"@oh-my-pi/omptype": "18.0.9",
|
|
41
|
+
"@oh-my-pi/pi-catalog": "18.0.9",
|
|
42
|
+
"@oh-my-pi/pi-utils": "18.0.9",
|
|
43
|
+
"@oh-my-pi/pi-wire": "18.0.9"
|
|
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
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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);
|
package/src/auth-storage.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
4637
|
-
const rightMeasured = right.
|
|
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
|
-
|
|
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 {
|
|
2
|
-
import
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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.
|
|
309
|
-
|
|
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";
|
package/src/registry/types.ts
CHANGED
|
@@ -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
|
|
943
|
-
const
|
|
944
|
-
const
|
|
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
|
|
1695
|
-
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
if (
|
|
433
|
-
if (
|
|
434
|
-
if (
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
418
|
-
|
|
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
|
}
|