@oh-my-pi/pi-ai 15.10.1 → 15.10.3
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 +28 -1
- package/dist/types/auth-storage.d.ts +24 -0
- package/dist/types/providers/__tests__/google-auth.test.d.ts +1 -0
- package/dist/types/providers/anthropic.d.ts +0 -1
- package/dist/types/providers/transform-messages.d.ts +1 -2
- package/dist/types/stream.d.ts +8 -0
- package/dist/types/utils/sse-debug.d.ts +0 -5
- package/dist/types/utils/stream-markup-healing.d.ts +2 -0
- package/package.json +2 -2
- package/src/auth-storage.ts +38 -1
- package/src/models.json +11 -11
- package/src/provider-models/descriptors.ts +1 -1
- package/src/providers/__tests__/google-auth.test.ts +144 -0
- package/src/providers/anthropic.ts +73 -40
- package/src/providers/azure-openai-responses.ts +34 -22
- package/src/providers/google-auth.ts +54 -5
- package/src/providers/openai-completions.ts +85 -27
- package/src/providers/openai-responses-server.ts +82 -55
- package/src/providers/openai-responses.ts +38 -26
- package/src/providers/transform-messages.ts +209 -120
- package/src/stream.ts +13 -2
- package/src/utils/sse-debug.ts +0 -271
- package/src/utils/stream-markup-healing.ts +6 -1
- package/src/prompts/turn-aborted-guidance.md +0 -4
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [15.10.3] - 2026-06-08
|
|
6
|
+
|
|
7
|
+
### Removed
|
|
8
|
+
|
|
9
|
+
- Removed the synthetic `<turn-aborted>` developer guidance note that `transformMessages` injected after an aborted/errored assistant turn (and its `turn-aborted-guidance.md` prompt). The per-call synthetic `"aborted"` tool results already tell the model the turn's tools were terminated, so the extra "verify current state before retrying" note was redundant — and it biased the model toward second-guessing a deliberate user interrupt when the turn was resumed.
|
|
10
|
+
- Removed the legacy Anthropic first-user-message skip for `<system-reminder>` blocks now that synthetic reminders no longer travel as user messages.
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
## [15.10.2] - 2026-06-08
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- Added support for `impersonated_service_account` Application Default Credentials (ADC) in Vertex AI to enable chained impersonation without failing via 401 `invalid_client`.
|
|
17
|
+
- Added `AuthStorage.getCredentialOrigin(provider)` (returning a structured `CredentialOrigin` / `CredentialOriginKind`) and `getEnvApiKeyName(provider)`, so callers can render where a provider's auth comes from — runtime override, config, stored OAuth/api-key, env var (with the backing variable name), or fallback resolver — without parsing the prose of `describeCredentialSource`.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- Changed `onSseEvent` recording for OpenAI Responses, Azure OpenAI Responses, OpenAI Completions, and Anthropic stream providers to emit reconstructed SSE events from decoded SDK stream items instead of wrapping raw fetch responses
|
|
22
|
+
- Changed OpenAI Completions SSE diagnostics to include `event: "chat.completion.chunk"` in `onSseEvent` records for chunked responses
|
|
23
|
+
- Changed the default Anthropic model in `DEFAULT_MODEL_PER_PROVIDER` from `claude-sonnet-4-6` to `claude-opus-4-6`, so sessions that fall back to the provider default (no configured `default` role, no `--model`, no restored session) now start on Claude Opus 4.6.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- Fixed duplicate upstream `tool_call_id` values collapsing distinct tool calls during message transformation, preserving one call/result pairing per emitted tool call before provider replay and keeping generated duplicate IDs distinct after OpenAI/Mistral wire-length caps. ([#2055](https://github.com/can1357/oh-my-pi/issues/2055))
|
|
28
|
+
- Fixed the Anthropic provider retrying persistent account usage/quota limits (e.g. `429 "This request would exceed your account's rate limit"`, `usage_limit_reached`) as if they were transient. Because the error text contains "rate limit", `isProviderRetryableError` matched it and the stream retry loop looped through its 2s/4s/8s backoff (then the `streamSimple` a/b/c policy re-minted the credential and ran the whole thing again) before surfacing the failure — even though the server's `retry-after` parked the account for minutes-to-hours. These errors are now recognized via `isUsageLimitError` and surfaced immediately to the credential-rotation layer, so e.g. `omp dry-balance --bench` reports a rate-limited account as failed at once instead of appearing to hang.
|
|
29
|
+
- Fixed MiniMax-compatible OpenAI-completions hosts losing tool-call argument content when `function.arguments` is streamed as an object across more than one delta. The accumulator added in #1776 wrote `block.partialArgs = rawArgs` per chunk, so every chunk but the last was overwritten — for an `edit` call this surfaced as a tail-slice of the patch text being applied (e.g. a single-line `replace 91..91:` body extending the deletion across the surrounding rows). Chunks are now shallow-merged; for shared string keys, `startsWith` distinguishes cumulative restatements (take the latest) from per-chunk-delta fragments (concatenate). Per-chunk `toolcall_delta` emission for the object branch is suppressed (the previous code emitted `JSON.stringify(rawArgs)` per chunk, which fed downstream concat consumers — `packages/agent/src/proxy.ts`, `openai-chat-server`, `openai-responses-server`, `anthropic-messages-server` — an invalid sequence like `{"input":"a"}{"input":"b"}`); the merged object is flushed instead as a single concat-safe delta in `finishToolCallBlock` before `toolcall_end`, so accumulators reconstruct the args correctly. The single-chunk shape covered by the existing #1776 regression test stays correct end-to-end. ([#2080](https://github.com/can1357/oh-my-pi/issues/2080))
|
|
30
|
+
- Fixed the OpenAI Responses compatibility server misrouting late `toolcall_delta` events for earlier parallel tool calls after a later `toolcall_start`. The encoder now keeps OpenFunctionCall state by content index, allocates output indexes at item start, and closes each tool item by its own `toolcall_end`, preserving deferred MiniMax object-argument flushes for the matching call. ([#2080](https://github.com/can1357/oh-my-pi/issues/2080))
|
|
31
|
+
|
|
5
32
|
## [15.10.1] - 2026-06-07
|
|
6
33
|
|
|
7
34
|
### Breaking Changes
|
|
@@ -3007,4 +3034,4 @@ _Dedicated to Peter's shoulder ([@steipete](https://twitter.com/steipete))_
|
|
|
3007
3034
|
|
|
3008
3035
|
## [0.9.4] - 2025-11-26
|
|
3009
3036
|
|
|
3010
|
-
Initial release with multi-provider LLM support.
|
|
3037
|
+
Initial release with multi-provider LLM support.
|
|
@@ -22,6 +22,21 @@ export type OAuthCredential = {
|
|
|
22
22
|
export type AuthCredential = ApiKeyCredential | OAuthCredential;
|
|
23
23
|
export type AuthCredentialEntry = AuthCredential | AuthCredential[];
|
|
24
24
|
export type AuthStorageData = Record<string, AuthCredentialEntry>;
|
|
25
|
+
/**
|
|
26
|
+
* Cascade leg that supplies a provider's active credential, highest precedence
|
|
27
|
+
* first — mirrors {@link AuthStorage.getApiKey}'s resolution order.
|
|
28
|
+
*/
|
|
29
|
+
export type CredentialOriginKind = "runtime" | "config" | "oauth" | "api_key" | "env" | "fallback";
|
|
30
|
+
/**
|
|
31
|
+
* Structured provenance for a provider's auth, for UI that needs a machine
|
|
32
|
+
* tag (the `/login` provider list) rather than the prose of
|
|
33
|
+
* {@link AuthStorage.describeCredentialSource}.
|
|
34
|
+
*/
|
|
35
|
+
export interface CredentialOrigin {
|
|
36
|
+
kind: CredentialOriginKind;
|
|
37
|
+
/** Env var name when `kind === "env"` and a single named variable backs it. */
|
|
38
|
+
envVar?: string;
|
|
39
|
+
}
|
|
25
40
|
/**
|
|
26
41
|
* Serialized representation of AuthStorage for passing to subagent workers.
|
|
27
42
|
* Contains only the essential credential data, not runtime state.
|
|
@@ -525,6 +540,15 @@ export declare class AuthStorage {
|
|
|
525
540
|
* silently satisfies xai-oauth and routes around `providers.xai.baseUrl`.
|
|
526
541
|
*/
|
|
527
542
|
hasNonEnvCredential(provider: string): boolean;
|
|
543
|
+
/**
|
|
544
|
+
* Classify where a provider's auth comes from, following the same precedence
|
|
545
|
+
* as {@link AuthStorage.getApiKey}: runtime override → config override →
|
|
546
|
+
* stored credential (api_key before oauth, matching getApiKey) → env var →
|
|
547
|
+
* fallback resolver. Returns undefined when no auth is configured.
|
|
548
|
+
*
|
|
549
|
+
* Compact, structured counterpart to {@link describeCredentialSource}.
|
|
550
|
+
*/
|
|
551
|
+
getCredentialOrigin(provider: string): CredentialOrigin | undefined;
|
|
528
552
|
/**
|
|
529
553
|
* Check if OAuth credentials are configured for a provider.
|
|
530
554
|
*/
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -117,7 +117,6 @@ export type AnthropicClientOptionsArgs = {
|
|
|
117
117
|
hasTools?: boolean;
|
|
118
118
|
thinkingEnabled?: boolean;
|
|
119
119
|
thinkingDisplay?: AnthropicThinkingDisplay;
|
|
120
|
-
onSseEvent?: AnthropicOptions["onSseEvent"];
|
|
121
120
|
fetch?: FetchImpl;
|
|
122
121
|
claudeCodeSessionId?: string;
|
|
123
122
|
};
|
|
@@ -7,6 +7,5 @@ import type { Api, AssistantMessage, Message, Model } from "../types";
|
|
|
7
7
|
* For aborted/errored turns, this function:
|
|
8
8
|
* - Preserves tool call structure (unlike converting to text summaries)
|
|
9
9
|
* - Injects synthetic "aborted" tool results
|
|
10
|
-
* - Adds a <turn-aborted> guidance marker for the model
|
|
11
10
|
*/
|
|
12
|
-
export declare function transformMessages<TApi extends Api>(messages: Message[], model: Model<TApi>, normalizeToolCallId?: (id: string, model: Model<TApi>, source: AssistantMessage) => string): Message[];
|
|
11
|
+
export declare function transformMessages<TApi extends Api>(messages: Message[], model: Model<TApi>, normalizeToolCallId?: (id: string, model: Model<TApi>, source: AssistantMessage) => string, maxNormalizedToolCallIdLength?: number, duplicateToolCallIdSuffixPrefix?: string): Message[];
|
package/dist/types/stream.d.ts
CHANGED
|
@@ -12,6 +12,14 @@ import { AssistantMessageEventStream } from "./utils/event-stream";
|
|
|
12
12
|
* Checks Bun.env, then cwd/.env, then ~/.env.
|
|
13
13
|
*/
|
|
14
14
|
export declare function getEnvApiKey(provider: string): string | undefined;
|
|
15
|
+
/**
|
|
16
|
+
* Name of the environment variable that backs `getEnvApiKey` for a provider,
|
|
17
|
+
* when that provider maps to a single named variable (e.g. `github-copilot` →
|
|
18
|
+
* `COPILOT_GITHUB_TOKEN`). Returns undefined for providers whose env fallback
|
|
19
|
+
* is computed (multi-var pickers, Vertex ADC / Bedrock probes, …) since no
|
|
20
|
+
* single variable name describes the source.
|
|
21
|
+
*/
|
|
22
|
+
export declare function getEnvApiKeyName(provider: string): string | undefined;
|
|
15
23
|
/**
|
|
16
24
|
* Enumerate every provider that has an env-var fallback for `getEnvApiKey`.
|
|
17
25
|
* Used by `omp auth-broker migrate --include-env` to discover env-sourced keys
|
|
@@ -1,10 +1,5 @@
|
|
|
1
1
|
import type { ServerSentEvent } from "@oh-my-pi/pi-utils";
|
|
2
2
|
import type { RawSseEvent } from "../types";
|
|
3
|
-
type FetchFunction = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
4
|
-
type FetchWithPreconnect = FetchFunction & {
|
|
5
|
-
preconnect?: typeof fetch.preconnect;
|
|
6
|
-
};
|
|
7
3
|
type RawSseObserver = (event: RawSseEvent) => void;
|
|
8
4
|
export declare function notifyRawSseEvent(observer: RawSseObserver | undefined, event: ServerSentEvent | RawSseEvent): void;
|
|
9
|
-
export declare function wrapFetchForSseDebug(fetchImpl: FetchWithPreconnect, observer: RawSseObserver | undefined): FetchWithPreconnect;
|
|
10
5
|
export {};
|
|
@@ -75,6 +75,8 @@ export declare class StreamMarkupHealing {
|
|
|
75
75
|
export declare function modelMayLeakKimiToolCalls(provider: string, modelId: string): boolean;
|
|
76
76
|
/** Cheap model/provider gate for DeepSeek DSML envelope leaks. */
|
|
77
77
|
export declare function modelMayLeakDsmlToolCalls(provider: string, modelId: string): boolean;
|
|
78
|
+
/** Cheap model/provider gate for MiniMax plain thinking tag leaks. */
|
|
79
|
+
export declare function modelMayLeakThinkingTags(provider: string, modelId: string): boolean;
|
|
78
80
|
export declare function getStreamMarkupHealingPattern(provider: string, modelId: string, options?: {
|
|
79
81
|
readonly parseThinkingTags?: boolean;
|
|
80
82
|
}): StreamMarkupHealingPattern | undefined;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-ai",
|
|
4
|
-
"version": "15.10.
|
|
4
|
+
"version": "15.10.3",
|
|
5
5
|
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"@bufbuild/protobuf": "^2.12.0",
|
|
42
|
-
"@oh-my-pi/pi-utils": "15.10.
|
|
42
|
+
"@oh-my-pi/pi-utils": "15.10.3",
|
|
43
43
|
"openai": "^6.39.0",
|
|
44
44
|
"partial-json": "^0.1.7",
|
|
45
45
|
"zod": "4.4.3"
|
package/src/auth-storage.ts
CHANGED
|
@@ -13,7 +13,7 @@ import * as path from "node:path";
|
|
|
13
13
|
import { getAgentDbPath, logger } from "@oh-my-pi/pi-utils";
|
|
14
14
|
import type { ApiKeyResolver } from "./auth-retry";
|
|
15
15
|
import { isUsageLimitError } from "./rate-limit-utils";
|
|
16
|
-
import { getEnvApiKey } from "./stream";
|
|
16
|
+
import { getEnvApiKey, getEnvApiKeyName } from "./stream";
|
|
17
17
|
import type { Provider } from "./types";
|
|
18
18
|
import type {
|
|
19
19
|
CredentialRankingStrategy,
|
|
@@ -59,6 +59,23 @@ export type AuthCredentialEntry = AuthCredential | AuthCredential[];
|
|
|
59
59
|
|
|
60
60
|
export type AuthStorageData = Record<string, AuthCredentialEntry>;
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Cascade leg that supplies a provider's active credential, highest precedence
|
|
64
|
+
* first — mirrors {@link AuthStorage.getApiKey}'s resolution order.
|
|
65
|
+
*/
|
|
66
|
+
export type CredentialOriginKind = "runtime" | "config" | "oauth" | "api_key" | "env" | "fallback";
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Structured provenance for a provider's auth, for UI that needs a machine
|
|
70
|
+
* tag (the `/login` provider list) rather than the prose of
|
|
71
|
+
* {@link AuthStorage.describeCredentialSource}.
|
|
72
|
+
*/
|
|
73
|
+
export interface CredentialOrigin {
|
|
74
|
+
kind: CredentialOriginKind;
|
|
75
|
+
/** Env var name when `kind === "env"` and a single named variable backs it. */
|
|
76
|
+
envVar?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
62
79
|
/**
|
|
63
80
|
* Serialized representation of AuthStorage for passing to subagent workers.
|
|
64
81
|
* Contains only the essential credential data, not runtime state.
|
|
@@ -1430,6 +1447,26 @@ export class AuthStorage {
|
|
|
1430
1447
|
return false;
|
|
1431
1448
|
}
|
|
1432
1449
|
|
|
1450
|
+
/**
|
|
1451
|
+
* Classify where a provider's auth comes from, following the same precedence
|
|
1452
|
+
* as {@link AuthStorage.getApiKey}: runtime override → config override →
|
|
1453
|
+
* stored credential (api_key before oauth, matching getApiKey) → env var →
|
|
1454
|
+
* fallback resolver. Returns undefined when no auth is configured.
|
|
1455
|
+
*
|
|
1456
|
+
* Compact, structured counterpart to {@link describeCredentialSource}.
|
|
1457
|
+
*/
|
|
1458
|
+
getCredentialOrigin(provider: string): CredentialOrigin | undefined {
|
|
1459
|
+
if (this.#runtimeOverrides.has(provider)) return { kind: "runtime" };
|
|
1460
|
+
if (this.#configOverrides.has(provider)) return { kind: "config" };
|
|
1461
|
+
const stored = this.#getCredentialsForProvider(provider);
|
|
1462
|
+
if (stored.length > 0) {
|
|
1463
|
+
return { kind: stored.some(credential => credential.type === "api_key") ? "api_key" : "oauth" };
|
|
1464
|
+
}
|
|
1465
|
+
if (getEnvApiKey(provider)) return { kind: "env", envVar: getEnvApiKeyName(provider) };
|
|
1466
|
+
if (this.#fallbackResolver?.(provider)) return { kind: "fallback" };
|
|
1467
|
+
return undefined;
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1433
1470
|
/**
|
|
1434
1471
|
* Check if OAuth credentials are configured for a provider.
|
|
1435
1472
|
*/
|
package/src/models.json
CHANGED
|
@@ -13270,7 +13270,7 @@
|
|
|
13270
13270
|
},
|
|
13271
13271
|
"minimax/minimax-m3": {
|
|
13272
13272
|
"id": "minimax/minimax-m3",
|
|
13273
|
-
"name": "MiniMax: MiniMax M3
|
|
13273
|
+
"name": "MiniMax: MiniMax M3",
|
|
13274
13274
|
"api": "openai-completions",
|
|
13275
13275
|
"provider": "kilo",
|
|
13276
13276
|
"baseUrl": "https://api.kilo.ai/api/gateway",
|
|
@@ -41929,8 +41929,8 @@
|
|
|
41929
41929
|
"image"
|
|
41930
41930
|
],
|
|
41931
41931
|
"cost": {
|
|
41932
|
-
"input": 0.
|
|
41933
|
-
"output": 0.
|
|
41932
|
+
"input": 0.049999999999999996,
|
|
41933
|
+
"output": 0.15,
|
|
41934
41934
|
"cacheRead": 0,
|
|
41935
41935
|
"cacheWrite": 0
|
|
41936
41936
|
},
|
|
@@ -42490,7 +42490,7 @@
|
|
|
42490
42490
|
"image"
|
|
42491
42491
|
],
|
|
42492
42492
|
"cost": {
|
|
42493
|
-
"input": 0.
|
|
42493
|
+
"input": 0.09999999999999999,
|
|
42494
42494
|
"output": 0.3,
|
|
42495
42495
|
"cacheRead": 0,
|
|
42496
42496
|
"cacheWrite": 0
|
|
@@ -43439,7 +43439,7 @@
|
|
|
43439
43439
|
"text"
|
|
43440
43440
|
],
|
|
43441
43441
|
"cost": {
|
|
43442
|
-
"input": 0.
|
|
43442
|
+
"input": 0.39999999999999997,
|
|
43443
43443
|
"output": 0.39999999999999997,
|
|
43444
43444
|
"cacheRead": 0,
|
|
43445
43445
|
"cacheWrite": 0
|
|
@@ -45516,7 +45516,7 @@
|
|
|
45516
45516
|
"text"
|
|
45517
45517
|
],
|
|
45518
45518
|
"cost": {
|
|
45519
|
-
"input": 0.
|
|
45519
|
+
"input": 0.09,
|
|
45520
45520
|
"output": 0.09999999999999999,
|
|
45521
45521
|
"cacheRead": 0,
|
|
45522
45522
|
"cacheWrite": 0
|
|
@@ -45564,8 +45564,8 @@
|
|
|
45564
45564
|
"text"
|
|
45565
45565
|
],
|
|
45566
45566
|
"cost": {
|
|
45567
|
-
"input": 0.
|
|
45568
|
-
"output": 0.
|
|
45567
|
+
"input": 0.12,
|
|
45568
|
+
"output": 0.5,
|
|
45569
45569
|
"cacheRead": 0,
|
|
45570
45570
|
"cacheWrite": 0
|
|
45571
45571
|
},
|
|
@@ -46226,13 +46226,13 @@
|
|
|
46226
46226
|
"image"
|
|
46227
46227
|
],
|
|
46228
46228
|
"cost": {
|
|
46229
|
-
"input": 0.
|
|
46229
|
+
"input": 0.09999999999999999,
|
|
46230
46230
|
"output": 0.15,
|
|
46231
46231
|
"cacheRead": 0,
|
|
46232
46232
|
"cacheWrite": 0
|
|
46233
46233
|
},
|
|
46234
46234
|
"contextWindow": 262144,
|
|
46235
|
-
"maxTokens":
|
|
46235
|
+
"maxTokens": 262144,
|
|
46236
46236
|
"thinking": {
|
|
46237
46237
|
"mode": "effort",
|
|
46238
46238
|
"minLevel": "minimal",
|
|
@@ -49976,7 +49976,7 @@
|
|
|
49976
49976
|
"cacheRead": 0,
|
|
49977
49977
|
"cacheWrite": 0
|
|
49978
49978
|
},
|
|
49979
|
-
"contextWindow":
|
|
49979
|
+
"contextWindow": 256000,
|
|
49980
49980
|
"maxTokens": 8888,
|
|
49981
49981
|
"compat": {
|
|
49982
49982
|
"supportsUsageInStreaming": false
|
|
@@ -132,7 +132,7 @@ function catalogDescriptor(
|
|
|
132
132
|
* openai-codex) are handled separately because they require different config shapes.
|
|
133
133
|
*/
|
|
134
134
|
export const PROVIDER_DESCRIPTORS: readonly ProviderDescriptor[] = [
|
|
135
|
-
descriptor("anthropic", "claude-
|
|
135
|
+
descriptor("anthropic", "claude-opus-4-6", config => anthropicModelManagerOptions(config)),
|
|
136
136
|
catalogDescriptor(
|
|
137
137
|
"alibaba-coding-plan",
|
|
138
138
|
"qwen3.5-plus",
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { Buffer } from "node:buffer";
|
|
3
|
+
import * as fs from "node:fs/promises";
|
|
4
|
+
import * as os from "node:os";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
import type { FetchImpl } from "../../types";
|
|
7
|
+
import { __resetVertexTokenCache, getVertexAccessToken } from "../google-auth";
|
|
8
|
+
|
|
9
|
+
const CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform";
|
|
10
|
+
const JWT_BEARER_GRANT = "urn:ietf:params:oauth:grant-type:jwt-bearer";
|
|
11
|
+
|
|
12
|
+
/** Generate a real RS256 private key so signJwtRs256 / pemToPkcs8 run for real. */
|
|
13
|
+
async function generateServiceAccountPem(): Promise<string> {
|
|
14
|
+
const keyPair = (await globalThis.crypto.subtle.generateKey(
|
|
15
|
+
{ name: "RSASSA-PKCS1-v1_5", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-256" },
|
|
16
|
+
true,
|
|
17
|
+
["sign", "verify"],
|
|
18
|
+
)) as CryptoKeyPair;
|
|
19
|
+
const pkcs8 = new Uint8Array(await globalThis.crypto.subtle.exportKey("pkcs8", keyPair.privateKey));
|
|
20
|
+
const body = (
|
|
21
|
+
Buffer.from(pkcs8)
|
|
22
|
+
.toString("base64")
|
|
23
|
+
.match(/.{1,64}/g) ?? []
|
|
24
|
+
).join("\n");
|
|
25
|
+
return `-----BEGIN PRIVATE KEY-----\n${body}\n-----END PRIVATE KEY-----\n`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function urlOf(input: string | URL | Request): string {
|
|
29
|
+
if (typeof input === "string") return input;
|
|
30
|
+
if (input instanceof URL) return input.toString();
|
|
31
|
+
return input.url;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("getVertexAccessToken impersonated_service_account ADC", () => {
|
|
35
|
+
let tmpDir: string;
|
|
36
|
+
let originalGac: string | undefined;
|
|
37
|
+
|
|
38
|
+
beforeEach(async () => {
|
|
39
|
+
__resetVertexTokenCache();
|
|
40
|
+
originalGac = Bun.env.GOOGLE_APPLICATION_CREDENTIALS;
|
|
41
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-vertex-adc-"));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterEach(async () => {
|
|
45
|
+
__resetVertexTokenCache();
|
|
46
|
+
if (originalGac === undefined) delete Bun.env.GOOGLE_APPLICATION_CREDENTIALS;
|
|
47
|
+
else Bun.env.GOOGLE_APPLICATION_CREDENTIALS = originalGac;
|
|
48
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("rejects a malformed service_account_impersonation_url before any network call", async () => {
|
|
52
|
+
const adcPath = path.join(tmpDir, "impersonated-bad-url.json");
|
|
53
|
+
await Bun.write(
|
|
54
|
+
adcPath,
|
|
55
|
+
JSON.stringify({
|
|
56
|
+
type: "impersonated_service_account",
|
|
57
|
+
// Missing the trailing ":generateAccessToken" the principal parser requires.
|
|
58
|
+
service_account_impersonation_url:
|
|
59
|
+
"https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/target@project.iam.gserviceaccount.com",
|
|
60
|
+
source_credentials: {
|
|
61
|
+
type: "authorized_user",
|
|
62
|
+
client_id: "client-id",
|
|
63
|
+
client_secret: "client-secret",
|
|
64
|
+
refresh_token: "refresh-token",
|
|
65
|
+
},
|
|
66
|
+
}),
|
|
67
|
+
);
|
|
68
|
+
Bun.env.GOOGLE_APPLICATION_CREDENTIALS = adcPath;
|
|
69
|
+
|
|
70
|
+
const calls: string[] = [];
|
|
71
|
+
const fetchImpl: FetchImpl = async input => {
|
|
72
|
+
calls.push(urlOf(input));
|
|
73
|
+
return new Response("{}");
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// The principal is parsed before the source exchange, so a bad URL must fail
|
|
77
|
+
// up front rather than after burning a source-token round trip.
|
|
78
|
+
await expect(getVertexAccessToken({ fetch: fetchImpl })).rejects.toBeInstanceOf(RangeError);
|
|
79
|
+
expect(calls).toEqual([]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("signs an RS256 JWT for a service_account source and reconstructs the IAM URL", async () => {
|
|
83
|
+
const pem = await generateServiceAccountPem();
|
|
84
|
+
const adcPath = path.join(tmpDir, "impersonated-sa.json");
|
|
85
|
+
await Bun.write(
|
|
86
|
+
adcPath,
|
|
87
|
+
JSON.stringify({
|
|
88
|
+
type: "impersonated_service_account",
|
|
89
|
+
// Non-canonical project segment proves the request URL is rebuilt, not echoed.
|
|
90
|
+
service_account_impersonation_url:
|
|
91
|
+
"https://iamcredentials.googleapis.com/v1/projects/explicit-proj/serviceAccounts/target@project.iam.gserviceaccount.com:generateAccessToken",
|
|
92
|
+
source_credentials: {
|
|
93
|
+
type: "service_account",
|
|
94
|
+
client_email: "source@project.iam.gserviceaccount.com",
|
|
95
|
+
private_key: pem,
|
|
96
|
+
private_key_id: "key-1",
|
|
97
|
+
},
|
|
98
|
+
// delegates intentionally omitted — the IAM body must default to [].
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
Bun.env.GOOGLE_APPLICATION_CREDENTIALS = adcPath;
|
|
102
|
+
|
|
103
|
+
const calls: { url: string; init?: RequestInit }[] = [];
|
|
104
|
+
const fetchImpl: FetchImpl = async (input, init) => {
|
|
105
|
+
const url = urlOf(input);
|
|
106
|
+
calls.push({ url, init });
|
|
107
|
+
if (url === "https://oauth2.googleapis.com/token") {
|
|
108
|
+
return new Response(JSON.stringify({ access_token: "sa-source-token", expires_in: 3600 }));
|
|
109
|
+
}
|
|
110
|
+
if (url.startsWith("https://iamcredentials.googleapis.com/")) {
|
|
111
|
+
return new Response(
|
|
112
|
+
JSON.stringify({
|
|
113
|
+
accessToken: "impersonated-token",
|
|
114
|
+
expireTime: new Date(Date.now() + 3_600_000).toISOString(),
|
|
115
|
+
}),
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
return new Response("unexpected", { status: 404 });
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const token = await getVertexAccessToken({ fetch: fetchImpl });
|
|
122
|
+
expect(token).toBe("impersonated-token");
|
|
123
|
+
|
|
124
|
+
// Source JWT exchange happens first, then the impersonation exchange.
|
|
125
|
+
expect(calls.map(c => c.url)).toEqual([
|
|
126
|
+
"https://oauth2.googleapis.com/token",
|
|
127
|
+
"https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/target@project.iam.gserviceaccount.com:generateAccessToken",
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
// Source credential is exchanged via a signed JWT bearer assertion, not a refresh grant.
|
|
131
|
+
const sourceBody = new URLSearchParams(String(calls[0].init?.body));
|
|
132
|
+
expect(sourceBody.get("grant_type")).toBe(JWT_BEARER_GRANT);
|
|
133
|
+
expect((sourceBody.get("assertion") ?? "").split(".")).toHaveLength(3);
|
|
134
|
+
|
|
135
|
+
// The IAM call carries the source-derived bearer token and defaults delegates to [].
|
|
136
|
+
const iamHeaders = calls[1].init?.headers as Record<string, string>;
|
|
137
|
+
expect(iamHeaders.Authorization).toBe("Bearer sa-source-token");
|
|
138
|
+
expect(JSON.parse(String(calls[1].init?.body))).toEqual({
|
|
139
|
+
delegates: [],
|
|
140
|
+
scope: [CLOUD_PLATFORM_SCOPE],
|
|
141
|
+
lifetime: "3600s",
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
});
|